carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
4 "encoding/json"
5 "fmt"
6 "reflect"
7 "sort"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/provision"
13)
14
15const (
16 offTestHost = "a.go.example.com"
17 offTestOp = "mv_abcdefghijklmnopqrstuvwxyz"
18)
19
20// planManifestIDs are the rows of the plan's "The dependency list" table, in
21// its order. A row added to or dropped from OfflineManifest without the plan
22// (and this list) fails TestOfflineManifestIsCovered.
23var planManifestIDs = []string{
24 "db", "db-tail", "build-state", "lease", "files", "env", "key-adoption", "config", "env-pin", "release",
25 "store", "timers", "next-due", "replication", "row", "routing", "source-retire", "sidecar", "restore-verify",
26 "warm-cache", "receipts", "class",
27}
28
29// planManifestPairs pins each row's Owner (who proves it where it must end
30// up) and Source (who reports its expected value on the source side). A
31// reassignment fails here, so it is a decision and not a drift. Rows the
32// sealer alone produces (db-tail, build-state, sidecar) are owned by the seal;
33// every carried outage item is owned by the landing and sourced by the seal.
34var planManifestPairs = map[string][2]string{
35 "db": {OfflineOwnerLanding, OfflineSourceSeal}, "db-tail": {OfflineOwnerSeal, OfflineSourceSeal},
36 "build-state": {OfflineOwnerSeal, OfflineSourceSeal}, "lease": {OfflineOwnerLanding, OfflineSourceSeal},
37 "files": {OfflineOwnerLanding, OfflineSourceSeal}, "env": {OfflineOwnerLanding, OfflineSourceSeal},
38 "key-adoption": {OfflineOwnerLanding, ""}, "config": {OfflineOwnerLanding, ""},
39 "env-pin": {OfflineOwnerLanding, OfflineSourceSeal}, "release": {OfflineOwnerLanding, OfflineSourceSeal},
40 "store": {OfflineOwnerNone, ""}, "timers": {OfflineOwnerLanding, OfflineSourceSeal},
41 "next-due": {OfflineOwnerLanding, OfflineSourceSeal}, "replication": {OfflineOwnerLanding, OfflineSourceSeal},
42 "row": {OfflineOwnerLanding, ""}, "routing": {OfflineOwnerNone, ""}, "source-retire": {OfflineOwnerNone, ""},
43 "sidecar": {OfflineOwnerSeal, OfflineSourceSeal}, "restore-verify": {OfflineOwnerNone, ""},
44 "warm-cache": {OfflineOwnerLanding, ""}, "receipts": {OfflineOwnerNone, ""}, "class": {OfflineOwnerDoor, ""},
45}
46
47var offlineDispositionKinds = []string{"carried", "rebuilt", "refused", "parked", "not carried"}
48
49// validateOfflineManifest is the checklist rule: every row has an id, a thing
50// it names, a disposition that begins with its kind and gives its reason, and
51// exactly one owner; no id repeats.
52func validateOfflineManifest(rows []OfflineItem) error {
53 seen := map[string]bool{}
54 owners := map[string]bool{OfflineOwnerDoor: true, OfflineOwnerSeal: true, OfflineOwnerLanding: true, OfflineOwnerNone: true}
55 for i, it := range rows {
56 switch {
57 case it.ID == "":
58 return fmt.Errorf("row %d has no id", i)
59 case seen[it.ID]:
60 return fmt.Errorf("row %q is listed twice", it.ID)
61 case it.Item == "":
62 return fmt.Errorf("row %q names no item", it.ID)
63 case !owners[it.Owner]:
64 return fmt.Errorf("row %q has owner %q, which is not door, seal, landing or none", it.ID, it.Owner)
65 case it.Source != "" && it.Source != OfflineSourceSeal:
66 return fmt.Errorf("row %q has source %q, which is neither seal nor empty", it.ID, it.Source)
67 }
68 seen[it.ID] = true
69 kind := ""
70 for _, k := range offlineDispositionKinds {
71 if strings.HasPrefix(it.Disposition, k+": ") && len(it.Disposition) > len(k)+2 {
72 kind = k
73 }
74 }
75 if kind == "" {
76 return fmt.Errorf("row %q has disposition %q, which does not begin with a kind and a reason", it.ID, it.Disposition)
77 }
78 if strings.ContainsRune(it.Disposition, '\u2014') || strings.ContainsRune(it.Item, '\u2014') {
79 return fmt.Errorf("row %q has an em dash", it.ID)
80 }
81 }
82 return nil
83}
84
85func TestOfflineManifestIsCovered(t *testing.T) {
86 if err := validateOfflineManifest(OfflineManifest); err != nil {
87 t.Fatal(err)
88 }
89 var ids []string
90 for _, it := range OfflineManifest {
91 ids = append(ids, it.ID)
92 }
93 if !reflect.DeepEqual(ids, planManifestIDs) {
94 t.Fatalf("the manifest's rows are not the plan's:\n got %v\nwant %v", ids, planManifestIDs)
95 }
96
97 // Owner and Source of every row are the pinned pair, and the pins cover
98 // every row.
99 if len(planManifestPairs) != len(OfflineManifest) {
100 t.Fatalf("%d pinned pairs for %d rows", len(planManifestPairs), len(OfflineManifest))
101 }
102 for _, it := range OfflineManifest {
103 want, ok := planManifestPairs[it.ID]
104 if !ok || it.Owner != want[0] || it.Source != want[1] {
105 t.Errorf("%s: owner %q source %q, pinned %v", it.ID, it.Owner, it.Source, want)
106 }
107 }
108 // The outage items: every carried item the target must have is required
109 // at open (owner landing) and reported at flip (source seal).
110 for _, id := range []string{"db", "lease", "files", "env", "timers", "next-due", "replication"} {
111 it, _ := offlineItem(id)
112 if it.Owner != OfflineOwnerLanding || it.Source != OfflineSourceSeal {
113 t.Errorf("%s is not proven on the target and reported by the seal: %+v", id, it)
114 }
115 }
116 var sourced []string
117 for _, it := range OfflineSourced() {
118 sourced = append(sourced, it.ID)
119 }
120 wantSourced := []string{"db", "db-tail", "build-state", "lease", "files", "env", "env-pin", "release", "timers", "next-due", "replication", "sidecar"}
121 if !reflect.DeepEqual(sourced, wantSourced) {
122 t.Fatalf("OfflineSourced: %v, want %v", sourced, wantSourced)
123 }
124
125 // Every row is owned by exactly one of the four: OfflineOwned partitions
126 // the manifest.
127 total := 0
128 for _, owner := range []string{OfflineOwnerDoor, OfflineOwnerSeal, OfflineOwnerLanding, OfflineOwnerNone} {
129 rows := OfflineOwned(owner)
130 total += len(rows)
131 for _, it := range rows {
132 if got, ok := OfflineItemOwner(it.ID); !ok || got != owner {
133 t.Errorf("OfflineItemOwner(%q) = %q, %v; OfflineOwned(%q) lists it", it.ID, got, ok, owner)
134 }
135 }
136 }
137 if total != len(OfflineManifest) {
138 t.Fatalf("the owners cover %d of %d rows", total, len(OfflineManifest))
139 }
140 if len(OfflineOwned(OfflineOwnerSeal)) == 0 || len(OfflineOwned(OfflineOwnerLanding)) == 0 || len(OfflineOwned(OfflineOwnerDoor)) == 0 {
141 t.Fatal("a stage owns nothing: the doors would have nothing to wait for")
142 }
143 if _, ok := OfflineItemOwner("nope"); ok {
144 t.Fatal("an unlisted item has an owner")
145 }
146 // An item nothing checks must say why it is not checked.
147 for _, it := range OfflineOwned(OfflineOwnerNone) {
148 if !strings.Contains(it.Disposition, ": ") {
149 t.Errorf("%s: owner none with no reason", it.ID)
150 }
151 }
152}
153
154// The checklist rule must bite: a row added with no owner, a duplicate, an
155// unknown owner or a disposition with no reason fails.
156func TestOfflineManifestRuleBites(t *testing.T) {
157 good := OfflineItem{"extra", "something", "carried: because", OfflineOwnerSeal, OfflineSourceSeal}
158 if err := validateOfflineManifest(append(append([]OfflineItem(nil), OfflineManifest...), good)); err != nil {
159 t.Fatalf("a well-formed extra row: %v", err)
160 }
161 for name, bad := range map[string]OfflineItem{
162 "no owner": {"extra", "something", "carried: because", "", ""},
163 "unknown owner": {"extra", "something", "carried: because", "operator", ""},
164 "no id": {"", "something", "carried: because", OfflineOwnerSeal, ""},
165 "duplicate id": {"db", "something", "carried: because", OfflineOwnerSeal, ""},
166 "no item": {"extra", "", "carried: because", OfflineOwnerSeal, ""},
167 "no reason": {"extra", "something", "carried", OfflineOwnerSeal, ""},
168 "empty reason": {"extra", "something", "carried: ", OfflineOwnerSeal, ""},
169 "unknown kind": {"extra", "something", "forgotten: because", OfflineOwnerSeal, ""},
170 "unknown source": {"extra", "something", "carried: because", OfflineOwnerSeal, "operator"},
171 "em dash in a row": {"extra", "something", "carried: because \u2014 reasons", OfflineOwnerSeal, ""},
172 } {
173 if err := validateOfflineManifest(append(append([]OfflineItem(nil), OfflineManifest...), bad)); err == nil {
174 t.Errorf("%s: the rule accepted the row", name)
175 }
176 }
177}
178
179// A report may carry only items the manifest lists, and only the ones its
180// role carries: the source side carries what the seal reports, the target
181// what the landing owns.
182func TestOfflineReportItemsAreTheManifests(t *testing.T) {
183 base := func(role string) OfflineReport {
184 return OfflineReport{V: OfflineReportVersion, Op: offTestOp, Host: offTestHost, Box: "box-a", Role: role, Phase: OfflinePhaseSealed,
185 Items: map[string]Check{}, At: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}
186 }
187 sealer := base(OfflineRoleSealer)
188 for _, it := range OfflineSourced() {
189 sealer.Items[it.ID] = Check{OK: true}
190 }
191 if err := ValidateOfflineReport(sealer); err != nil {
192 t.Fatalf("a full sealer report: %v", err)
193 }
194 if ok, bad := sealer.SourceItemsOK(); !ok || len(bad) != 0 {
195 t.Fatalf("source items: %v %v", ok, bad)
196 }
197 // A sealer report does not satisfy the open door, whatever it carries.
198 if ok, bad := sealer.ItemsOK(OfflineOwnerLanding); ok || !reflect.DeepEqual(bad, []string{"key-adoption", "config", "row", "warm-cache"}) {
199 t.Fatalf("a sealer report satisfies the landing's items: %v %v", ok, bad)
200 }
201 // A not-OK seal item is named, in manifest order.
202 sealer.Items["files"] = Check{OK: false, Reason: "no archive"}
203 delete(sealer.Items, "db")
204 if ok, bad := sealer.SourceItemsOK(); ok || !reflect.DeepEqual(bad, []string{"db", "files"}) {
205 t.Fatalf("SourceItemsOK: %v %v", ok, bad)
206 }
207
208 // The open door's set includes every outage item, so a target with no
209 // files directory cannot pass it.
210 target := base(OfflineRoleTarget)
211 target.Phase = OfflinePhaseLanded
212 for _, it := range OfflineOwned(OfflineOwnerLanding) {
213 target.Items[it.ID] = Check{OK: true}
214 }
215 if err := ValidateOfflineReport(target); err != nil {
216 t.Fatalf("a full target report: %v", err)
217 }
218 if ok, bad := target.ItemsOK(OfflineOwnerLanding); !ok || len(bad) != 0 {
219 t.Fatalf("a full target report: %v %v", ok, bad)
220 }
221 for _, id := range []string{"db", "lease", "files", "env", "timers", "next-due", "replication"} {
222 missing := target
223 missing.Items = map[string]Check{}
224 for k, v := range target.Items {
225 missing.Items[k] = v
226 }
227 delete(missing.Items, id)
228 if ok, bad := missing.ItemsOK(OfflineOwnerLanding); ok || !reflect.DeepEqual(bad, []string{id}) {
229 t.Errorf("open passes a target report without %s: %v %v", id, ok, bad)
230 }
231 failed := missing
232 failed.Items = map[string]Check{}
233 for k, v := range target.Items {
234 failed.Items[k] = v
235 }
236 failed.Items[id] = Check{OK: false, Reason: "not there"}
237 if ok, bad := failed.ItemsOK(OfflineOwnerLanding); ok || !reflect.DeepEqual(bad, []string{id}) {
238 t.Errorf("open passes a target report with %s failed: %v %v", id, ok, bad)
239 }
240 }
241
242 // An item the manifest does not list fails the report.
243 rogue := base(OfflineRoleSealer)
244 rogue.Items["made-up"] = Check{OK: true}
245 if err := ValidateOfflineReport(rogue); err == nil || !strings.Contains(err.Error(), "made-up") {
246 t.Fatalf("an unlisted item: %v", err)
247 }
248 // An item the role does not carry: landing-only items on the source
249 // side, source-only items on the target.
250 for _, id := range []string{"row", "config", "key-adoption", "warm-cache"} {
251 r := base(OfflineRoleSealer)
252 r.Items[id] = Check{OK: true}
253 if err := ValidateOfflineReport(r); err == nil {
254 t.Errorf("a sealer report carried %s", id)
255 }
256 }
257 for _, id := range []string{"db-tail", "build-state", "sidecar"} {
258 r := base(OfflineRoleTarget)
259 r.Items[id] = Check{OK: true}
260 if err := ValidateOfflineReport(r); err == nil {
261 t.Errorf("a target report carried %s", id)
262 }
263 }
264 for _, owner := range []string{OfflineOwnerDoor, OfflineOwnerNone} {
265 for _, it := range OfflineOwned(owner) {
266 for _, role := range []string{OfflineRoleSource, OfflineRoleSealer, OfflineRoleTarget} {
267 r := base(role)
268 r.Items[it.ID] = Check{OK: true}
269 if err := ValidateOfflineReport(r); err == nil {
270 t.Errorf("a %s report carried %s, which is %s-owned and checked by no report", role, it.ID, owner)
271 }
272 }
273 }
274 }
275 // Version, phase, role and names.
276 for name, mut := range map[string]func(*OfflineReport){
277 "version": func(r *OfflineReport) { r.V = 0 },
278 "phase": func(r *OfflineReport) { r.Phase = "dancing" },
279 "role": func(r *OfflineReport) { r.Role = "bystander" },
280 "host": func(r *OfflineReport) { r.Host = "../x" },
281 "no op": func(r *OfflineReport) { r.Op = "" },
282 "box path": func(r *OfflineReport) { r.Box = "a/b" },
283 } {
284 r := base(OfflineRoleSealer)
285 mut(&r)
286 if err := ValidateOfflineReport(r); err == nil {
287 t.Errorf("%s: accepted", name)
288 }
289 }
290}
291
292// Every field a report carries survives the wire.
293func TestOfflineReportRoundTrips(t *testing.T) {
294 at := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
295 in := OfflineReport{
296 V: 1, Op: offTestOp, Host: offTestHost, Box: "box-a", Role: OfflineRoleSealer, Phase: OfflinePhaseSealed,
297 Items: map[string]Check{"db": {OK: true}, "files": {OK: false, Reason: "no archive"}}, At: at,
298 Held: true, Fenced: true, Quiesced: true, LocalDB: true, Files: true, Env: true, Sidecar: true, RowPresent: true, LocalTXID: 41,
299 FinalTXID: 42, Staged: map[string]StagedObject{"replica/0001.ltx": {Size: 9, ETag: "e1"}},
300 FilesPlain: "p", FilesSealed: "s", FilesBytes: 7, FilesEntries: 2,
301 EnvSHA256: "e", KeySHA256: "k", EnvDigest: "d", Release: "v0.28.4", TimersDigest: "t", Replication: "cold", NextDue: 99,
302 TombstoneOwner: TombstoneOwner(offTestOp), TombstoneUntil: at.Add(TombstoneTTL),
303 Procs: 2, LayoutJournal: true,
304 Retired: []RetiredPath{{Path: "/data/a.go.example.com.db", Action: RetiredParked}, {Path: "/opt/carlos/live/a.go.example.com", Action: RetiredLeft, Reason: "root-owned"}},
305 }
306 raw, err := json.Marshal(in)
307 if err != nil {
308 t.Fatal(err)
309 }
310 var out OfflineReport
311 if err := json.Unmarshal(raw, &out); err != nil {
312 t.Fatal(err)
313 }
314 if !reflect.DeepEqual(in, out) {
315 t.Fatalf("round trip:\n in %+v\nout %+v", in, out)
316 }
317 // A report's booleans are always written: an absent held means not held,
318 // and a reader can see the box said so.
319 empty, _ := json.Marshal(OfflineReport{})
320 for _, k := range []string{`"held":false`, `"fenced":false`, `"quiesced":false`} {
321 if !strings.Contains(string(empty), k) {
322 t.Errorf("a zero report omits %s: %s", k, empty)
323 }
324 }
325 // The tombstone's time is written only when there is one.
326 if strings.Contains(string(empty), "tombstone_until") {
327 t.Errorf("a zero report writes a tombstone time: %s", empty)
328 }
329}
330
331func TestOfflineKeyHelpers(t *testing.T) {
332 if got := OfflineReportKey(offTestHost, offTestOp, OfflineRoleSealer, "box-a"); got != "control/offline-moves/"+offTestHost+"/"+offTestOp+"/sealer/box-a.json" {
333 t.Fatalf("report key %q", got)
334 }
335 if got := OfflineReportPrefix(offTestHost, offTestOp); got != "control/offline-moves/"+offTestHost+"/"+offTestOp+"/" {
336 t.Fatalf("report prefix %q", got)
337 }
338 if got := OfflineArchivePrefix(offTestHost, offTestOp); got != "moves/@offline/"+offTestHost+"/"+offTestOp+"/" {
339 t.Fatalf("archive prefix %q", got)
340 }
341 if got := TombstoneOwner(offTestOp); got != "moved:"+offTestOp {
342 t.Fatalf("tombstone owner %q", got)
343 }
344 if got := OfflineParkDir("/data", offTestHost, offTestOp, OfflineRoleSource); got != "/data/.offline-moves/"+offTestHost+"/"+offTestOp+"/source" {
345 t.Fatalf("park dir %q", got)
346 }
347 for _, role := range []string{OfflineParkTargetBefore, OfflineParkVerify} {
348 if got := OfflineParkDir("/data", offTestHost, offTestOp, role); got != "/data/.offline-moves/"+offTestHost+"/"+offTestOp+"/"+role {
349 t.Errorf("park dir for %s: %q", role, got)
350 }
351 // A park role is not a report role: the report key does not take it.
352 if OfflineReportKey(offTestHost, offTestOp, role, "box-a") != "" {
353 t.Errorf("a report key for park role %s", role)
354 }
355 }
356 if OfflineParkTargetBefore != "target-before" || OfflineParkVerify != "verify" {
357 t.Fatal("the park role names are the plan's")
358 }
359 // Nothing else is accepted, and a numbered suffix is the caller's.
360 for _, role := range []string{"target-before-2", "scratch", "verify/x"} {
361 if OfflineParkDir("/data", offTestHost, offTestOp, role) != "" {
362 t.Errorf("park role %q accepted", role)
363 }
364 }
365 if TombstoneTTL != 7*24*time.Hour {
366 t.Fatalf("tombstone ttl %v", TombstoneTTL)
367 }
368
369 // Hosts that fail ValidHostShape are rejected by every helper that takes
370 // one. The precondition keeps this honest if the shape rule moves.
371 for _, host := range []string{"", "../x", "a/b.example.com", "a b.example.com", "UPPER.EXAMPLE.COM", ".leading.example.com", "a.b.example.com/", "a..b"} {
372 if provision.ValidHostShape(host) {
373 t.Fatalf("test precondition: %q is a valid host shape", host)
374 }
375 if k := OfflineReportKey(host, offTestOp, OfflineRoleSealer, "box-a"); k != "" {
376 t.Errorf("report key for %q: %q", host, k)
377 }
378 if k := OfflineReportPrefix(host, offTestOp); k != "" {
379 t.Errorf("report prefix for %q: %q", host, k)
380 }
381 if k := OfflineArchivePrefix(host, offTestOp); k != "" {
382 t.Errorf("archive prefix for %q: %q", host, k)
383 }
384 if k := OfflineParkDir("/data", host, offTestOp, OfflineRoleSource); k != "" {
385 t.Errorf("park dir for %q: %q", host, k)
386 }
387 if OfflineNamesOK(host, offTestOp, OfflineRoleSealer, "box-a") {
388 t.Errorf("names ok for %q", host)
389 }
390 }
391 // And the other parts cannot climb out of the prefix they are joined to.
392 for _, op := range []string{"", ".", "..", "a/b", "../mv_x", "mv x"} {
393 if OfflineReportKey(offTestHost, op, OfflineRoleSealer, "box-a") != "" || OfflineArchivePrefix(offTestHost, op) != "" ||
394 OfflineParkDir("/data", offTestHost, op, OfflineRoleSource) != "" || OfflineReportPrefix(offTestHost, op) != "" {
395 t.Errorf("op %q accepted", op)
396 }
397 }
398 for _, box := range []string{"", "..", "a/b", "box a"} {
399 if OfflineReportKey(offTestHost, offTestOp, OfflineRoleSealer, box) != "" {
400 t.Errorf("box %q accepted", box)
401 }
402 }
403 for _, role := range []string{"", "Source", "operator", "../source"} {
404 if OfflineReportKey(offTestHost, offTestOp, role, "box-a") != "" || OfflineParkDir("/data", offTestHost, offTestOp, role) != "" {
405 t.Errorf("role %q accepted", role)
406 }
407 }
408 if OfflineParkDir("", offTestHost, offTestOp, OfflineRoleSource) != "" {
409 t.Error("a park dir with no data dir")
410 }
411}
412
413// The report key sits outside every prefix the bucket makes create-only or
414// no-delete, so a box may rewrite its report.
415func TestOfflineReportKeyIsOutsideTheProtectedPrefixes(t *testing.T) {
416 key := OfflineReportKey(offTestHost, offTestOp, OfflineRoleTarget, "box-a")
417 for _, p := range []string{"control/boxes", "control/box-volumes", "control/box-enrollment", "control/box-fences"} {
418 if strings.HasPrefix(key, p) {
419 t.Errorf("%s is under %s", key, p)
420 }
421 }
422}
423
424// Sanity for the inventory the other tasks pin their wiring to: the exported
425// state constants of the record side are the ones a report's phases pair with.
426func TestOfflineStateAndPhaseNamesAreDistinct(t *testing.T) {
427 states := []string{provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding,
428 provision.OfflineLanded, provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting}
429 sort.Strings(states)
430 for i := 1; i < len(states); i++ {
431 if states[i] == states[i-1] {
432 t.Fatalf("state %q is declared twice", states[i])
433 }
434 }
435 for _, s := range states {
436 if !strings.HasPrefix(s, "offline-") {
437 t.Errorf("state %q lacks the offline- prefix", s)
438 }
439 }
440 if provision.MoveKindOffline != "offline" {
441 t.Fatalf("kind %q", provision.MoveKindOffline)
442 }
443}
444
445func TestOfflineSealKeyAndValidation(t *testing.T) {
446 const host, op = "acme.go.example.com", "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa"
447 if got := OfflineSealKey(host, op); got != "control/offline-moves/"+host+"/"+op+"/seal.json" {
448 t.Fatalf("seal key = %q", got)
449 }
450 for _, bad := range [][2]string{{"", op}, {"not a host", op}, {host, ""}, {host, "../x"}, {host, "a/b"}} {
451 if OfflineSealKey(bad[0], bad[1]) != "" || OfflineSealEvidence(bad[0], bad[1], []byte("x")) != "" {
452 t.Errorf("key for %q accepted", bad)
453 }
454 }
455 // The seal key is not a report key and sits under the report prefix.
456 if !strings.HasPrefix(OfflineSealKey(host, op), OfflineReportPrefix(host, op)) {
457 t.Error("the seal is outside the move's prefix")
458 }
459 items := map[string]Check{}
460 for _, it := range OfflineSourced() {
461 items[it.ID] = Check{OK: true}
462 }
463 good := OfflineReport{V: OfflineReportVersion, Op: op, Host: host, Box: "ie-box", Role: OfflineRoleSealer, Phase: OfflinePhaseSealed,
464 Items: items, TombstoneOwner: TombstoneOwner(op), At: time.Now()}
465 if err := ValidateOfflineSeal(good, host, op); err != nil {
466 t.Fatal(err)
467 }
468 for name, edit := range map[string]func(*OfflineReport){
469 "another host": func(r *OfflineReport) { r.Host = "other.go.example.com" },
470 "another op": func(r *OfflineReport) { r.Op = "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb" },
471 "a source role": func(r *OfflineReport) { r.Role = OfflineRoleSource },
472 "not sealed": func(r *OfflineReport) { r.Phase = OfflinePhaseStopped },
473 "no tombstone": func(r *OfflineReport) { r.TombstoneOwner = "" },
474 "an item not OK": func(r *OfflineReport) {
475 c := map[string]Check{}
476 for k, v := range items {
477 c[k] = v
478 }
479 c["files"] = Check{}
480 r.Items = c
481 },
482 "a missing item": func(r *OfflineReport) { r.Items = map[string]Check{} },
483 } {
484 r := good
485 edit(&r)
486 if ValidateOfflineSeal(r, host, op) == nil {
487 t.Errorf("%s: accepted", name)
488 }
489 }
490 // The evidence is the key and the hash of the bytes.
491 ev := OfflineSealEvidence(host, op, []byte("x"))
492 if !strings.HasPrefix(ev, OfflineSealKey(host, op)+"@") || len(ev) != len(OfflineSealKey(host, op))+1+64 {
493 t.Errorf("evidence = %q", ev)
494 }
495}
496
497// The stager (offline-staging's pre-copy): its report is a report like any
498// other, under its own role, and carries no manifest item; the stager is the
499// lexically first box, whatever order the listing gave.
500func TestOfflineStagerReport(t *testing.T) {
501 r := OfflineReport{V: OfflineReportVersion, Op: offTestOp, Host: offTestHost, Box: "box-a", Role: OfflineRoleStager, Phase: OfflinePhaseStaged,
502 ReplicaObjects: 10, ReplicaBytes: 100, StagedObjects: 9, StagedBytes: 90, RoundObjects: 3, RoundBytes: 30}
503 if err := ValidateOfflineReport(r); err != nil {
504 t.Fatal(err)
505 }
506 if got := OfflineReportKey(offTestHost, offTestOp, OfflineRoleStager, "box-a"); got != "control/offline-moves/"+offTestHost+"/"+offTestOp+"/stager/box-a.json" {
507 t.Fatalf("key = %q", got)
508 }
509 r.Items = map[string]Check{"db": {OK: true}}
510 if ValidateOfflineReport(r) == nil {
511 t.Fatal("a stager report carrying an item was accepted")
512 }
513 body, err := json.Marshal(OfflineReport{V: 1})
514 if err != nil || strings.Contains(string(body), "replica_objects") || strings.Contains(string(body), "stage_error") {
515 t.Fatalf("the stager's fields are not omitempty: %s", body)
516 }
517 for _, c := range []struct {
518 boxes []string
519 want string
520 }{{nil, ""}, {[]string{"box-c", "box-a", "box-b"}, "box-a"}, {[]string{"", "box-z"}, "box-z"}} {
521 if got := OfflineStager(c.boxes); got != c.want {
522 t.Errorf("OfflineStager(%v) = %q, want %q", c.boxes, got, c.want)
523 }
524 }
525}
526