| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "reflect" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/boxchoice" |
| 13 | "github.com/carlosframework/platform/internal/hostops" |
| 14 | ) |
| 15 | |
| 16 | // The count runs without any cloud adapter (§25): it reads only the |
| 17 | // edges snapshot and the chooser. Each region's AWS region is the |
| 18 | // chooser's (the signed roster's), whatever a file claims: box z's file |
| 19 | // names uk in eu-west-1. |
| 20 | func TestRegionCapacitiesCountsEachPlacement(t *testing.T) { |
| 21 | snap := boxchoice.Snapshot{Complete: true, Boxes: []boxchoice.Box{ |
| 22 | {BoxID: "a", Placement: "uk", Region: "eu-west-2"}, |
| 23 | {BoxID: "b", Placement: "uk", Region: "eu-west-2"}, |
| 24 | {BoxID: "c", Placement: "ie", Region: "eu-west-1"}, |
| 25 | {BoxID: "d", Placement: "fleet/pi", Region: "eu-west-1"}, |
| 26 | {BoxID: "e", Placement: ""}, |
| 27 | {BoxID: "z", Placement: "uk", Region: "eu-west-1"}, |
| 28 | }} |
| 29 | asked := []string{} |
| 30 | capacity := func(_ context.Context, p string) (int, string, error) { |
| 31 | asked = append(asked, p) |
| 32 | switch p { |
| 33 | case "uk": |
| 34 | return 2, "eu-west-2", nil |
| 35 | case "ie": |
| 36 | return 0, "eu-west-1", nil |
| 37 | } |
| 38 | return 0, "", hostops.ErrContract // no roster region: left out |
| 39 | } |
| 40 | got, err := regionCapacities(context.Background(), snap, "", capacity) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-west-1", Eligible: 0}, {Placement: "uk", AWSRegion: "eu-west-2", Eligible: 2}} |
| 45 | if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(asked, []string{"fleet/pi", "ie", "uk"}) { |
| 46 | t.Fatalf("got %+v asked %v", got, asked) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // Controller ruling: a region whose count fails, or whose read is |
| 51 | // incomplete, gets no data point (never a 0, which would trip the alarm on |
| 52 | // a transient read failure; missing data is the alarm's own signal if it |
| 53 | // lasts). The other regions' counts are unaffected, and the error is |
| 54 | // returned so the pass logs it. |
| 55 | func TestRegionCapacitiesOmitsARegionWhoseReadFailed(t *testing.T) { |
| 56 | snap := boxchoice.Snapshot{Complete: true, Boxes: []boxchoice.Box{ |
| 57 | {BoxID: "a", Placement: "ie"}, {BoxID: "b", Placement: "uk"}, {BoxID: "c", Placement: "us"}, |
| 58 | }} |
| 59 | for name, failure := range map[string]error{ |
| 60 | "incomplete": hostops.ErrEvidenceIncomplete, |
| 61 | "other": errors.New("SlowDown"), |
| 62 | } { |
| 63 | capacity := func(_ context.Context, p string) (int, string, error) { |
| 64 | if p == "uk" { |
| 65 | return 0, "", failure |
| 66 | } |
| 67 | return 1, "eu-" + p, nil |
| 68 | } |
| 69 | got, err := regionCapacities(context.Background(), snap, "", capacity) |
| 70 | want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-ie", Eligible: 1}, {Placement: "us", AWSRegion: "eu-us", Eligible: 1}} |
| 71 | if !errors.Is(err, failure) || !reflect.DeepEqual(got, want) { |
| 72 | t.Fatalf("%s: got %+v, %v; want %+v and the failure", name, got, err, want) |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Astra 2a final review, P2: a home box publishes the home's placement |
| 78 | // code "". It is counted under the home's roster name, as the chooser |
| 79 | // reads it, once whether the home's boxes publish "" or the name; with no |
| 80 | // home name (or one that is not a placement code) the chooser places |
| 81 | // nothing in the home, and it is not asked. |
| 82 | func TestRegionCapacitiesCountsTheHomeUnderItsName(t *testing.T) { |
| 83 | snap := boxchoice.Snapshot{Complete: true, Boxes: []boxchoice.Box{ |
| 84 | {BoxID: "a", Placement: ""}, {BoxID: "b", Placement: ""}, {BoxID: "c", Placement: "ie"}, {BoxID: "d", Placement: "uk"}, |
| 85 | }} |
| 86 | for home, wantAsked := range map[string][]string{ |
| 87 | "ie": {"ie", "uk"}, |
| 88 | "": {"ie", "uk"}, |
| 89 | "Not/Code": {"ie", "uk"}, |
| 90 | "de": {"de", "ie", "uk"}, |
| 91 | } { |
| 92 | asked := []string{} |
| 93 | capacity := func(_ context.Context, p string) (int, string, error) { |
| 94 | asked = append(asked, p) |
| 95 | return 2, "aws-" + p, nil |
| 96 | } |
| 97 | got, err := regionCapacities(context.Background(), snap, home, capacity) |
| 98 | if err != nil || !reflect.DeepEqual(asked, wantAsked) || len(got) != len(wantAsked) { |
| 99 | t.Fatalf("home %q: got %+v, %v, asked %v; want %v asked", home, got, err, asked, wantAsked) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | // The same end to end, with only home boxes: each publishes placement "", |
| 105 | // and the pass publishes the home's placeable count, non-zero, to the AWS |
| 106 | // region the signed roster gives the home (ie, eu-west-1). |
| 107 | func TestPublishCapacityCountsTheHomeRegion(t *testing.T) { |
| 108 | cx := haClaimCase(t, "ie") |
| 109 | home := func(m map[string]any) { m["placement"] = "" } |
| 110 | publishHABox(t, cx, "box-ie-default", false, home) |
| 111 | publishHABox(t, cx, "box-ie-tito", false, home) |
| 112 | var pub capacityRecorder |
| 113 | if err := cx.srv.publishCapacity(context.Background(), &pub); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if len(pub.got) != 1 || pub.got[0].Placement != "ie" || pub.got[0].AWSRegion != "eu-west-1" || pub.got[0].Eligible == 0 { |
| 117 | t.Fatalf("published %+v (%d calls), want the home ie with a non-zero count in eu-west-1", pub.got, pub.calls) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // capacityRecorder keeps what one pass would publish. |
| 122 | type capacityRecorder struct { |
| 123 | got []regionCapacity |
| 124 | calls int |
| 125 | } |
| 126 | |
| 127 | func (c *capacityRecorder) PublishCapacity(_ context.Context, counts []regionCapacity) error { |
| 128 | c.got = counts |
| 129 | c.calls++ |
| 130 | return nil |
| 131 | } |
| 132 | |
| 133 | // Astra 2a round 2 finding 3, end to end: a stray box's edges file naming |
| 134 | // ie in another AWS region (and sorting after the real box) does not move |
| 135 | // ie's count out of the roster's eu-west-1. |
| 136 | func TestPublishCapacityTakesTheRegionFromTheRoster(t *testing.T) { |
| 137 | cx := haClaimCase(t, "") |
| 138 | publishHABox(t, cx, "box-ie-default", false, nil) |
| 139 | publishHABox(t, cx, "box-ie-stray", false, func(m map[string]any) { m["region"] = "eu-west-2" }) |
| 140 | var pub capacityRecorder |
| 141 | if err := cx.srv.publishCapacity(context.Background(), &pub); err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-west-1", Eligible: 1}} |
| 145 | if !reflect.DeepEqual(pub.got, want) { |
| 146 | t.Fatalf("published %+v, want %+v", pub.got, want) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // Controller ruling 1: the pass counts through the scoped chooser, so a |
| 151 | // region with a placeable box publishes a non-zero count (an unscoped |
| 152 | // chooser answers 0 for every region). |
| 153 | func TestPublishCapacityCountsPlaceableBoxes(t *testing.T) { |
| 154 | cx := haClaimCase(t, "") |
| 155 | publishHABox(t, cx, "box-ie-default", false, nil) |
| 156 | var pub capacityRecorder |
| 157 | if err := cx.srv.publishCapacity(context.Background(), &pub); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | if len(pub.got) != 1 || pub.got[0].Placement != "ie" || pub.got[0].Eligible != 1 { |
| 161 | t.Fatalf("published %+v, want ie with 1", pub.got) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // Controller ruling 2: an incomplete read publishes nothing for the |
| 166 | // region (not 0), while a complete read with no placeable box publishes 0. |
| 167 | func TestPublishCapacityIncompleteReadPublishesNothingButNoRoomPublishesZero(t *testing.T) { |
| 168 | ctx := context.Background() |
| 169 | cx := haClaimCase(t, "") |
| 170 | publishHABox(t, cx, "box-ie-default", true, nil) // stale: named, not placeable |
| 171 | var pub capacityRecorder |
| 172 | if err := cx.srv.publishCapacity(ctx, &pub); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-west-1", Eligible: 0}} |
| 176 | if pub.calls != 1 || !reflect.DeepEqual(pub.got, want) { |
| 177 | t.Fatalf("a complete read with no placeable box published %+v (%d calls), want %+v", pub.got, pub.calls, want) |
| 178 | } |
| 179 | |
| 180 | publishHABox(t, cx, "box-ie-default", false, nil) |
| 181 | pub = capacityRecorder{} |
| 182 | edges := cx.srv.cfg.ClaimAuthority.Executors.Edges |
| 183 | cx.srv.cfg.ClaimAuthority.Executors.Edges = func(ctx context.Context) (boxchoice.Snapshot, error) { |
| 184 | snap, err := edges(ctx) |
| 185 | snap.Complete = false |
| 186 | return snap, err |
| 187 | } |
| 188 | err := cx.srv.publishCapacity(ctx, &pub) |
| 189 | if !errors.Is(err, hostops.ErrEvidenceIncomplete) { |
| 190 | t.Fatalf("an incomplete read: %v, want ErrEvidenceIncomplete", err) |
| 191 | } |
| 192 | if pub.calls != 0 || pub.got != nil { |
| 193 | t.Fatalf("an incomplete read published %+v (%d calls), want nothing", pub.got, pub.calls) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // The same, with the only box's own file naming another AWS region: the |
| 198 | // chooser does not count a box whose file disagrees with the roster, and |
| 199 | // the 0 still goes to the roster's eu-west-1, not the file's eu-west-2. |
| 200 | func TestPublishCapacityIgnoresTheBoxsOwnAWSRegion(t *testing.T) { |
| 201 | cx := haClaimCase(t, "") |
| 202 | publishHABox(t, cx, "box-ie-default", false, func(m map[string]any) { m["region"] = "eu-west-2" }) |
| 203 | var pub capacityRecorder |
| 204 | if err := cx.srv.publishCapacity(context.Background(), &pub); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-west-1", Eligible: 0}} |
| 208 | if !reflect.DeepEqual(pub.got, want) { |
| 209 | t.Fatalf("published %+v, want %+v", pub.got, want) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestPlacementChooserIsScopedLikeCreate(t *testing.T) { |
| 214 | s := &Server{cfg: Config{RegionHA: true}} |
| 215 | if s.placementChooser("") != nil { |
| 216 | t.Fatal("a chooser with no claim runtime") |
| 217 | } |
| 218 | x := &hostops.ExecutorChooser{Namespace: "stale", Pool: "acme"} |
| 219 | s.cfg.ClaimAuthority = &hostops.ProductionAuthority{Namespace: "platform", Executors: x} |
| 220 | got := s.placementChooser("") |
| 221 | if got == nil || got.Namespace != "platform" || got.Pool != "" || x.Namespace != "stale" { |
| 222 | t.Fatalf("chooser %+v, original %+v", got, x) |
| 223 | } |
| 224 | s.cfg.RegionHA = false |
| 225 | if s.placementChooser("") != nil { |
| 226 | t.Fatal("a chooser with region HA off") |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | func TestCloudWatchCapacityPublishesEachRegionInItsOwnRegion(t *testing.T) { |
| 231 | now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) |
| 232 | type call struct { |
| 233 | region, ns, metric, dim string |
| 234 | value float64 |
| 235 | } |
| 236 | var calls []call |
| 237 | c := cloudWatchCapacity{now: func() time.Time { return now }, put: func(_ context.Context, region string, in *cloudwatch.PutMetricDataInput) error { |
| 238 | d := in.MetricData[0] |
| 239 | calls = append(calls, call{region, *in.Namespace, *d.MetricName, *d.Dimensions[0].Name + "=" + *d.Dimensions[0].Value, *d.Value}) |
| 240 | if !d.Timestamp.Equal(now) || d.Unit != "Count" { |
| 241 | t.Fatalf("datum %+v", d) |
| 242 | } |
| 243 | return nil |
| 244 | }} |
| 245 | err := c.PublishCapacity(context.Background(), []regionCapacity{{"ie", "eu-west-1", 2}, {"uk", "eu-west-2", 0}}) |
| 246 | want := []call{{"eu-west-1", "Carlos/Region", "EligibleBoxes", "Region=ie", 2}, {"eu-west-2", "Carlos/Region", "EligibleBoxes", "Region=uk", 0}} |
| 247 | if err != nil || !reflect.DeepEqual(calls, want) { |
| 248 | t.Fatalf("calls %+v %v", calls, err) |
| 249 | } |
| 250 | if capacityPublisherFromEnv(context.Background(), func(string) string { return "" }) != nil { |
| 251 | t.Fatal("published with CARLOS_REGION_METRICS unset") |
| 252 | } |
| 253 | } |
| 254 | |