region HA 2a: publish the box's validated private address and only what its source reports
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
10 files changed,
+418
−5
internal/edgesfile/private.go+34 −0internal/edgesfile/private_test.go+35 −0internal/host/awsaddr.go+57 −0internal/host/boxaddress.go+76 −0internal/host/boxaddress_test.go+179 −0internal/host/edgesclaims.go+6 −1internal/host/edgesclaims_test.go+5 −0internal/host/edgesfile.go+17 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+4 −2region_ha_boundary_test.go+5 −1
diff --git a/internal/edgesfile/private.go b/internal/edgesfile/private.go| new file mode 100644 |
| index 000000000..835f0ec45 |
| --- /dev/null |
| +++ b/internal/edgesfile/private.go |
| @@ -0,0 +1,34 @@ |
| +package edgesfile |
| + |
| +import ( |
| + "net" |
| + "strings" |
| +) |
| + |
| +// PrivateAddress validates a box's private address against its region |
| +// network's CIDR blocks (region HA spec 2 §6, census ruling region-ha-2a): |
| +// a well-formed IPv4 address, in a private range, neither loopback nor |
| +// link-local (169.254.0.0/16, the metadata range, included), and inside |
| +// one of cidrs. It returns the address and the block that holds it, both |
| +// canonical, or "", "". The writer publishes only what it passes, and a |
| +// reader dials only what it passes against its own region's network. |
| +func PrivateAddress(addr string, cidrs []string) (string, string) { |
| + ip := net.ParseIP(strings.TrimSpace(addr)) |
| + if ip == nil || ip.To4() == nil { |
| + return "", "" |
| + } |
| + ip = ip.To4() |
| + if !ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { |
| + return "", "" |
| + } |
| + for _, c := range cidrs { |
| + _, block, err := net.ParseCIDR(strings.TrimSpace(c)) |
| + if err != nil || block.IP.To4() == nil { |
| + continue |
| + } |
| + if block.Contains(ip) { |
| + return ip.String(), block.String() |
| + } |
| + } |
| + return "", "" |
| +} |
diff --git a/internal/edgesfile/private_test.go b/internal/edgesfile/private_test.go| new file mode 100644 |
| index 000000000..8007e512e |
| --- /dev/null |
| +++ b/internal/edgesfile/private_test.go |
| @@ -0,0 +1,35 @@ |
| +package edgesfile |
| + |
| +import "testing" |
| + |
| +// Census ruling region-ha-2a: a private address passes only when it is a |
| +// well-formed private IPv4 address inside the region network's CIDR, and |
| +// never loopback or link-local (the metadata range included). |
| +func TestPrivateAddress(t *testing.T) { |
| + vpc := []string{"10.0.0.0/16"} |
| + cases := []struct { |
| + name, addr string |
| + cidrs []string |
| + want, cidr string |
| + }{ |
| + {"inside the region network", "10.0.1.5", vpc, "10.0.1.5", "10.0.0.0/16"}, |
| + {"inside the second block", "172.31.4.9", []string{"10.0.0.0/16", " 172.31.0.0/16 "}, "172.31.4.9", "172.31.0.0/16"}, |
| + {"malformed", "10.0.1", vpc, "", ""}, |
| + {"a hostname", "box.internal", vpc, "", ""}, |
| + {"the metadata range", "169.254.169.254", []string{"169.254.0.0/16"}, "", ""}, |
| + {"loopback", "127.0.0.1", []string{"127.0.0.0/8"}, "", ""}, |
| + {"outside the region network", "10.1.1.5", vpc, "", ""}, |
| + {"a public address", "203.0.113.4", []string{"203.0.113.0/24"}, "", ""}, |
| + {"IPv6", "fd00::5", []string{"fd00::/8"}, "", ""}, |
| + {"no network to check it against", "10.0.1.5", nil, "", ""}, |
| + {"a malformed network", "10.0.1.5", []string{"10.0.0.0/33"}, "", ""}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + got, cidr := PrivateAddress(c.addr, c.cidrs) |
| + if got != c.want || cidr != c.cidr { |
| + t.Fatalf("PrivateAddress(%q, %v) = %q, %q; want %q, %q", c.addr, c.cidrs, got, cidr, c.want, c.cidr) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/awsaddr.go b/internal/host/awsaddr.go| new file mode 100644 |
| index 000000000..812f53759 |
| --- /dev/null |
| +++ b/internal/host/awsaddr.go |
| @@ -0,0 +1,57 @@ |
| +package host |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| +) |
| + |
| +// imdsAddresses is the AWS adapter of AddressSource (region HA spec 2 |
| +// §25): IMDSv2's local-ipv4, public-ipv4, and the VPC's CIDR blocks for |
| +// the primary interface (network/interfaces/macs/<mac>/vpc-ipv4-cidr-blocks), |
| +// read on every call. IMDS reports only addresses this instance holds, so |
| +// once the region's EIP has moved to the load balancer no box reports it |
| +// (A1-12). A leaf that does not answer 200 is blank. |
| +type imdsAddresses struct { |
| + token func() string |
| + leaf func(token, path string) (string, int) |
| +} |
| + |
| +func newIMDSAddresses() imdsAddresses { |
| + client := &http.Client{Timeout: 2 * time.Second} |
| + return imdsAddresses{ |
| + token: func() string { return imdsToken(client) }, |
| + leaf: func(token, path string) (string, int) { return imdsLeaf(client, token, path) }, |
| + } |
| +} |
| + |
| +func (s imdsAddresses) BoxAddresses() BoxAddresses { |
| + token := s.token() |
| + if token == "" { |
| + return BoxAddresses{} |
| + } |
| + read := func(path string) string { |
| + v, code := s.leaf(token, path) |
| + if code != http.StatusOK { |
| + return "" |
| + } |
| + return v |
| + } |
| + out := BoxAddresses{Public: read("/latest/meta-data/public-ipv4")} |
| + ip := read("/latest/meta-data/local-ipv4") |
| + mac := read("/latest/meta-data/mac") |
| + if ip == "" || mac == "" || strings.ContainsAny(mac, "/\\ ") { |
| + return out |
| + } |
| + blocks := strings.Fields(read("/latest/meta-data/network/interfaces/macs/" + mac + "/vpc-ipv4-cidr-blocks")) |
| + out.Private, out.PrivateCIDR = edgesfile.PrivateAddress(ip, blocks) |
| + return out |
| +} |
| + |
| +// boxAddressSource is what the edges file publisher reads with region HA |
| +// on: host.env's settings, then IMDS on EC2. |
| +func boxAddressSource(getenv func(string) string) AddressSource { |
| + return envAddresses{getenv: getenv, provider: newIMDSAddresses()} |
| +} |
diff --git a/internal/host/boxaddress.go b/internal/host/boxaddress.go| new file mode 100644 |
| index 000000000..94ea6d6f4 |
| --- /dev/null |
| +++ b/internal/host/boxaddress.go |
| @@ -0,0 +1,76 @@ |
| +package host |
| + |
| +import ( |
| + "net" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/edgesfile" |
| +) |
| + |
| +// BoxAddresses are the addresses this box publishes in its edges file for |
| +// other edges to dial (region HA spec 2 §6). Public is for an edge in |
| +// another region. Private, with PrivateCIDR (the region network it lies |
| +// in), is for an edge in the same region. They are routing data only: an |
| +// edge dials them, and nothing grants anything on them. "" is an address |
| +// the source could not report, or one that failed validation, and nothing |
| +// is published for it. |
| +type BoxAddresses struct { |
| + Private string |
| + PrivateCIDR string |
| + Public string |
| +} |
| + |
| +// AddressSource reads this box's addresses afresh on every call. The edges |
| +// file publisher asks it before every publication and publishes only what |
| +// it reports, so an address the box no longer holds is never republished. |
| +// Every source is root-configured: the cloud's metadata service, or |
| +// host.env. None reads a file the agent can write. |
| +type AddressSource interface { |
| + BoxAddresses() BoxAddresses |
| +} |
| + |
| +// envAddresses is the provider-neutral source (§25), from host.env: |
| +// CARLOS_PRIVATE_IP with CARLOS_PRIVATE_CIDR, and CARLOS_PUBLIC_IP. A field |
| +// whose setting is present is the answer for that field, and a value that |
| +// does not validate publishes nothing: an operator's setting is never |
| +// silently replaced. CARLOS_PRIVATE_IP without CARLOS_PRIVATE_CIDR is |
| +// invalid (census ruling region-ha-2a). A field left unset comes from |
| +// provider, the cloud's adapter, when there is one. |
| +type envAddresses struct { |
| + getenv func(string) string |
| + provider AddressSource |
| +} |
| + |
| +func (s envAddresses) BoxAddresses() BoxAddresses { |
| + ip := strings.TrimSpace(s.getenv("CARLOS_PRIVATE_IP")) |
| + cidr := strings.TrimSpace(s.getenv("CARLOS_PRIVATE_CIDR")) |
| + public := strings.TrimSpace(s.getenv("CARLOS_PUBLIC_IP")) |
| + privateSet, publicSet := ip != "" || cidr != "", public != "" |
| + out := BoxAddresses{} |
| + if privateSet { |
| + out.Private, out.PrivateCIDR = edgesfile.PrivateAddress(ip, []string{cidr}) |
| + } |
| + if publicSet { |
| + out.Public = ipv4Only(public) |
| + } |
| + if (!privateSet || !publicSet) && s.provider != nil { |
| + p := s.provider.BoxAddresses() |
| + if !privateSet { |
| + out.Private, out.PrivateCIDR = edgesfile.PrivateAddress(p.Private, []string{p.PrivateCIDR}) |
| + } |
| + if !publicSet { |
| + out.Public = ipv4Only(p.Public) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// ipv4Only is s when it is an IPv4 address, in its canonical form, and "" |
| +// otherwise. |
| +func ipv4Only(s string) string { |
| + ip := net.ParseIP(strings.TrimSpace(s)) |
| + if ip == nil || ip.To4() == nil { |
| + return "" |
| + } |
| + return ip.To4().String() |
| +} |
diff --git a/internal/host/boxaddress_test.go b/internal/host/boxaddress_test.go| new file mode 100644 |
| index 000000000..8a36599f2 |
| --- /dev/null |
| +++ b/internal/host/boxaddress_test.go |
| @@ -0,0 +1,179 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// staticAddresses is a fixed AddressSource. |
| +type staticAddresses BoxAddresses |
| + |
| +func (a staticAddresses) BoxAddresses() BoxAddresses { return BoxAddresses(a) } |
| + |
| +// countingAddresses counts reads, answering each from its list in turn. |
| +type countingAddresses struct { |
| + answers []BoxAddresses |
| + reads int |
| +} |
| + |
| +func (c *countingAddresses) BoxAddresses() BoxAddresses { |
| + a := c.answers[min(c.reads, len(c.answers)-1)] |
| + c.reads++ |
| + return a |
| +} |
| + |
| +func envOf(m map[string]string) func(string) string { return func(k string) string { return m[k] } } |
| + |
| +// The core source works with no provider adapter at all (§25). |
| +func TestEnvAddressesWithoutAProvider(t *testing.T) { |
| + got := envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": " 10.0.1.5 ", "CARLOS_PRIVATE_CIDR": "10.0.0.0/16", "CARLOS_PUBLIC_IP": "198.51.100.7"})}.BoxAddresses() |
| + if got != (BoxAddresses{Private: "10.0.1.5", PrivateCIDR: "10.0.0.0/16", Public: "198.51.100.7"}) { |
| + t.Fatalf("got %+v", got) |
| + } |
| + if got := (envAddresses{getenv: envOf(nil)}).BoxAddresses(); got != (BoxAddresses{}) { |
| + t.Fatalf("nothing set: %+v", got) |
| + } |
| + // CARLOS_PRIVATE_IP needs CARLOS_PRIVATE_CIDR beside it. |
| + if got := (envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "10.0.1.5"})}).BoxAddresses(); got.Private != "" { |
| + t.Fatalf("a private address with no network: %+v", got) |
| + } |
| + got = envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "169.254.169.254", "CARLOS_PRIVATE_CIDR": "169.254.0.0/16", "CARLOS_PUBLIC_IP": "box.example.com"})}.BoxAddresses() |
| + if got != (BoxAddresses{}) { |
| + t.Fatalf("invalid settings: %+v", got) |
| + } |
| +} |
| + |
| +func TestEnvAddressesOverrideTheProviderFieldByField(t *testing.T) { |
| + provider := &countingAddresses{answers: []BoxAddresses{{Private: "10.0.9.9", PrivateCIDR: "10.0.0.0/16", Public: "203.0.113.9"}}} |
| + got := envAddresses{getenv: envOf(map[string]string{"CARLOS_PUBLIC_IP": "198.51.100.7"}), provider: provider}.BoxAddresses() |
| + if got != (BoxAddresses{Private: "10.0.9.9", PrivateCIDR: "10.0.0.0/16", Public: "198.51.100.7"}) { |
| + t.Fatalf("got %+v", got) |
| + } |
| + // Both set: the provider is not asked. |
| + provider.reads = 0 |
| + envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "10.0.1.5", "CARLOS_PRIVATE_CIDR": "10.0.0.0/16", "CARLOS_PUBLIC_IP": "198.51.100.7"}), provider: provider}.BoxAddresses() |
| + if provider.reads != 0 { |
| + t.Fatalf("provider asked %d times with both settings present", provider.reads) |
| + } |
| + // A bad private setting publishes no private address; the provider's |
| + // answer does not replace it. |
| + got = envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "not-an-ip", "CARLOS_PRIVATE_CIDR": "10.0.0.0/16"}), provider: provider}.BoxAddresses() |
| + if got != (BoxAddresses{Public: "203.0.113.9"}) { |
| + t.Fatalf("bad setting: %+v", got) |
| + } |
| + // The provider's answer is validated too. |
| + bad := &countingAddresses{answers: []BoxAddresses{{Private: "10.9.9.9", PrivateCIDR: "10.0.0.0/16"}}} |
| + if got := (envAddresses{getenv: envOf(nil), provider: bad}).BoxAddresses(); got.Private != "" { |
| + t.Fatalf("an out-of-network provider address: %+v", got) |
| + } |
| +} |
| + |
| +func TestIMDSAddressesReadsTheLeavesAndTheVPCNetwork(t *testing.T) { |
| + leaves := map[string]string{ |
| + "/latest/meta-data/local-ipv4": "10.0.1.5", |
| + "/latest/meta-data/public-ipv4": "203.0.113.4", |
| + "/latest/meta-data/mac": "0e:12:34:56:78:9a", |
| + "/latest/meta-data/network/interfaces/macs/0e:12:34:56:78:9a/vpc-ipv4-cidr-blocks": "172.31.0.0/16\n10.0.0.0/16", |
| + } |
| + s := imdsAddresses{token: func() string { return "tok" }, leaf: func(token, path string) (string, int) { |
| + if token != "tok" { |
| + t.Fatalf("token %q", token) |
| + } |
| + v, ok := leaves[path] |
| + if !ok { |
| + return "", http.StatusNotFound |
| + } |
| + return v, http.StatusOK |
| + }} |
| + if got := s.BoxAddresses(); got != (BoxAddresses{Private: "10.0.1.5", PrivateCIDR: "10.0.0.0/16", Public: "203.0.113.4"}) { |
| + t.Fatalf("got %+v", got) |
| + } |
| + // An address outside the VPC's blocks is not published. |
| + leaves["/latest/meta-data/local-ipv4"] = "10.9.1.5" |
| + if got := s.BoxAddresses(); got.Private != "" || got.PrivateCIDR != "" { |
| + t.Fatalf("out of network: %+v", got) |
| + } |
| + // No VPC network answer: no private address. |
| + leaves["/latest/meta-data/local-ipv4"] = "10.0.1.5" |
| + delete(leaves, "/latest/meta-data/network/interfaces/macs/0e:12:34:56:78:9a/vpc-ipv4-cidr-blocks") |
| + if got := s.BoxAddresses(); got.Private != "" { |
| + t.Fatalf("no network: %+v", got) |
| + } |
| + // No public address (a box without one, or the EIP gone): blank. |
| + delete(leaves, "/latest/meta-data/public-ipv4") |
| + if got := s.BoxAddresses(); got.Public != "" { |
| + t.Fatalf("no public leaf: %+v", got) |
| + } |
| + noToken := imdsAddresses{token: func() string { return "" }, leaf: s.leaf} |
| + if got := noToken.BoxAddresses(); got != (BoxAddresses{}) { |
| + t.Fatalf("no token: %+v", got) |
| + } |
| +} |
| + |
| +func readEdgesFile(t *testing.T, s release.Store, box string) edgesFile { |
| + t.Helper() |
| + body, _, err := s.Get(context.Background(), edgesFileKey(box)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var f edgesFile |
| + if err := json.Unmarshal(body, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f |
| +} |
| + |
| +// Region HA spec 2 §6 (A1-12): with region HA on, every publication reads |
| +// the address source afresh, and a blank read publishes no address. The |
| +// last published EIP is never held: it may be the region's, which the |
| +// load balancer has taken. |
| +func TestEdgesFilePublishesOnlyWhatTheSourceReports(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + claims := newEdgesClaims("annie", (&claimsBook{}).read) |
| + src := &countingAddresses{answers: []BoxAddresses{ |
| + {Private: "10.0.1.5", PrivateCIDR: "10.0.0.0/16", Public: "203.0.113.9"}, |
| + {Private: "10.0.1.5", PrivateCIDR: "10.0.0.0/16"}, |
| + }} |
| + claims.addrs = src |
| + var last []byte |
| + ident := edgesFile{Box: "annie", BoxID: "annie"} |
| + // The loop's own reading (the eip argument) is not what is published. |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "198.51.100.1", &last, false, claims); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "10.0.1.5" || f.PrivateCIDR != "10.0.0.0/16" { |
| + t.Fatalf("first publication: %+v", f) |
| + } |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f := readEdgesFile(t, s, "annie"); f.EIP != "" || f.PrivateIP != "10.0.1.5" { |
| + t.Fatalf("after a blank public read: eip %q private %q, want no public address", f.EIP, f.PrivateIP) |
| + } |
| + if src.reads != 2 { |
| + t.Fatalf("source read %d times, want once per publication", src.reads) |
| + } |
| +} |
| + |
| +// With region HA off there is no claims cache, and the publisher is |
| +// main's: no private address, and a blank probe holds the last EIP. |
| +func TestEdgesFileWithRegionHAOffKeepsTheEIPHold(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + ident := edgesFile{Box: "annie"} |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "203.0.113.9", &last, false, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "" || f.PrivateCIDR != "" { |
| + t.Fatalf("%+v", f) |
| + } |
| +} |
diff --git a/internal/host/edgesclaims.go b/internal/host/edgesclaims.go| index 35a38250f..96749ffdc 100644 |
| --- a/internal/host/edgesclaims.go |
| +++ b/internal/host/edgesclaims.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "log" |
| "math" |
| + "os" |
| "sort" |
| "time" |
| @@ -71,11 +72,15 @@ type edgesClaims struct { |
| passDeadline time.Duration |
| logf func(format string, a ...any) |
| failingRun int // consecutive passes with a failed claim read |
| + // addrs is the box's own addresses, read before every publication |
| + // (region HA spec 2 §6). It rides here because this cache exists |
| + // exactly when region HA is on. |
| + addrs AddressSource |
| } |
| func newEdgesClaims(self string, read func(context.Context, string) (hostclaim.Observed, error)) *edgesClaims { |
| return &edgesClaims{self: self, read: read, now: time.Now, entries: map[string]edgesClaimEntry{}, |
| - passDeadline: edgesClaimPassDeadline, logf: log.Printf} |
| + passDeadline: edgesClaimPassDeadline, logf: log.Printf, addrs: boxAddressSource(os.Getenv)} |
| } |
| // relocatableRow is spec 1 §2's relocatable host as a registry row shows |
diff --git a/internal/host/edgesclaims_test.go b/internal/host/edgesclaims_test.go| index 5cdbff15d..bf0a75c87 100644 |
| --- a/internal/host/edgesclaims_test.go |
| +++ b/internal/host/edgesclaims_test.go |
| @@ -50,6 +50,11 @@ func serving(exec string, stamp uint64) *hostclaim.Claim { |
| func publishWithClaims(t *testing.T, reg *registry.Registry, claims *edgesClaims) map[string]edgeHost { |
| t.Helper() |
| + // These tests are about the hosts; the box's addresses come from a |
| + // fixed source, never from IMDS. nil claims is region HA off. |
| + if claims != nil { |
| + claims.addrs = staticAddresses{Public: "1.2.3.4"} |
| + } |
| st := release.NewDirStore(t.TempDir()) |
| var last []byte |
| if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims); err != nil { |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index b0112d9f1..c188a054a 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -205,6 +205,13 @@ type edgesFile struct { |
| Box string `json:"box"` |
| EIP string `json:"eip"` |
| Region string `json:"region,omitempty"` |
| + // PrivateIP is the address an edge in this box's region dials (region |
| + // HA spec 2 §6), and PrivateCIDR the region network that holds it, |
| + // both validated by edgesfile.PrivateAddress before they are written. |
| + // Routing data only. Additive and omitempty: a reader that does not know |
| + // them, or finds them absent, dials EIP, as before. |
| + PrivateIP string `json:"private_ip,omitempty"` |
| + PrivateCIDR string `json:"private_cidr,omitempty"` |
| // Placement is this box's CARLOS placement code — CARLOS_REGION verbatim |
| // ("ie", "uk"), the OTHER region vocabulary (see Region above, issue |
| // #329). Additive and omitempty like Region: a pre-placement body parses |
| @@ -591,7 +598,15 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re |
| hosts[key] = edgeHost{Role: role, Wildcard: true, Alias: alias, Account: acct, App: app, Stamp: stamp} |
| } |
| - if eip == "" && *last != nil { |
| + var private BoxAddresses |
| + if claims != nil && claims.addrs != nil { |
| + // Region HA spec 2 §6 (A1-12): publish only what the box's address |
| + // source reports now. A blank read publishes no address, never the |
| + // last one: that may be the region's EIP, which the load balancer |
| + // has since taken. |
| + private = claims.addrs.BoxAddresses() |
| + eip = private.Public |
| + } else if eip == "" && *last != nil { |
| var prior edgesFile |
| if err := json.Unmarshal(*last, &prior); err == nil { |
| eip = prior.EIP |
| @@ -600,6 +615,7 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re |
| f := ident |
| f.V, f.EIP, f.Hosts, f.Generation = 1, eip, hosts, "" |
| + f.PrivateIP, f.PrivateCIDR = private.Private, private.PrivateCIDR |
| gateCopy := f |
| gateCopy.Headroom = nil // a changing reading is not, by itself, a reason to write |
| gate, err := json.Marshal(gateCopy) |
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 a9f4b4b33..0088c44b0 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -4,9 +4,11 @@ |
| # Task 4: the placement policy (internal/boxchoice policy.go; headroom totals and the edges file's generation carried through Eligible and edgesfile.Snapshot.BoxChoice). Envelope-only: no root-read unit changed or was added, and the legacy seal stands. |
| # 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. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| -@coverage internal/edgesfile da42c799d62e065817b5cce4f9a3ead1308dbd379615a85a7b14533c16d7e804 |
| -@coverage internal/host ac55f3fa73d0e0c6d5979e446b2accdd2863b06a93c54d2006f6b547f840abd0 |
| +@coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf |
| +@coverage internal/host 38011bf89a668507ccd9bfac2430c431dd4b3d9ca6ad4ebfe88efc7be41b453b |
| @coverage internal/hostops 3dc23aaff7bdae0341a8c9a6fbac076a2f57ca1e4676d83543433ff9ad7d1c79 |
| +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 48bad9a5f49c5fca53fcf7bd6a241576fd2cd332974114e463b2cb3832e3598d |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index 6f0ad3804..327ebcef6 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -22,11 +22,15 @@ var regionHACoreFiles = []string{ |
| "internal/lbhealth/lbhealth.go", |
| "internal/hostactors/roles.go", |
| "internal/boxchoice/policy.go", |
| + "internal/host/boxaddress.go", |
| + "internal/edgesfile/private.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| // a reader can see where the provider lives. |
| -var regionHAAdapterFiles = []string{} |
| +var regionHAAdapterFiles = []string{ |
| + "internal/host/awsaddr.go", |
| +} |
| // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| // in them goes through s3compat.Options(), so CARLOS_S3_ENDPOINT applies. |