region HA 2a: dial a box's private address within its region
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+151
−12
internal/edge/homemap.go+62 −9internal/edge/homemap_private_test.go+84 −0internal/edge/homemap_test.go+3 −3internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+2 −0
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index dfc1c85ec..504e6447a 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -80,10 +80,12 @@ import ( |
| "os" |
| "slices" |
| "sort" |
| + "strconv" |
| "strings" |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -132,6 +134,39 @@ type edgesFileView struct { |
| Box string `json:"box"` |
| EIP string `json:"eip"` |
| Hosts map[string]edgesHostView `json:"hosts"` |
| + // PrivateIP, PrivateCIDR, Region and Placement choose the dial address |
| + // (region HA spec 2 §6, dialAddress). All are absent from a writer that |
| + // predates them, and the dial is then EIP, as before. |
| + PrivateIP string `json:"private_ip"` |
| + PrivateCIDR string `json:"private_cidr"` |
| + Region string `json:"region"` |
| + Placement string `json:"placement"` |
| +} |
| + |
| +// dialAddress is where this edge dials f's box (region HA spec 2 §6). With |
| +// privateDial (this edge's own region HA switch) on, a box that publishes |
| +// the same AWS region and placement as this one is dialled at its private |
| +// address, so the dial stays inside the region's network, but only when |
| +// that address passes edgesfile.PrivateAddress against the network this |
| +// box's own file publishes (census ruling region-ha-2a). Every other box, |
| +// and every box with privateDial off, is dialled at its public address, |
| +// as before. self is this box's own file, which selfKnown says was found. |
| +// "" is nothing to dial. |
| +func dialAddress(f, self edgesFileView, selfKnown, privateDial bool) string { |
| + if privateDial && selfKnown && f.Region != "" && f.Placement != "" && |
| + f.Region == self.Region && f.Placement == self.Placement { |
| + if addr, _ := edgesfile.PrivateAddress(f.PrivateIP, []string{self.PrivateCIDR}); addr != "" { |
| + return addr |
| + } |
| + } |
| + return f.EIP |
| +} |
| + |
| +// regionHAFromEnv is CARLOS_REGION_HA as strconv.ParseBool reads it, the |
| +// reading every region HA switch makes. |
| +func regionHAFromEnv(getenv func(string) string) bool { |
| + on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA"))) |
| + return err == nil && on |
| } |
| // edgesHostView is the per-host object. An object rather than a bare string is |
| @@ -161,6 +196,8 @@ type edgesHostView struct { |
| // homeEntry is one folded answer: where to dial, and which box that is. |
| type homeEntry struct { |
| + // eip is the address to dial: dialAddress's answer, the home box's |
| + // private address within its region and its EIP from anywhere else. |
| eip string |
| box string |
| // platform carries the publisher's class mark through the fold. |
| @@ -238,6 +275,10 @@ type homeMap struct { |
| keyMu sync.RWMutex |
| keyCurrent []byte |
| keyNext []byte |
| + |
| + // privateDial is this edge's region HA switch: only with it on does |
| + // the fold dial a same-region box's private address (dialAddress). |
| + privateDial bool |
| } |
| func newHomeMap(s release.Store, self string) *homeMap { |
| @@ -253,7 +294,9 @@ func (e *Edge) newHomeMap() *homeMap { |
| if e.staticStore == nil { |
| return nil |
| } |
| - return newHomeMap(e.staticStore, e.box) |
| + m := newHomeMap(e.staticStore, e.box) |
| + m.privateDial = regionHAFromEnv(os.Getenv) |
| + return m |
| } |
| // Refresh rebuilds the host fold and re-reads the mesh keys. The two halves |
| @@ -330,7 +373,7 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| if failed > 0 { |
| m.logPartialPoll(failed, tried, lastErr) |
| } |
| - hosts, tied := foldHomes(next, m.self) |
| + hosts, tied := foldHomes(next, m.self, m.privateDial) |
| if len(tied) > 0 { |
| // One claim revision names one executor, so two boxes publishing |
| // one host at the same stamp is a fault upstream. The fold stays |
| @@ -532,9 +575,11 @@ func (m *homeMap) logPartialPoll(failed, tried int, err error) { |
| // target would have the edge dial itself. Excluded by the file's own box |
| // field rather than by its object name, because the field is what the |
| // publisher stamps and what self is derived to match. |
| -// - NO EIP. Task 1 publishes a box with no discoverable public IP as |
| -// eip:"" so the DR runbook still gets its hosts list. There is nothing to |
| -// dial, so it is not a target. |
| +// - NO DIAL ADDRESS. Task 1 publishes a box with no discoverable public IP |
| +// as eip:"" so the DR runbook still gets its hosts list. There is |
| +// nothing to dial, so it is not a target. The address is dialAddress's: |
| +// a box in this box's own region is dialled at its private address when |
| +// it publishes one (region HA spec 2 §6), every other at its EIP. |
| // - A ROLE THIS BUILD DOES NOT KNOW. Only "home" routes. The role field |
| // exists so read-local roles can be added later; a reader that treated |
| // every role as a home would send traffic to a box that had declared it |
| @@ -546,28 +591,36 @@ func (m *homeMap) logPartialPoll(failed, tried int, err error) { |
| // split-and-wrong: the whole fleet aiming at one box is a condition that heals |
| // on the next publish, where half the fleet aiming at each is a condition that |
| // looks fine from any single box. |
| -func foldHomes(files map[string]edgesFileView, self string) (map[string]homeEntry, []string) { |
| +func foldHomes(files map[string]edgesFileView, self string, privateDial bool) (map[string]homeEntry, []string) { |
| keys := make([]string, 0, len(files)) |
| for k := range files { |
| keys = append(keys, k) |
| } |
| slices.Sort(keys) |
| + var own edgesFileView |
| + ownKnown := false |
| + for _, k := range keys { |
| + if f := files[k]; self != "" && f.Box == self { |
| + own, ownKnown = f, true |
| + } |
| + } |
| hosts := make(map[string]homeEntry) |
| for _, k := range keys { |
| f := files[k] |
| if f.Box == "" || f.Box == self { |
| continue |
| } |
| + addr := dialAddress(f, own, ownKnown, privateDial) |
| for host, h := range f.Hosts { |
| if host == "" { |
| continue |
| } |
| switch h.Role { |
| case edgeRoleHome: |
| - // A dial target needs an address. A file with no EIP (a box |
| + // A dial target needs an address. A file with none (a box |
| // with no discoverable public IP) still carries its cert |
| // halves below; its dial halves are simply not dialable. |
| - if f.EIP == "" { |
| + if addr == "" { |
| if !h.Wildcard || !strings.HasPrefix(host, "*.") { |
| continue |
| } |
| @@ -583,7 +636,7 @@ func foldHomes(files map[string]edgesFileView, self string) (map[string]homeEntr |
| default: |
| continue |
| } |
| - in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome, |
| + in := homeEntry{eip: addr, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome, |
| account: h.Account, app: h.App, stamp: h.Stamp} |
| prev, dup := hosts[host] |
| if !dup { |
diff --git a/internal/edge/homemap_private_test.go b/internal/edge/homemap_private_test.go| new file mode 100644 |
| index 000000000..ec2c9c018 |
| --- /dev/null |
| +++ b/internal/edge/homemap_private_test.go |
| @@ -0,0 +1,84 @@ |
| +package edge |
| + |
| +import "testing" |
| + |
| +// privateFleet publishes this edge's own file (annie, in ie, network |
| +// 10.0.0.0/16) and one file per case. |
| +func privateFleet(t *testing.T, m *homeMap) { |
| + t.Helper() |
| + putJSON(t, m.s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","private_ip":"10.0.1.1","private_cidr":"10.0.0.0/16","region":"eu-west-1","placement":"ie","hosts":{}}`) |
| + putJSON(t, m.s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","private_ip":"10.0.1.2","private_cidr":"10.0.0.0/16","region":"eu-west-1","placement":"ie","hosts":{"b.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/carla.json", `{"v":1,"box":"carla","eip":"3.3.3.3","private_ip":"10.1.1.3","private_cidr":"10.1.0.0/16","region":"eu-west-2","placement":"uk","hosts":{"c.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/dora.json", `{"v":1,"box":"dora","eip":"4.4.4.4","region":"eu-west-1","placement":"ie","hosts":{"d.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/edna.json", `{"v":1,"box":"edna","eip":"5.5.5.5","private_ip":"10.0.1.5","region":"eu-west-1","placement":"fleet/shop","hosts":{"e.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/fern.json", `{"v":1,"box":"fern","eip":"","private_ip":"10.0.1.6","region":"eu-west-1","placement":"ie","hosts":{"f.test":{"role":"home"}}}`) |
| + // Addresses a reader refuses: each falls back to the public address. |
| + putJSON(t, m.s, "control/edges/gail.json", `{"v":1,"box":"gail","eip":"7.7.7.7","private_ip":"10.0.1","region":"eu-west-1","placement":"ie","hosts":{"g.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/hana.json", `{"v":1,"box":"hana","eip":"8.8.8.8","private_ip":"169.254.169.254","private_cidr":"169.254.0.0/16","region":"eu-west-1","placement":"ie","hosts":{"h.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/iris.json", `{"v":1,"box":"iris","eip":"9.9.9.9","private_ip":"10.9.1.9","private_cidr":"10.9.0.0/16","region":"eu-west-1","placement":"ie","hosts":{"i.test":{"role":"home"}}}`) |
| + refreshHomeMap(t, m) |
| +} |
| + |
| +// Region HA spec 2 §6: with this edge's region HA on, a box in the same |
| +// region is dialled at its private address and a box in another region at |
| +// its own public address. An address that fails validation against this |
| +// edge's own network is refused, and the dial is public. |
| +func TestHomeMapDialsPrivateWithinTheRegionAndPublicBetween(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + m.privateDial = true |
| + privateFleet(t, m) |
| + for host, want := range map[string]string{ |
| + "b.test": "10.0.1.2", // same region and placement: private |
| + "c.test": "3.3.3.3", // another region: its own public address |
| + "d.test": "4.4.4.4", // a writer with no private_ip: public, as before |
| + "e.test": "5.5.5.5", // same AWS region, another placement: public |
| + "f.test": "10.0.1.6", // no public address, but private within the region |
| + "g.test": "7.7.7.7", // a malformed private address: refused |
| + "h.test": "8.8.8.8", // the metadata range: refused |
| + "i.test": "9.9.9.9", // outside this region's network: refused |
| + } { |
| + if addr, _, ok := m.Home(host); !ok || addr != want { |
| + t.Errorf("%s dials %q (ok=%v), want %q", host, addr, ok, want) |
| + } |
| + } |
| + // An edge whose own file is not in the map knows no region or network |
| + // of its own: every dial is public, and a box with no public address |
| + // is no target. |
| + ghost := newHomeMap(m.s, "ghost") |
| + ghost.privateDial = true |
| + refreshHomeMap(t, ghost) |
| + if addr, _, _ := ghost.Home("b.test"); addr != "2.2.2.2" { |
| + t.Errorf("without its own file: b.test dials %q, want 2.2.2.2", addr) |
| + } |
| + if _, _, ok := ghost.Home("f.test"); ok { |
| + t.Error("without its own file, a box with no public address became a target") |
| + } |
| +} |
| + |
| +// With this edge's region HA off, a peer publishing private_ip changes |
| +// nothing: every dial is public, as on main, during a mixed roll or a |
| +// rollback. |
| +func TestHomeMapWithRegionHAOffDialsPublicOnly(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "annie") |
| + privateFleet(t, m) |
| + for host, want := range map[string]string{"b.test": "2.2.2.2", "c.test": "3.3.3.3", "d.test": "4.4.4.4"} { |
| + if addr, _, ok := m.Home(host); !ok || addr != want { |
| + t.Errorf("%s dials %q (ok=%v), want %q", host, addr, ok, want) |
| + } |
| + } |
| + if _, _, ok := m.Home("f.test"); ok { |
| + t.Error("a box with only a private address became a target with region HA off") |
| + } |
| +} |
| + |
| +func TestEdgeHomeMapFollowsRegionHA(t *testing.T) { |
| + e := &Edge{staticStore: meshTestStore(t), box: "annie"} |
| + t.Setenv("CARLOS_REGION_HA", "") |
| + if e.newHomeMap().privateDial { |
| + t.Fatal("region HA unset dials private") |
| + } |
| + t.Setenv("CARLOS_REGION_HA", "1") |
| + if !e.newHomeMap().privateDial { |
| + t.Fatal("region HA on does not dial private") |
| + } |
| +} |
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go| index 5c9d5e0e9..cdf518660 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -1715,7 +1715,7 @@ func TestHomeMapReportsTwoBoxesAtTheSameStamp(t *testing.T) { |
| "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{ |
| "tied.test": {Role: "home", Stamp: 7}, "fine.test": {Role: "home", Stamp: 9}, "plain.test": {Role: "home"}}), |
| } |
| - hosts, tied := foldHomes(files, "carol") |
| + hosts, tied := foldHomes(files, "carol", false) |
| if len(tied) != 1 || tied[0] != "tied.test" { |
| t.Fatalf("tied = %v, want [tied.test]: unstamped duplicates are normal (static sites run on every box) and are not reported", tied) |
| } |
| @@ -1735,7 +1735,7 @@ func TestHomeMapReportsATieBelowTheWinner(t *testing.T) { |
| "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}), |
| "control/edges/carol.json": file("carol", "3.3.3.3", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}), |
| } |
| - hosts, tied := foldHomes(files, "dave") |
| + hosts, tied := foldHomes(files, "dave", false) |
| if len(tied) != 1 || tied[0] != "h.test" { |
| t.Fatalf("tied = %v, want [h.test]", tied) |
| } |
| @@ -1751,7 +1751,7 @@ func TestHomeMapReportsATieWithThisBoxItself(t *testing.T) { |
| "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}), |
| "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}), |
| } |
| - hosts, tied := foldHomes(files, "annie") |
| + hosts, tied := foldHomes(files, "annie", false) |
| if len(tied) != 1 || tied[0] != "h.test" { |
| t.Fatalf("tied = %v, want [h.test]", tied) |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv| index 792db1fc7..3bfa644f1 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -5,10 +5,12 @@ |
| # Task 5: the edges file's headroom carries the box's total memory and data volume size (headroomFor and capacityMiB, internal/host/headroom.go; edgeHeadroom's two additive fields). headroomFor calls the existing readMemBytes and diskBytes and adds no read of its own. Envelope-only: readHeadroom and readMemBytes are unchanged, no root-read unit changed or was added, and the legacy seal stands. |
| # Task 6: hostops.ExecutorChooser applies spec 2's policy in Choose and Permits (creates and relocation targets) and names the placeable set in PlaceableBoxes and Capacity. ExecutorChooser.eligible, the accepted-legacy unit (merge ruling 2), is unchanged: the policy runs over its result. Envelope-only, and the legacy seal stands. |
| # Task 7: the box's own addresses (internal/host boxaddress.go, the provider-neutral source over CARLOS_PRIVATE_IP with CARLOS_PRIVATE_CIDR, and CARLOS_PUBLIC_IP, all from root-owned host.env; awsaddr.go, the IMDS adapter, which reads the VPC's CIDR blocks too) and the edges file's private_ip and private_cidr, validated before they are written by edgesfile.PrivateAddress (census ruling region-ha-2a, condition 1). edgesFileWriteClaims (authority, F,J1) is re-digested with its class, task, basis and sites unchanged: with region HA on it reads the address source before every publication and no longer holds a blank EIP. It adds no registry, /data, release or claim read, and nothing runs as root that did not before; the legacy seal stands. |
| +# Task 8: with the reader's own region HA on, forwarding dials a same-region box's private address when it passes edgesfile.PrivateAddress against the reader's own published network, and every other box at its public address (internal/edge homemap.go: dialAddress, regionHAFromEnv, edgesFileView's private_ip, private_cidr, region and placement). The home map is not a root-read unit. Envelope-only, and the legacy seal stands. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| @coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf |
| @coverage internal/host 75e0a977c0f286966f2fb1ebbca10aa40f3795117958bf82e80cf545d6a776fa |
| @coverage internal/hostops 3dc23aaff7bdae0341a8c9a6fbac076a2f57ca1e4676d83543433ff9ad7d1c79 |
| +@coverage internal/edge 2c474d685471295daf20f061e595becab78175c5754dbef0d2187f0f8519fbe3 |
| internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 root-route;kept=F;scope=protected-decision-only registry:Account@10835,registry:Alias@5297,registry:Alias@5332,registry:Alias@5754,registry:Alias@5911,registry:Alias@5981,registry:Alias@6013,registry:Alias@6482,registry:Alias@8772,registry:App@10847,registry:DNSStatus@5429,registry:DNSStatus@5920,registry:Host@10480,registry:Host@2878,registry:Host@5389,registry:Host@6234,registry:Host@6567,registry:Host@8819,registry:Host@8866,registry:Host@8916,registry:Host@8951,registry:Host@9000,registry:Platform@2921,registry:Provisioned@2935,registry:Replica@2959,registry:Status@877,whole:Route@3212,whole:Route@323,whole:Route@3505,whole:Route@351,whole:RouteAlias@5282,whole:RouteAlias@8725,whole:RoutePath@3520,whole:WildcardDomain@8905 8c7cdffba66886b92f9a2ac663a86dbdd5f80699afd2e6afb4571da916c727ad |