| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/boxid" |
| 15 | "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| 16 | "github.com/carlosframework/platform/internal/hostactors" |
| 17 | "github.com/carlosframework/platform/internal/hostenv" |
| 18 | "github.com/carlosframework/platform/internal/hostfloor" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | // rootJoinWorld is a deployment that has invited box "ada-hiking", whose |
| 23 | // identity record says it is instance i-ada. |
| 24 | type rootJoinWorld struct { |
| 25 | j rootJoin |
| 26 | store *release.DirStore |
| 27 | sg release.Signer |
| 28 | armed []string |
| 29 | invite hostactors.Invitation |
| 30 | } |
| 31 | |
| 32 | func newRootJoinWorld(t *testing.T, invite bool) *rootJoinWorld { |
| 33 | t.Helper() |
| 34 | ctx := context.Background() |
| 35 | pub, key, err := ed25519.GenerateKey(nil) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | w := &rootJoinWorld{store: release.NewDirStore(t.TempDir()), sg: release.RootSigner(key)} |
| 40 | _, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada") |
| 41 | if _, err := w.store.Put(ctx, boxid.Key("ada-hiking"), raw, ""); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | if err := hostfloor.Publish(ctx, w.store, w.sg, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | if invite { |
| 48 | if w.invite, err = (hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"}).Invite(ctx, w.sg, "ada-hiking", raw); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | } |
| 52 | w.j = rootJoin{box: "ada-hiking", optDir: t.TempDir(), bucket: "bucket", store: w.store, pub: pub, |
| 53 | instance: func() string { return "i-ada" }, armed: func() bool { return false }, |
| 54 | arm: func(policy, identity, enrollment string) error { |
| 55 | w.armed = []string{policy, identity, enrollment} |
| 56 | return nil |
| 57 | }} |
| 58 | return w |
| 59 | } |
| 60 | |
| 61 | func TestRootJoinArmsWithTheInvitationsEnrollment(t *testing.T) { |
| 62 | t.Parallel() |
| 63 | w := newRootJoinWorld(t, true) |
| 64 | got, err := w.j.run(context.Background()) |
| 65 | if err != nil || got != joinArmed || len(w.armed) != 3 { |
| 66 | t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| 67 | } |
| 68 | raw, err := os.ReadFile(w.armed[0]) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | var p hostfloor.Policy |
| 73 | if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| 74 | t.Fatalf("policy %s", raw) |
| 75 | } |
| 76 | if _, err := p.Verify(w.j.pub, "bucket"); err != nil { |
| 77 | t.Fatalf("the policy does not verify: %v", err) |
| 78 | } |
| 79 | if w.armed[1] != channelIdentityPath(w.j.optDir) { |
| 80 | t.Fatalf("identity %s", w.armed[1]) |
| 81 | } |
| 82 | e, err := hostfloor.ReadEnrollment(w.armed[2]) |
| 83 | if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) { |
| 84 | t.Fatalf("enrollment %+v %v", e, err) |
| 85 | } |
| 86 | for _, p := range []string{w.armed[0], w.armed[2]} { |
| 87 | if fi, err := os.Stat(p); err != nil || fi.Mode().Perm() != 0o600 { |
| 88 | t.Fatalf("%s: %v %v", p, fi, err) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestRootJoinDoesNothingUntilInvitedOrOnceArmed(t *testing.T) { |
| 94 | t.Parallel() |
| 95 | w := newRootJoinWorld(t, false) |
| 96 | if got, err := w.j.run(context.Background()); err != nil || got != joinNotInvited || w.armed != nil { |
| 97 | t.Fatalf("not invited: %s %v %v", got, err, w.armed) |
| 98 | } |
| 99 | v := newRootJoinWorld(t, true) |
| 100 | v.j.armed = func() bool { return true } |
| 101 | v.j.store = nil // an armed box reads nothing |
| 102 | if got, err := v.j.run(context.Background()); err != nil || got != joinArmedAlready || v.armed != nil { |
| 103 | t.Fatalf("armed: %s %v %v", got, err, v.armed) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestRootJoinRefusesAnInvitationThatIsNotThisBoxs(t *testing.T) { |
| 108 | t.Parallel() |
| 109 | for name, spoil := range map[string]func(*rootJoinWorld){ |
| 110 | "another instance": func(w *rootJoinWorld) { w.j.instance = func() string { return "i-other" } }, |
| 111 | "another record": func(w *rootJoinWorld) { |
| 112 | _, raw := boxidtest.NewSigner(t).Record(t, "ada-hiking", "123", "eu-west-1", "i-ada") |
| 113 | _, etag, _ := w.store.Get(context.Background(), boxid.Key("ada-hiking")) |
| 114 | w.store.Put(context.Background(), boxid.Key("ada-hiking"), raw, etag) |
| 115 | }, |
| 116 | "another release key": func(w *rootJoinWorld) { |
| 117 | other, _, _ := ed25519.GenerateKey(nil) |
| 118 | w.j.pub = other |
| 119 | }, |
| 120 | "another deployment": func(w *rootJoinWorld) { w.j.bucket = "other-bucket" }, |
| 121 | } { |
| 122 | t.Run(name, func(t *testing.T) { |
| 123 | w := newRootJoinWorld(t, true) |
| 124 | spoil(w) |
| 125 | if got, err := w.j.run(context.Background()); err == nil || w.armed != nil { |
| 126 | t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| 127 | } |
| 128 | }) |
| 129 | } |
| 130 | // A store that cannot be read is an error, never "not invited". |
| 131 | w := newRootJoinWorld(t, true) |
| 132 | w.j.store = joinOutageStore{} |
| 133 | if got, err := w.j.run(context.Background()); err == nil || got == joinNotInvited { |
| 134 | t.Fatalf("unreadable store: %s %v", got, err) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | type joinOutageStore struct{ release.Store } |
| 139 | |
| 140 | func (joinOutageStore) Get(context.Context, string) ([]byte, string, error) { |
| 141 | return nil, "", errors.New("connection reset") |
| 142 | } |
| 143 | |
| 144 | // The join units' census condition (platform-1e, region-ha-2b): the path |
| 145 | // unit's file is only a trigger. Whatever the agent, or anything else, |
| 146 | // writes into it, root's join reads none of it, takes no parameter from |
| 147 | // it, and decides from the signed invitation, the published identity |
| 148 | // record, the signed host floor and IMDS alone. |
| 149 | func TestRootJoinTakesNothingFromItsTrigger(t *testing.T) { |
| 150 | data := t.TempDir() |
| 151 | t.Setenv("CARLOS_DATA_DIR", data) |
| 152 | clean := newRootJoinWorld(t, true) |
| 153 | want, err := clean.j.run(context.Background()) |
| 154 | if err != nil || want != joinArmed { |
| 155 | t.Fatalf("baseline: %s %v", want, err) |
| 156 | } |
| 157 | for name, body := range map[string]string{ |
| 158 | "another box": `{"box":"grace-hiking","instance":"i-grace","enrollment":"ffffffffffffffffffffffffffffffff"}`, |
| 159 | "a path": "../../etc/carlos/host-actor.json\n", |
| 160 | "a policy": `{"schema":1,"mode":"armed","deployment_bucket":"other-bucket"}`, |
| 161 | "nul and newlines": "a\x00b\n\n--policy=/tmp/x\n", |
| 162 | "a megabyte of junk": strings.Repeat("x", 1<<20), |
| 163 | } { |
| 164 | t.Run(name, func(t *testing.T) { |
| 165 | if err := os.WriteFile(filepath.Join(data, "system-join-requested"), []byte(body), 0o644); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | w := newRootJoinWorld(t, true) |
| 169 | got, err := w.j.run(context.Background()) |
| 170 | if err != nil || got != want || len(w.armed) != 3 || w.armed[1] != channelIdentityPath(w.j.optDir) { |
| 171 | t.Fatalf("%s %v, armed %v", got, err, w.armed) |
| 172 | } |
| 173 | e, err := hostfloor.ReadEnrollment(w.armed[2]) |
| 174 | if err != nil || e != (hostfloor.Enrollment{Box: "ada-hiking", Enrollment: w.invite.Enrollment, InstanceID: "i-ada"}) { |
| 175 | t.Fatalf("enrollment %+v %v", e, err) |
| 176 | } |
| 177 | }) |
| 178 | } |
| 179 | // And by construction: root's join names neither the trigger nor its |
| 180 | // path anywhere in its source. |
| 181 | src, err := os.ReadFile("joinroot.go") |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | if bytes.Contains(src, []byte("joinRequestMarker")) || bytes.Contains(src, []byte("system-join-requested")) { |
| 186 | t.Fatal("joinroot.go reads its trigger") |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // A group box names its invitation by its group reference. While IMDS has |
| 191 | // not given it, there is no name, and the join says so; it never reads the |
| 192 | // invitation of "" and reports "not invited". |
| 193 | func TestRootJoinNamesAGroupBoxByItsReferenceOrFails(t *testing.T) { |
| 194 | group := groupEnv(map[string]string{hostenv.BoxReference: hostenv.GroupReference}) |
| 195 | withGroupIdentity(t, "", "", errors.New("IMDS answered 500")) |
| 196 | if got, err := joinBoxLabel(group, "machine"); err == nil || got != "" || err.Error() != "this box's AWS identity could not be read, so it cannot join yet: IMDS answered 500" { |
| 197 | t.Fatalf("no identity: %q %v", got, err) |
| 198 | } |
| 199 | withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil) |
| 200 | want := hostactors.GroupBoxReference("123456789012", "i-0a61f580df1acfa65") |
| 201 | if got, err := joinBoxLabel(group, "machine"); err != nil || got != want { |
| 202 | t.Fatalf("group box: %q %v, want %q", got, err, want) |
| 203 | } |
| 204 | // Any other box keeps metricsBoxLabel's name. |
| 205 | other := groupEnv(nil) |
| 206 | if got, err := joinBoxLabel(other, "machine"); err != nil || got != metricsBoxLabel(other, "machine") || got == "" { |
| 207 | t.Fatalf("other box: %q %v", got, err) |
| 208 | } |
| 209 | // And a run with no name reads nothing. |
| 210 | w := newRootJoinWorld(t, true) |
| 211 | w.j.box = "" |
| 212 | w.j.store = nil |
| 213 | if got, err := w.j.run(context.Background()); err == nil || got == joinNotInvited || w.armed != nil { |
| 214 | t.Fatalf("nameless: %s %v %v", got, err, w.armed) |
| 215 | } |
| 216 | } |
| 217 | |