| 1 | package moves |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | const ( |
| 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. |
| 23 | var 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. |
| 34 | var 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 | |
| 47 | var 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. |
| 52 | func 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 | |
| 85 | func 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. |
| 156 | func 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. |
| 182 | func 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. |
| 293 | func 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 | |
| 331 | func 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. |
| 415 | func 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. |
| 426 | func 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 | |
| 445 | func 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. |
| 500 | func 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 | |