| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io/fs" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/boxid" |
| 15 | "github.com/carlosframework/platform/internal/hostactors" |
| 16 | "github.com/carlosframework/platform/internal/hostfloor" |
| 17 | "github.com/carlosframework/platform/internal/status" |
| 18 | ) |
| 19 | |
| 20 | var joinNow = time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) |
| 21 | |
| 22 | // joinFacts is a box, "ada-hiking", in each state the tests name. The |
| 23 | // zero value has no host-actor.json and no invitation. |
| 24 | type joinFacts struct { |
| 25 | enrollment *hostfloor.Enrollment |
| 26 | enrollmentErr error |
| 27 | roster hostactors.Roster |
| 28 | rosterErr error |
| 29 | guard hostfloor.Running |
| 30 | guardErr error |
| 31 | invited bool |
| 32 | invitedErr error |
| 33 | report *status.Box |
| 34 | reportErr error |
| 35 | relaunchWhy string |
| 36 | relaunchErr error |
| 37 | relaunchAsked *bool |
| 38 | marked bool |
| 39 | region string |
| 40 | } |
| 41 | |
| 42 | func (j joinFacts) facts() modeFacts { |
| 43 | return modeFacts{ |
| 44 | box: "ada-hiking", |
| 45 | enrollment: func() (hostfloor.Enrollment, error) { |
| 46 | if j.enrollmentErr != nil { |
| 47 | return hostfloor.Enrollment{}, j.enrollmentErr |
| 48 | } |
| 49 | if j.enrollment == nil { |
| 50 | return hostfloor.Enrollment{}, &fs.PathError{Op: "open", Path: hostActorPath, Err: fs.ErrNotExist} |
| 51 | } |
| 52 | return *j.enrollment, nil |
| 53 | }, |
| 54 | roster: func(context.Context) (hostactors.Roster, error) { return j.roster, j.rosterErr }, |
| 55 | guard: func() (hostfloor.Running, error) { |
| 56 | if j.guardErr != nil { |
| 57 | return hostfloor.Running{}, j.guardErr |
| 58 | } |
| 59 | return j.guard, nil |
| 60 | }, |
| 61 | invited: func(context.Context, string) (bool, error) { return j.invited, j.invitedErr }, |
| 62 | report: func(context.Context, string) (status.Box, error) { |
| 63 | if j.reportErr != nil { |
| 64 | return status.Box{}, j.reportErr |
| 65 | } |
| 66 | if j.report == nil { |
| 67 | return status.Box{}, os.ErrNotExist |
| 68 | } |
| 69 | return *j.report, nil |
| 70 | }, |
| 71 | relaunch: func(context.Context, hostactors.Box) (string, error) { |
| 72 | if j.relaunchAsked != nil { |
| 73 | *j.relaunchAsked = true |
| 74 | } |
| 75 | return j.relaunchWhy, j.relaunchErr |
| 76 | }, |
| 77 | marked: j.marked, |
| 78 | region: j.region, |
| 79 | now: func() time.Time { return joinNow }, |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | const joinEnrollment = "0123456789abcdef0123456789abcdef" |
| 84 | |
| 85 | func enrolledFacts() joinFacts { |
| 86 | e := hostfloor.Enrollment{Box: "ada-hiking", Enrollment: joinEnrollment, InstanceID: "i-ada"} |
| 87 | return joinFacts{ |
| 88 | enrollment: &e, |
| 89 | roster: hostactors.Roster{FloorSHA256: "floor", Boxes: map[string]hostactors.Box{"ada-hiking": { |
| 90 | Enrollment: joinEnrollment, State: "active", Cloud: hostactors.Cloud{InstanceID: "i-ada"}}}}, |
| 91 | guard: hostfloor.Running{GuardReady: true, FloorSHA256: "floor", Hash: "image"}, |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // guardInstalled is a box that guard-arm has armed but the console has not |
| 96 | // enrolled yet. |
| 97 | func guardInstalled() joinFacts { |
| 98 | f := enrolledFacts() |
| 99 | f.roster.Boxes = map[string]hostactors.Box{} |
| 100 | return f |
| 101 | } |
| 102 | |
| 103 | func freshReport(at time.Time) *status.Box { |
| 104 | return &status.Box{GeneratedAt: at, Metrics: &status.BoxMetrics{Enrollment: joinEnrollment, FloorGuardReady: true, FloorSHA256: "floor", BinarySHA256: "image"}} |
| 105 | } |
| 106 | |
| 107 | func TestSelectModeHostsOnlyWhenEnrolledAndArmed(t *testing.T) { |
| 108 | d, err := selectMode(context.Background(), enrolledFacts().facts()) |
| 109 | if err != nil || d.Mode != modeHosting || d.Box != "ada-hiking" { |
| 110 | t.Fatalf("enrolled and armed: %+v %v", d, err) |
| 111 | } |
| 112 | held := map[string]func(*joinFacts){ |
| 113 | "the guard is not armed": func(f *joinFacts) { f.guardErr = hostfloor.ErrFloor }, |
| 114 | "the guard has no policy": func(f *joinFacts) { f.guardErr = &fs.PathError{Op: "open", Path: "policy.json", Err: fs.ErrNotExist} }, |
| 115 | "the guard is for another floor": func(f *joinFacts) { f.guard.FloorSHA256 = "old" }, |
| 116 | "the roster entry is quarantined": func(f *joinFacts) { |
| 117 | b := f.roster.Boxes["ada-hiking"] |
| 118 | b.State = "quarantined" |
| 119 | f.roster.Boxes["ada-hiking"] = b |
| 120 | }, |
| 121 | "the roster entry is another enrollment": func(f *joinFacts) { |
| 122 | b := f.roster.Boxes["ada-hiking"] |
| 123 | b.Enrollment = strings.Repeat("f", 32) |
| 124 | f.roster.Boxes["ada-hiking"] = b |
| 125 | }, |
| 126 | "the roster entry is another instance": func(f *joinFacts) { |
| 127 | b := f.roster.Boxes["ada-hiking"] |
| 128 | b.Cloud.InstanceID = "i-old" |
| 129 | f.roster.Boxes["ada-hiking"] = b |
| 130 | }, |
| 131 | "host-actor.json names another box": func(f *joinFacts) { f.enrollment.Box = "grace-hiking" }, |
| 132 | } |
| 133 | for name, spoil := range held { |
| 134 | t.Run(name, func(t *testing.T) { |
| 135 | f := enrolledFacts() |
| 136 | spoil(&f) |
| 137 | d, err := selectMode(context.Background(), f.facts()) |
| 138 | if err != nil || d.Mode != modeJoining || d.Stage != stageHeld || d.Why == "" { |
| 139 | t.Fatalf("%+v %v", d, err) |
| 140 | } |
| 141 | }) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // A restart at each joining state resumes joining at that state (spec 2 |
| 146 | // §11): nothing but an active roster entry with an armed guard hosts. |
| 147 | func TestSelectModeResumesEachJoiningStage(t *testing.T) { |
| 148 | for _, c := range []struct { |
| 149 | name string |
| 150 | facts joinFacts |
| 151 | want joinStage |
| 152 | }{ |
| 153 | {"no invitation", joinFacts{}, stageNoInvitation}, |
| 154 | {"invited", joinFacts{invited: true}, stageInvited}, |
| 155 | {"guard-arm cut off after host-actor.json", func() joinFacts { f := guardInstalled(); f.guardErr = hostfloor.ErrFloor; return f }(), stageInvited}, |
| 156 | {"guard installed", guardInstalled(), stageGuardInstalled}, |
| 157 | {"guard-ready reported", func() joinFacts { f := guardInstalled(); f.report = freshReport(joinNow.Add(-time.Minute)); return f }(), stageGuardReady}, |
| 158 | {"a stale report", func() joinFacts { |
| 159 | f := guardInstalled() |
| 160 | f.report = freshReport(joinNow.Add(-16 * time.Minute)) |
| 161 | return f |
| 162 | }(), stageGuardInstalled}, |
| 163 | {"a report for another image", func() joinFacts { |
| 164 | f := guardInstalled() |
| 165 | f.report = freshReport(joinNow) |
| 166 | f.report.Metrics.BinarySHA256 = "bootstrap" |
| 167 | return f |
| 168 | }(), stageGuardInstalled}, |
| 169 | } { |
| 170 | t.Run(c.name, func(t *testing.T) { |
| 171 | d, err := selectMode(context.Background(), c.facts.facts()) |
| 172 | if err != nil || d.Mode != modeJoining || d.Stage != c.want { |
| 173 | t.Fatalf("%+v %v, want joining at %s", d, err, c.want) |
| 174 | } |
| 175 | }) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A fact that cannot be read is unknown, never its negative: an enrolled |
| 180 | // box whose roster read fails is not sent back to joining. |
| 181 | func TestSelectModeIsUnknownWhenAFactCannotBeRead(t *testing.T) { |
| 182 | outage := errors.New("connection reset") |
| 183 | for name, f := range map[string]joinFacts{ |
| 184 | "the invitation": {invitedErr: outage}, |
| 185 | "host-actor.json": {enrollmentErr: hostfloor.ErrFloor}, |
| 186 | "the roster": func() joinFacts { f := enrolledFacts(); f.rosterErr = outage; return f }(), |
| 187 | "the guard": func() joinFacts { f := enrolledFacts(); f.guardErr = errors.New("permission denied"); return f }(), |
| 188 | "the status report": func() joinFacts { f := guardInstalled(); f.reportErr = outage; return f }(), |
| 189 | "a roster that does not verify": func() joinFacts { f := guardInstalled(); f.rosterErr = hostactors.ErrRoster; return f }(), |
| 190 | } { |
| 191 | t.Run(name, func(t *testing.T) { |
| 192 | d, err := selectMode(context.Background(), f.facts()) |
| 193 | if !errors.Is(err, errModeUnknown) || d.Mode == modeHosting { |
| 194 | t.Fatalf("%+v %v", d, err) |
| 195 | } |
| 196 | }) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // C3: a box its region's group launched hosts only on the launch it was |
| 201 | // enrolled on. Stopped and started since, it is held, never hosting on its |
| 202 | // old enrollment; a launch that cannot be read is unknown. A box an |
| 203 | // operator enrolled (no group) is not asked. |
| 204 | func TestSelectModeHoldsAGroupBoxStartedAgain(t *testing.T) { |
| 205 | grouped := func() joinFacts { |
| 206 | f := enrolledFacts() |
| 207 | b := f.roster.Boxes["ada-hiking"] |
| 208 | b.Group, b.Region = "routers-ie", "ie" |
| 209 | f.roster.Boxes["ada-hiking"] = b |
| 210 | f.relaunchAsked = new(bool) |
| 211 | f.marked, f.region = true, "ie" |
| 212 | return f |
| 213 | } |
| 214 | f := grouped() |
| 215 | if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting || !*f.relaunchAsked { |
| 216 | t.Fatalf("same launch: %+v %v", d, err) |
| 217 | } |
| 218 | f = grouped() |
| 219 | f.relaunchWhy = "this instance was stopped and started since it was enrolled" |
| 220 | if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeJoining || d.Stage != stageHeld || d.Why != f.relaunchWhy { |
| 221 | t.Fatalf("started again: %+v %v", d, err) |
| 222 | } |
| 223 | f = grouped() |
| 224 | f.relaunchErr = errors.New("IMDS answered 500") |
| 225 | if d, err := selectMode(context.Background(), f.facts()); !errors.Is(err, errModeUnknown) || d.Mode == modeHosting { |
| 226 | t.Fatalf("unreadable launch: %+v %v", d, err) |
| 227 | } |
| 228 | f = enrolledFacts() |
| 229 | f.relaunchWhy, f.relaunchAsked = "never asked", new(bool) |
| 230 | if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting || *f.relaunchAsked { |
| 231 | t.Fatalf("an operator-enrolled box: %+v %v, asked %v", d, err, *f.relaunchAsked) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestRelaunchedComparesTheLaunchTheRosterBinds(t *testing.T) { |
| 236 | record := func(pending string) (boxid.Record, []byte) { |
| 237 | doc := `{"accountId":"123","region":"eu-west-1","instanceId":"i-ada"` + pending + `}` |
| 238 | rec := boxid.Record{V: 1, Box: "i-ada", IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)), IdentitySignature: "c2ln"} |
| 239 | raw, _ := json.Marshal(rec) |
| 240 | return rec, raw |
| 241 | } |
| 242 | enrolledRec, enrolled := record(`,"pendingTime":"2026-10-17T09:00:00Z"`) |
| 243 | row := hostactors.Box{EvidenceSHA256: hostfloor.Digest(enrolled), Group: "routers-ie"} |
| 244 | if why := relaunched(row, enrolled, enrolledRec); why != "" { |
| 245 | t.Fatalf("the same launch: %q", why) |
| 246 | } |
| 247 | again, _ := record(`,"pendingTime":"2026-10-17T11:30:00Z"`) |
| 248 | if why := relaunched(row, enrolled, again); !strings.Contains(why, "stopped and started") { |
| 249 | t.Fatalf("started again: %q", why) |
| 250 | } |
| 251 | none, _ := record("") |
| 252 | if why := relaunched(row, enrolled, none); why == "" { |
| 253 | t.Fatal("a document with no pendingTime matched") |
| 254 | } |
| 255 | _, other := record(`,"pendingTime":"2026-10-17T09:00:00Z","x":1`) |
| 256 | if why := relaunched(row, other, enrolledRec); !strings.Contains(why, "not the one it was enrolled on") { |
| 257 | t.Fatalf("another record: %q", why) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // Condition 3 of census ruling region-ha-2b: the group marker and a group |
| 262 | // box's CARLOS_REGION come from the parameter store, and a value that |
| 263 | // disagrees with the signed roster holds the box instead of hosting it. |
| 264 | func TestSelectModeHoldsAStoreValueTheRosterDisagreesWith(t *testing.T) { |
| 265 | grouped := func() joinFacts { |
| 266 | f := enrolledFacts() |
| 267 | b := f.roster.Boxes["ada-hiking"] |
| 268 | b.Group, b.Region = "routers-ie", "ie" |
| 269 | f.roster.Boxes["ada-hiking"] = b |
| 270 | f.marked, f.region = true, "ie" |
| 271 | return f |
| 272 | } |
| 273 | if d, err := selectMode(context.Background(), grouped().facts()); err != nil || d.Mode != modeHosting { |
| 274 | t.Fatalf("agreeing: %+v %v", d, err) |
| 275 | } |
| 276 | for name, spoil := range map[string]func(*joinFacts){ |
| 277 | "marked, enrolled by hand": func(f *joinFacts) { |
| 278 | b := f.roster.Boxes["ada-hiking"] |
| 279 | b.Group = "" |
| 280 | f.roster.Boxes["ada-hiking"] = b |
| 281 | }, |
| 282 | "enrolled from a group, not marked": func(f *joinFacts) { f.marked = false }, |
| 283 | "another region": func(f *joinFacts) { f.region = "uk" }, |
| 284 | "no region": func(f *joinFacts) { f.region = "" }, |
| 285 | } { |
| 286 | t.Run(name, func(t *testing.T) { |
| 287 | f := grouped() |
| 288 | spoil(&f) |
| 289 | d, err := selectMode(context.Background(), f.facts()) |
| 290 | if err != nil || d.Mode != modeJoining || d.Stage != stageHeld || !strings.Contains(d.Why, "does not host") { |
| 291 | t.Fatalf("%+v %v", d, err) |
| 292 | } |
| 293 | }) |
| 294 | } |
| 295 | // A box an operator enrolled keeps its own region, unchecked. |
| 296 | f := enrolledFacts() |
| 297 | f.region = "anything" |
| 298 | if d, err := selectMode(context.Background(), f.facts()); err != nil || d.Mode != modeHosting { |
| 299 | t.Fatalf("an operator-enrolled box: %+v %v", d, err) |
| 300 | } |
| 301 | } |
| 302 | |