| 1 | package hostenv |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "runtime" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | ) |
| 15 | |
| 16 | // fakeParams is a parameter store in memory. failPrefix makes ByPath fail |
| 17 | // for one prefix, as an unreachable or refused SSM call would. staleOnce |
| 18 | // makes the next ByPath of that prefix answer as it stood before every |
| 19 | // value now under it was written: a seed that read just before another |
| 20 | // seed wrote. |
| 21 | type fakeParams struct { |
| 22 | vals map[string]string |
| 23 | sets []string |
| 24 | failPrefix string |
| 25 | staleOnce string |
| 26 | } |
| 27 | |
| 28 | func (f *fakeParams) ByPath(_ context.Context, prefix string) (map[string]string, error) { |
| 29 | if prefix == f.failPrefix { |
| 30 | return nil, errors.New("AccessDeniedException") |
| 31 | } |
| 32 | if prefix == f.staleOnce { |
| 33 | f.staleOnce = "" |
| 34 | return map[string]string{}, nil |
| 35 | } |
| 36 | out := map[string]string{} |
| 37 | for name, v := range f.vals { |
| 38 | if rest, ok := strings.CutPrefix(name, prefix); ok && !strings.Contains(rest, "/") { |
| 39 | out[rest] = v |
| 40 | } |
| 41 | } |
| 42 | return out, nil |
| 43 | } |
| 44 | |
| 45 | func (f *fakeParams) Set(_ context.Context, name, value string) error { |
| 46 | f.vals[name] = value |
| 47 | f.sets = append(f.sets, name) |
| 48 | return nil |
| 49 | } |
| 50 | |
| 51 | func (f *fakeParams) SetNew(_ context.Context, name, value string) error { |
| 52 | if _, ok := f.vals[name]; ok { |
| 53 | return ErrExists |
| 54 | } |
| 55 | f.vals[name] = value |
| 56 | f.sets = append(f.sets, name) |
| 57 | return nil |
| 58 | } |
| 59 | |
| 60 | // today is a hosting box's host.env as an operator has it today. |
| 61 | func today() map[string]string { |
| 62 | return map[string]string{ |
| 63 | "CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:123456789012:carlos-alerts", |
| 64 | "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket", |
| 65 | "CARLOS_HIBERNATE_BUCKET": "data-ie", |
| 66 | "CARLOS_HIBERNATE_REGION": "eu-west-1", |
| 67 | "CARLOS_HOST_CLAIM": "1", |
| 68 | "CARLOS_HOST_CLAIM_ORIGINS": "1", |
| 69 | "CARLOS_REGION": "ie", |
| 70 | "CARLOS_REGION_HA": "1", |
| 71 | "CARLOS_RELEASE_PUBKEY": "q3J0bXlwdWJsaWNrZXk+/=", |
| 72 | "CARLOS_SECRETS_KEY": "c2VjcmV0c2tleQ==", |
| 73 | "CARLOS_SYSTEM_CHANNEL": "stable", |
| 74 | "CARLOS_HIBERNATE_PREFIX": "fleet", |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestSeedThenFetchIsTodaysFile(t *testing.T) { |
| 79 | ctx := context.Background() |
| 80 | p := &fakeParams{vals: map[string]string{}} |
| 81 | s, err := Seed(ctx, p, "flagship", "ie", today()) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if s.Shared != len(SharedKeys) || s.Regional != len(today())-len(SharedKeys) || s.SharedPrefix != "/carlos/flagship/shared/" || s.RegionPrefix != "/carlos/flagship/ie/" { |
| 86 | t.Fatalf("seeded %+v", s) |
| 87 | } |
| 88 | if p.vals["/carlos/flagship/shared/CARLOS_RELEASE_PUBKEY"] != today()["CARLOS_RELEASE_PUBKEY"] || p.vals["/carlos/flagship/ie/CARLOS_REGION"] != "ie" { |
| 89 | t.Fatalf("layout %v", p.vals) |
| 90 | } |
| 91 | // Another region's parameter, and a non-variable under shared, are not |
| 92 | // this region's host.env. |
| 93 | p.vals["/carlos/flagship/uk/CARLOS_REGION"] = "uk" |
| 94 | p.vals["/carlos/flagship/shared/bootstrap-sha256"] = strings.Repeat("a", 64) |
| 95 | body, err := Fetch(ctx, p, "flagship", "ie") |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | got, err := Parse(body) |
| 100 | if err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | // A box write assembles host.env for is one its group launched: it |
| 104 | // takes its group reference (BoxReference), which is never a parameter. |
| 105 | want := today() |
| 106 | want[BoxReference] = GroupReference |
| 107 | if !reflect.DeepEqual(got, want) { |
| 108 | t.Fatalf("round trip:\n got %v\nwant %v", got, want) |
| 109 | } |
| 110 | if !bytes.HasPrefix(body, []byte("# ")) || !bytes.Contains(body, []byte("\nCARLOS_ALERT_SNS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:carlos-alerts\nCARLOS_BOX_REFERENCE=group\nCARLOS_DEPLOYMENT_BUCKET=deploy-bucket\n")) { |
| 111 | t.Fatalf("not sorted KEY=value lines:\n%s", body) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func TestFetchRefusesAMissingRequiredValue(t *testing.T) { |
| 116 | ctx := context.Background() |
| 117 | for _, k := range Required { |
| 118 | p := &fakeParams{vals: map[string]string{}} |
| 119 | env := today() |
| 120 | if _, err := Seed(ctx, p, "flagship", "ie", env); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | for name := range p.vals { |
| 124 | if strings.HasSuffix(name, "/"+k) { |
| 125 | delete(p.vals, name) |
| 126 | } |
| 127 | } |
| 128 | _, err := Fetch(ctx, p, "flagship", "ie") |
| 129 | if !errors.Is(err, ErrMissing) || !strings.Contains(err.Error(), k) { |
| 130 | t.Fatalf("without %s: %v", k, err) |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestFetchRefusesAFailedReadAndAConflict(t *testing.T) { |
| 136 | ctx := context.Background() |
| 137 | p := &fakeParams{vals: map[string]string{}} |
| 138 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | // A refused read is never an empty scope: nothing is assembled. |
| 142 | p.failPrefix = "/carlos/flagship/shared/" |
| 143 | if _, err := Fetch(ctx, p, "flagship", "ie"); err == nil || errors.Is(err, ErrMissing) { |
| 144 | t.Fatalf("failed read: %v", err) |
| 145 | } |
| 146 | p.failPrefix = "" |
| 147 | // A variable in both scopes is one of them wrong. |
| 148 | p.vals["/carlos/flagship/ie/CARLOS_RELEASE_PUBKEY"] = "other" |
| 149 | if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") { |
| 150 | t.Fatalf("conflict: %v", err) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestRenderRefusesAnUnsafeValueWithoutPrintingIt(t *testing.T) { |
| 155 | env := today() |
| 156 | env["CARLOS_SECRETS_KEY"] = "abc$(reboot)" |
| 157 | _, err := Render(env) |
| 158 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "reboot") { |
| 159 | t.Fatalf("unsafe value: %v", err) |
| 160 | } |
| 161 | for _, v := range []string{"a b", "a'b", `a"b`, "a\\b", "a\nb", "a#b", "a;b"} { |
| 162 | if _, err := Render(map[string]string{"CARLOS_X": v}); !errors.Is(err, ErrValue) { |
| 163 | t.Fatalf("%q rendered: %v", v, err) |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func TestSeedNeverChangesASharedValue(t *testing.T) { |
| 169 | ctx := context.Background() |
| 170 | p := &fakeParams{vals: map[string]string{}} |
| 171 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | // A second region with the same shared values writes only its own. |
| 175 | uk := today() |
| 176 | uk["CARLOS_REGION"], uk["CARLOS_HIBERNATE_BUCKET"] = "uk", "data-uk" |
| 177 | p.sets = nil |
| 178 | s, err := Seed(ctx, p, "flagship", "uk", uk) |
| 179 | if err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | if s.Shared != 0 { |
| 183 | t.Fatalf("rewrote shared values: %v", p.sets) |
| 184 | } |
| 185 | // A third region whose file disagrees on a shared value writes nothing. |
| 186 | au := today() |
| 187 | au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI=" |
| 188 | p.sets = nil |
| 189 | if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 { |
| 190 | t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets) |
| 191 | } |
| 192 | // A group box's own host.env seeds no BoxReference. |
| 193 | grp := today() |
| 194 | grp[BoxReference] = GroupReference |
| 195 | p.sets = nil |
| 196 | if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) { |
| 197 | t.Fatalf("seeded %v: %v", p.sets, err) |
| 198 | } |
| 199 | // An incomplete file writes nothing either. |
| 200 | partial := today() |
| 201 | delete(partial, "CARLOS_REGION_HA") |
| 202 | p.sets = nil |
| 203 | if _, err := Seed(ctx, p, "flagship", "au", partial); !errors.Is(err, ErrMissing) || len(p.sets) != 0 { |
| 204 | t.Fatalf("incomplete file: %v, wrote %v", err, p.sets) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // R4: two seeds racing for a shared value. The second read before the |
| 209 | // first wrote, so both try to create it; the second's SetNew is refused, |
| 210 | // it reads the value back, and it goes on only if the value is its own. |
| 211 | func TestSeedRacesForASharedValueAndOnlyOneWins(t *testing.T) { |
| 212 | ctx := context.Background() |
| 213 | p := &fakeParams{vals: map[string]string{}} |
| 214 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | // The same shared values: nothing shared is written, the region's are. |
| 218 | uk := today() |
| 219 | uk["CARLOS_REGION"] = "uk" |
| 220 | p.staleOnce, p.sets = "/carlos/flagship/shared/", nil |
| 221 | s, err := Seed(ctx, p, "flagship", "uk", uk) |
| 222 | if err != nil || s.Shared != 0 || s.Regional != len(today())-len(SharedKeys) { |
| 223 | t.Fatalf("same values: %+v %v, wrote %v", s, err, p.sets) |
| 224 | } |
| 225 | // A different secrets key: refused, naming the variable and never |
| 226 | // either value, and the first seed's value stands. |
| 227 | au := today() |
| 228 | au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI=" |
| 229 | p.staleOnce, p.sets = "/carlos/flagship/shared/", nil |
| 230 | _, err = Seed(ctx, p, "flagship", "au", au) |
| 231 | if !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "b3RoZXI=") || strings.Contains(err.Error(), today()["CARLOS_SECRETS_KEY"]) { |
| 232 | t.Fatalf("racing seed: %v", err) |
| 233 | } |
| 234 | if p.vals["/carlos/flagship/shared/CARLOS_SECRETS_KEY"] != today()["CARLOS_SECRETS_KEY"] || len(p.sets) != 0 { |
| 235 | t.Fatalf("the second seed changed or wrote %v", p.sets) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | func TestPrefixAndParse(t *testing.T) { |
| 240 | if _, err := Prefix("flagship", "../x"); !errors.Is(err, ErrValue) { |
| 241 | t.Fatalf("bad scope: %v", err) |
| 242 | } |
| 243 | if _, err := Fetch(context.Background(), &fakeParams{}, "flagship", SharedScope); !errors.Is(err, ErrValue) { |
| 244 | t.Fatalf("shared as a region: %v", err) |
| 245 | } |
| 246 | got, err := Parse([]byte("# comment\n\nexport CARLOS_REGION=\"ie\"\nCARLOS_HOST_CLAIM='1'\n")) |
| 247 | if err != nil || !reflect.DeepEqual(got, map[string]string{"CARLOS_REGION": "ie", "CARLOS_HOST_CLAIM": "1"}) { |
| 248 | t.Fatalf("parse: %v %v", got, err) |
| 249 | } |
| 250 | for _, body := range []string{"CARLOS_REGION=ie\nCARLOS_REGION=uk\n", "not an assignment\n", "lower=x\n"} { |
| 251 | if _, err := Parse([]byte(body)); !errors.Is(err, ErrValue) { |
| 252 | t.Fatalf("%q parsed: %v", body, err) |
| 253 | } |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // The command: write refuses without root and leaves no file on a failed |
| 258 | // assembly; with root it writes 0600; seed reads the named file. |
| 259 | func TestHostEnvCommand(t *testing.T) { |
| 260 | ctx := context.Background() |
| 261 | dir := withHostEnvRoot(t) |
| 262 | out := filepath.Join(dir, "etc", "carlos", "host.env") |
| 263 | p := &fakeParams{vals: map[string]string{}} |
| 264 | open := func(context.Context, string, string) (Params, error) { return p, nil } |
| 265 | var stdout bytes.Buffer |
| 266 | args := []string{"write", "--deployment", "flagship", "--region", "ie"} |
| 267 | if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") { |
| 268 | t.Fatalf("non-root write: %v", err) |
| 269 | } |
| 270 | if err := runHostEnv(ctx, args, &stdout, 0, open); !errors.Is(err, ErrMissing) { |
| 271 | t.Fatalf("write with no parameters: %v", err) |
| 272 | } |
| 273 | if _, err := os.Stat(out); !os.IsNotExist(err) { |
| 274 | t.Fatalf("a failed write left a file: %v", err) |
| 275 | } |
| 276 | from := filepath.Join(dir, "today.env") |
| 277 | body, _ := Render(today()) |
| 278 | if err := os.WriteFile(from, body, 0o600); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | seed := []string{"seed", "--deployment", "flagship", "--region", "ie", "--kms-key", "alias/carlos-flagship-config", "--from", from} |
| 282 | if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") { |
| 286 | t.Fatalf("output %q", stdout.String()) |
| 287 | } |
| 288 | err := runHostEnv(ctx, args, &stdout, 0, open) |
| 289 | if runtime.GOOS != "linux" { |
| 290 | if err == nil || !strings.Contains(err.Error(), "runs only on Linux") { |
| 291 | t.Fatalf("write off Linux: %v", err) |
| 292 | } |
| 293 | } else { |
| 294 | if err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | fi, err := os.Stat(out) |
| 298 | if err != nil || fi.Mode().Perm() != 0o600 { |
| 299 | t.Fatalf("host.env: %v %v", fi, err) |
| 300 | } |
| 301 | if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") { |
| 302 | t.Fatalf("output %q", stdout.String()) |
| 303 | } |
| 304 | } |
| 305 | // The path is fixed: there is no flag to move it. |
| 306 | for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} { |
| 307 | if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") { |
| 308 | t.Fatalf("%v: %v", bad, err) |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // runHostEnv's census conditions (platform-1e, region-ha-2b): write takes |
| 314 | // only allowlisted names, refuses values that could start a second line, |
| 315 | // end the file or change how systemd reads it, and writes through no |
| 316 | // symlinked directory. A refusal names the variable, never the value. |
| 317 | func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) { |
| 318 | ctx := context.Background() |
| 319 | p := &fakeParams{vals: map[string]string{}} |
| 320 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | // A name off the allowlist, under either scope, is refused by name. |
| 324 | for _, name := range []string{"/carlos/flagship/ie/CARLOS_PRIVATE_IP", "/carlos/flagship/shared/CARLOS_FLEET"} { |
| 325 | p.vals[name] = "10.0.0.9" |
| 326 | _, err := Fetch(ctx, p, "flagship", "ie") |
| 327 | short := name[strings.LastIndex(name, "/")+1:] |
| 328 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), short) || strings.Contains(err.Error(), "10.0.0.9") { |
| 329 | t.Fatalf("%s: %v", name, err) |
| 330 | } |
| 331 | delete(p.vals, name) |
| 332 | } |
| 333 | // Seeding a file with such a name writes nothing. |
| 334 | odd := today() |
| 335 | odd["CARLOS_POOLS"] = "gold" |
| 336 | p.sets = nil |
| 337 | if _, err := Seed(ctx, p, "flagship", "uk", odd); !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_POOLS") || len(p.sets) != 0 { |
| 338 | t.Fatalf("seed with an unknown name: %v, wrote %v", err, p.sets) |
| 339 | } |
| 340 | // Values that would break a line, end the file early or change how |
| 341 | // systemd reads it. |
| 342 | for _, v := range []string{"a\nCARLOS_REGION=uk", "a\x00b", "a\rb", "$(id)", "a b", "\"quoted\""} { |
| 343 | p.vals["/carlos/flagship/ie/CARLOS_HIBERNATE_PREFIX"] = v |
| 344 | if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrValue) || strings.Contains(err.Error(), v) { |
| 345 | t.Fatalf("%q: %v", v, err) |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | // withHostEnvRoot points write's fixed path at a directory of the test's |
| 351 | // own, with an etc in it as a box has one. |
| 352 | func withHostEnvRoot(t *testing.T) string { |
| 353 | t.Helper() |
| 354 | root := t.TempDir() |
| 355 | if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | was := hostEnvRoot |
| 362 | hostEnvRoot = root |
| 363 | t.Cleanup(func() { hostEnvRoot = was }) |
| 364 | return root |
| 365 | } |
| 366 | |
| 367 | // A provider token is not on the allowlist: Assemble and Seed refuse it by |
| 368 | // name, and the refusal never carries its value. |
| 369 | func TestDNSProviderTokenIsRefusedByNameNotValue(t *testing.T) { |
| 370 | const name, secret = "CARLOS_DNSIMPLE_TOKEN", "tok-9f8e7d6c" |
| 371 | if Allowed(name) { |
| 372 | t.Fatalf("%s is on the allowlist", name) |
| 373 | } |
| 374 | check := func(what string, err error) { |
| 375 | t.Helper() |
| 376 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), name) || strings.Contains(err.Error(), secret) { |
| 377 | t.Fatalf("%s: %v", what, err) |
| 378 | } |
| 379 | } |
| 380 | env := today() |
| 381 | env[name] = secret |
| 382 | shared, regional := Split(env) |
| 383 | _, err := Assemble(shared, regional) |
| 384 | check("Assemble", err) |
| 385 | p := &fakeParams{vals: map[string]string{}} |
| 386 | _, err = Seed(context.Background(), p, "flagship", "ie", env) |
| 387 | check("Seed", err) |
| 388 | if len(p.sets) != 0 { |
| 389 | t.Fatalf("Seed wrote %v", p.sets) |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | // The status destination overrides are off the allowlist: a group box |
| 394 | // reports to the deployment bucket, unprefixed, where the console reads, |
| 395 | // while joining and once hosting. So is the alert webhook, whose URL can |
| 396 | // carry a credential. Assemble and Seed refuse each by name, and the |
| 397 | // refusal never carries its value. |
| 398 | func TestStatusOverridesAndTheAlertWebhookAreRefusedByNameNotValue(t *testing.T) { |
| 399 | for name, value := range map[string]string{ |
| 400 | "CARLOS_STATUS_BUCKET": "other-status-bucket-5c1d", |
| 401 | "CARLOS_STATUS_PREFIX": "fleets-prefix-7a2b", |
| 402 | "CARLOS_STATUS_REGION": "ap-southeast-9z", |
| 403 | "CARLOS_ALERT_WEBHOOK": "https://hooks.example.com/services/T0/B0/s3cr3t-4e5f", |
| 404 | } { |
| 405 | t.Run(name, func(t *testing.T) { |
| 406 | if Allowed(name) { |
| 407 | t.Fatalf("%s is on the allowlist", name) |
| 408 | } |
| 409 | check := func(what string, err error) { |
| 410 | t.Helper() |
| 411 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), name) || strings.Contains(err.Error(), value) { |
| 412 | t.Fatalf("%s: %v", what, err) |
| 413 | } |
| 414 | } |
| 415 | env := today() |
| 416 | env[name] = value |
| 417 | shared, regional := Split(env) |
| 418 | _, err := Assemble(shared, regional) |
| 419 | check("Assemble", err) |
| 420 | p := &fakeParams{vals: map[string]string{}} |
| 421 | _, err = Seed(context.Background(), p, "flagship", "ie", env) |
| 422 | check("Seed", err) |
| 423 | if len(p.sets) != 0 { |
| 424 | t.Fatalf("Seed wrote %v", p.sets) |
| 425 | } |
| 426 | }) |
| 427 | } |
| 428 | } |
| 429 | |