package hostactors import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "errors" "reflect" "strings" "testing" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/release" ) func TestParseRoles(t *testing.T) { for in, want := range map[string]string{"default": RoleDefault, "legacy": RoleLegacy, "platform": RolePlatform, " legacy ": RoleLegacy} { if got, err := ParseBoxRole(in); err != nil || got != want { t.Fatalf("ParseBoxRole(%q) = %q, %v", in, got, err) } } for _, in := range []string{"", "Legacy", "draining", "none"} { if _, err := ParseBoxRole(in); !errors.Is(err, ErrRole) { t.Fatalf("ParseBoxRole(%q) accepted", in) } } if _, err := ParseGroupRole("platform"); !errors.Is(err, ErrRole) { t.Fatal("a group took the platform role") } if got, err := ParseGroupRole("legacy"); err != nil || got != RoleLegacy { t.Fatalf("ParseGroupRole(legacy) = %q, %v", got, err) } if RoleName(RoleDefault) != "default" || RoleName(RolePlatform) != "platform" { t.Fatal("RoleName") } } func TestBoxRoleIsSignedAndPersists(t *testing.T) { ctx := context.Background() d, sg, o := fixture(t) next, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy) if err != nil { t.Fatal(err) } read, err := d.Read(ctx) if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy || read.ETag != next.ETag { t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err) } // The same role again writes nothing. same, err := d.SetBoxRole(ctx, sg, next, "box", RoleLegacy) if err != nil || same.ETag != next.ETag { t.Fatalf("idempotent set wrote: %v", err) } // A stale observation loses the CAS. if _, err = d.SetBoxRole(ctx, sg, o, "box", RolePlatform); err == nil { t.Fatal("stale roster accepted") } back, err := d.SetBoxRole(ctx, sg, next, "box", RoleDefault) if err != nil || back.Roster.Boxes["box"].Role != RoleDefault { t.Fatalf("back to default: %+v %v", back.Roster.Boxes["box"], err) } if _, err = d.SetBoxRole(ctx, sg, back, "nobody", RoleLegacy); !errors.Is(err, ErrRoster) { t.Fatalf("unknown box: %v", err) } if _, err = d.SetBoxRole(ctx, sg, back, "box", "boss"); !errors.Is(err, ErrRole) { t.Fatalf("unknown role: %v", err) } // A retired box keeps no role. retired := Clone(back.Roster) b := retired.Boxes["box"] b.State = "retired" retired.Boxes["box"] = b gone, err := d.Replace(ctx, sg, back, retired) if err != nil { t.Fatal(err) } if _, err = d.SetBoxRole(ctx, sg, gone, "box", RoleLegacy); !errors.Is(err, ErrRoster) { t.Fatalf("retired box: %v", err) } } func TestRosterRefusesUnknownRolesAndAGroupChange(t *testing.T) { ctx := context.Background() d, sg, o := fixture(t) bad := Clone(o.Roster) b := bad.Boxes["box"] b.Role = "boss" bad.Boxes["box"] = b if _, err := d.Replace(ctx, sg, o, bad); !errors.Is(err, ErrRoster) { t.Fatalf("unknown box role written: %v", err) } moved := Clone(o.Roster) b = moved.Boxes["box"] b.Group = "routers" moved.Boxes["box"] = b if _, err := d.Replace(ctx, sg, o, moved); !errors.Is(err, ErrRoster) { t.Fatalf("group rewritten: %v", err) } noGroup := Clone(o.Roster) noGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", Role: RoleLegacy}} if _, err := d.Replace(ctx, sg, o, noGroup); !errors.Is(err, ErrRoster) { t.Fatalf("a role on a region with no group: %v", err) } platformGroup := Clone(o.Roster) platformGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", Role: RolePlatform}} if _, err := d.Replace(ctx, sg, o, platformGroup); !errors.Is(err, ErrRoster) { t.Fatalf("a platform group: %v", err) } } // groupFixture is fixture plus a group in ie and two boxes it enrolled. func groupFixture(t *testing.T) (Directory, release.Signer, Observed) { t.Helper() ctx := context.Background() d, sg, o := fixture(t) next := Clone(o.Roster) next.Regions = map[string]Region{ "ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}, "uk": {Account: "123", AWSRegion: "eu-west-2"}, } for i, ref := range []string{"g1", "g2"} { next.Boxes[ref] = Box{Enrollment: strings.Repeat(string(rune('c'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", Cloud: Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}, Group: "routers"} } o, err := d.Replace(ctx, sg, o, next) if err != nil { t.Fatal(err) } return d, sg, o } func TestGroupRoleSetsEveryMemberInOneWrite(t *testing.T) { ctx := context.Background() d, sg, o := groupFixture(t) next, set, err := d.SetGroupRole(ctx, sg, o, "ie", RoleLegacy) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(set, []string{"g1", "g2"}) { t.Fatalf("set %v", set) } if next.Roster.Revision != o.Roster.Revision+1 { t.Fatalf("revision %d, want one write", next.Roster.Revision) } r := next.Roster if r.Regions["ie"].Role != RoleLegacy || r.Boxes["g1"].Role != RoleLegacy || r.Boxes["g2"].Role != RoleLegacy { t.Fatalf("roles: %+v %+v %+v", r.Regions["ie"], r.Boxes["g1"], r.Boxes["g2"]) } // The operator-enrolled box in the same region is not the group's. if r.Boxes["box"].Role != RoleDefault { t.Fatalf("operator box took the group's role: %+v", r.Boxes["box"]) } again, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleLegacy) if err != nil || again.ETag != next.ETag { t.Fatalf("idempotent group set wrote: %v", err) } back, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleDefault) if err != nil || back.Roster.Boxes["g1"].Role != RoleDefault || back.Roster.Regions["ie"].Role != RoleDefault { t.Fatalf("back to default: %v", err) } if _, _, err = d.SetGroupRole(ctx, sg, back, "uk", RoleLegacy); !errors.Is(err, ErrRoster) { t.Fatalf("a region with no group: %v", err) } if _, _, err = d.SetGroupRole(ctx, sg, back, "ie", RolePlatform); !errors.Is(err, ErrRole) { t.Fatalf("a platform group: %v", err) } } // Automatic enrollment gives the new box the group's role in the same // roster write (A11-1): a replacement for an excluded group is never // placeable, even for a moment. func TestAutomaticEnrollmentInheritsTheGroupRole(t *testing.T) { for _, role := range []string{RoleDefault, RoleLegacy} { t.Run(RoleName(role), func(t *testing.T) { ctx := context.Background() d, sg, o := fixture(t) key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } doc := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`) sum := sha256.Sum256(doc) sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) if err != nil { t.Fatal(err) } raw, _ := json.Marshal(boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} next := Clone(o.Roster) next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}} if o, err = d.Replace(ctx, sg, o, next); err != nil { t.Fatal(err) } if _, err = d.Invite(ctx, sg, in.Reference, raw); err != nil { t.Fatal(err) } o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new") if err != nil { t.Fatal(err) } // The console's own read: a running member of the group. cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}} in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, time.Now) if err != nil { t.Fatal(err) } b := admitted.Roster.Boxes[newRef] if b.Group != "routers" || b.Role != role { t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role) } if admitted.Roster.Revision != o.Roster.Revision+1 { t.Fatal("the role was not set in the enrolling write") } }) } } // Every other roster write keeps the role: a quarantine, for one, rewrites // the box's entry and must not reopen placement on it. func TestOtherRosterWritesKeepTheRole(t *testing.T) { ctx := context.Background() d, sg, o := fixture(t) o, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy) if err != nil { t.Fatal(err) } q, err := d.Quarantine(ctx, sg, o, "box", o.Roster.Boxes["box"].Enrollment, "loss-1") if err != nil { t.Fatal(err) } if b := q.Roster.Boxes["box"]; b.Role != RoleLegacy || b.State != "quarantined" { t.Fatalf("after a quarantine: %+v", b) } read, err := d.Read(ctx) if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy { t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err) } }