region HA 2a: an unknown metadata read leaves the published edges file standing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
7 files changed,
+271
−46
internal/host/awsaddr.go+39 −10internal/host/boxaddress.go+13 −4internal/host/boxaddress_test.go+181 −19internal/host/edgesclaims.go+4 −2internal/host/edgesfile.go+26 −7internal/provision/plan14_root_reads_test.go+3 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+5 −3
diff --git a/internal/host/awsaddr.go b/internal/host/awsaddr.go| index 812f53759..4a042fe6a 100644 |
| --- a/internal/host/awsaddr.go |
| +++ b/internal/host/awsaddr.go |
| @@ -1,6 +1,7 @@ |
| package host |
| import ( |
| + "net" |
| "net/http" |
| "strings" |
| "time" |
| @@ -27,31 +28,59 @@ func newIMDSAddresses() imdsAddresses { |
| } |
| } |
| -func (s imdsAddresses) BoxAddresses() BoxAddresses { |
| +func (s imdsAddresses) BoxAddresses() (BoxAddresses, bool) { |
| token := s.token() |
| if token == "" { |
| - return BoxAddresses{} |
| + return BoxAddresses{}, false |
| } |
| + // read is a leaf's answer: its value on 200, blank on 404 (IMDS says the |
| + // instance has no such leaf: authoritative), and unknown on anything |
| + // else, a failed or timed-out request included. |
| + unknown := false |
| read := func(path string) string { |
| v, code := s.leaf(token, path) |
| - if code != http.StatusOK { |
| - return "" |
| + switch code { |
| + case http.StatusOK: |
| + return v |
| + case http.StatusNotFound: |
| + default: |
| + unknown = true |
| } |
| - return v |
| + return "" |
| } |
| 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 |
| + if unknown { |
| + return BoxAddresses{}, false |
| + } |
| + if ip == "" || mac == "" { |
| + return out, true |
| + } |
| + if _, err := net.ParseMAC(mac); err != nil { |
| + return out, true |
| } |
| blocks := strings.Fields(read("/latest/meta-data/network/interfaces/macs/" + mac + "/vpc-ipv4-cidr-blocks")) |
| + if unknown { |
| + return BoxAddresses{}, false |
| + } |
| out.Private, out.PrivateCIDR = edgesfile.PrivateAddress(ip, blocks) |
| - return out |
| + return out, true |
| } |
| // boxAddressSource is what the edges file publisher reads with region HA |
| -// on: host.env's settings, then IMDS on EC2. |
| +// on: host.env's settings, then IMDS on EC2. Whether this is an EC2 box is |
| +// decided once, here, when the loop arms (as readPlacementRegion is): a box |
| +// where IMDS does not answer a token request now reads host.env only, and |
| +// makes no metadata request on any later pass. An EC2 box whose IMDS fails |
| +// after this reports unknown on that pass instead. |
| func boxAddressSource(getenv func(string) string) AddressSource { |
| - return envAddresses{getenv: getenv, provider: newIMDSAddresses()} |
| + return boxAddressSourceFrom(getenv, newIMDSAddresses()) |
| +} |
| + |
| +func boxAddressSourceFrom(getenv func(string) string, imds imdsAddresses) AddressSource { |
| + if imds.token() == "" { |
| + return envAddresses{getenv: getenv} |
| + } |
| + return envAddresses{getenv: getenv, provider: imds} |
| } |
diff --git a/internal/host/boxaddress.go b/internal/host/boxaddress.go| index 94ea6d6f4..2e605a5d1 100644 |
| --- a/internal/host/boxaddress.go |
| +++ b/internal/host/boxaddress.go |
| @@ -25,8 +25,14 @@ type BoxAddresses struct { |
| // 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. |
| +// |
| +// ok is false when the source could not tell: a metadata request that |
| +// failed, timed out or answered something other than "here it is" or "there |
| +// is none". Unknown is not blank. A caller that gets !ok writes nothing and |
| +// leaves what it published before standing; only an authoritative answer, |
| +// blank fields included, changes the published address. |
| type AddressSource interface { |
| - BoxAddresses() BoxAddresses |
| + BoxAddresses() (BoxAddresses, bool) |
| } |
| // envAddresses is the provider-neutral source (§25), from host.env: |
| @@ -41,7 +47,7 @@ type envAddresses struct { |
| provider AddressSource |
| } |
| -func (s envAddresses) BoxAddresses() BoxAddresses { |
| +func (s envAddresses) BoxAddresses() (BoxAddresses, bool) { |
| ip := strings.TrimSpace(s.getenv("CARLOS_PRIVATE_IP")) |
| cidr := strings.TrimSpace(s.getenv("CARLOS_PRIVATE_CIDR")) |
| public := strings.TrimSpace(s.getenv("CARLOS_PUBLIC_IP")) |
| @@ -54,7 +60,10 @@ func (s envAddresses) BoxAddresses() BoxAddresses { |
| out.Public = ipv4Only(public) |
| } |
| if (!privateSet || !publicSet) && s.provider != nil { |
| - p := s.provider.BoxAddresses() |
| + p, ok := s.provider.BoxAddresses() |
| + if !ok { |
| + return BoxAddresses{}, false |
| + } |
| if !privateSet { |
| out.Private, out.PrivateCIDR = edgesfile.PrivateAddress(p.Private, []string{p.PrivateCIDR}) |
| } |
| @@ -62,7 +71,7 @@ func (s envAddresses) BoxAddresses() BoxAddresses { |
| out.Public = ipv4Only(p.Public) |
| } |
| } |
| - return out |
| + return out, true |
| } |
| // ipv4Only is s when it is an IPv4 address, in its canonical form, and "" |
diff --git a/internal/host/boxaddress_test.go b/internal/host/boxaddress_test.go| index 8a36599f2..8328e3e0e 100644 |
| --- a/internal/host/boxaddress_test.go |
| +++ b/internal/host/boxaddress_test.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "net/http" |
| "testing" |
| @@ -12,17 +13,31 @@ import ( |
| // staticAddresses is a fixed AddressSource. |
| type staticAddresses BoxAddresses |
| -func (a staticAddresses) BoxAddresses() BoxAddresses { return BoxAddresses(a) } |
| +func (a staticAddresses) BoxAddresses() (BoxAddresses, bool) { return BoxAddresses(a), true } |
| -// countingAddresses counts reads, answering each from its list in turn. |
| +// countingAddresses counts reads, answering each from its list in turn. A |
| +// read whose index is in unknown reports that the source could not tell. |
| type countingAddresses struct { |
| answers []BoxAddresses |
| + unknown map[int]bool |
| reads int |
| } |
| -func (c *countingAddresses) BoxAddresses() BoxAddresses { |
| - a := c.answers[min(c.reads, len(c.answers)-1)] |
| +func (c *countingAddresses) BoxAddresses() (BoxAddresses, bool) { |
| + i := c.reads |
| c.reads++ |
| + if c.unknown[i] { |
| + return BoxAddresses{}, false |
| + } |
| + return c.answers[min(i, len(c.answers)-1)], true |
| +} |
| + |
| +// addrs is the answer of a source that is known to have answered. |
| +func addrs(s AddressSource) BoxAddresses { |
| + a, ok := s.BoxAddresses() |
| + if !ok { |
| + panic("address source did not answer") |
| + } |
| return a |
| } |
| @@ -30,18 +45,18 @@ func envOf(m map[string]string) func(string) string { return func(k string) stri |
| // 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() |
| + got := addrs(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"})}) |
| 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{}) { |
| + if got := addrs((envAddresses{getenv: envOf(nil)})); 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 != "" { |
| + if got := addrs((envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "10.0.1.5"})})); 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() |
| + got = addrs(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"})}) |
| if got != (BoxAddresses{}) { |
| t.Fatalf("invalid settings: %+v", got) |
| } |
| @@ -49,25 +64,25 @@ func TestEnvAddressesWithoutAProvider(t *testing.T) { |
| 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() |
| + got := addrs(envAddresses{getenv: envOf(map[string]string{"CARLOS_PUBLIC_IP": "198.51.100.7"}), provider: provider}) |
| 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() |
| + addrs(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}) |
| 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() |
| + got = addrs(envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_IP": "not-an-ip", "CARLOS_PRIVATE_CIDR": "10.0.0.0/16"}), provider: provider}) |
| 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 != "" { |
| + if got := addrs((envAddresses{getenv: envOf(nil), provider: bad})); got.Private != "" { |
| t.Fatalf("an out-of-network provider address: %+v", got) |
| } |
| } |
| @@ -89,28 +104,30 @@ func TestIMDSAddressesReadsTheLeavesAndTheVPCNetwork(t *testing.T) { |
| } |
| 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"}) { |
| + if got := addrs(s); 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 != "" { |
| + if got := addrs(s); 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 != "" { |
| + if got := addrs(s); got.Private != "" { |
| t.Fatalf("no network: %+v", got) |
| } |
| - // No public address (a box without one, or the EIP gone): blank. |
| + // A 404 on public-ipv4 is IMDS saying this instance holds no public |
| + // address (the EIP gone to the load balancer, A1-12): an authoritative |
| + // blank, published as none. |
| delete(leaves, "/latest/meta-data/public-ipv4") |
| - if got := s.BoxAddresses(); got.Public != "" { |
| + if got := addrs(s); 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) |
| + if got, ok := noToken.BoxAddresses(); ok || got != (BoxAddresses{}) { |
| + t.Fatalf("no token: %+v, ok %v; want unknown", got, ok) |
| } |
| } |
| @@ -177,3 +194,148 @@ func TestEdgesFileWithRegionHAOffKeepsTheEIPHold(t *testing.T) { |
| t.Fatalf("%+v", f) |
| } |
| } |
| + |
| +// Anything but a 200 or a 404 from a leaf, and a failed token, is unknown, |
| +// not blank (the census-fix ruling): a hiccup must not read as "no address". |
| +func TestIMDSAddressesUnknownIsNotBlank(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": "10.0.0.0/16", |
| + } |
| + for _, bad := range []struct { |
| + name, path string |
| + code int |
| + }{ |
| + {"public-ipv4 times out", "/latest/meta-data/public-ipv4", 0}, |
| + {"public-ipv4 5xx", "/latest/meta-data/public-ipv4", http.StatusServiceUnavailable}, |
| + {"local-ipv4 401", "/latest/meta-data/local-ipv4", http.StatusUnauthorized}, |
| + {"mac fails", "/latest/meta-data/mac", 0}, |
| + {"vpc blocks 500", "/latest/meta-data/network/interfaces/macs/0e:12:34:56:78:9a/vpc-ipv4-cidr-blocks", http.StatusInternalServerError}, |
| + } { |
| + t.Run(bad.name, func(t *testing.T) { |
| + s := imdsAddresses{token: func() string { return "tok" }, leaf: func(token, path string) (string, int) { |
| + if path == bad.path { |
| + return "", bad.code |
| + } |
| + return leaves[path], http.StatusOK |
| + }} |
| + if got, ok := s.BoxAddresses(); ok || got != (BoxAddresses{}) { |
| + t.Fatalf("got %+v, ok %v; want unknown", got, ok) |
| + } |
| + }) |
| + } |
| + // A malformed mac never reaches a request path. |
| + s := imdsAddresses{token: func() string { return "tok" }, leaf: func(token, path string) (string, int) { |
| + if path == "/latest/meta-data/mac" { |
| + return "../../x", http.StatusOK |
| + } |
| + return leaves[path], http.StatusOK |
| + }} |
| + if got, ok := s.BoxAddresses(); !ok || got.Private != "" || got.Public != "203.0.113.4" { |
| + t.Fatalf("bad mac: %+v, ok %v", got, ok) |
| + } |
| +} |
| + |
| +// An unknown provider read is unknown to the whole source, unless host.env |
| +// answered every field. |
| +func TestEnvAddressesPassUnknownThrough(t *testing.T) { |
| + provider := &countingAddresses{unknown: map[int]bool{0: true, 1: true}} |
| + if got, ok := (envAddresses{getenv: envOf(map[string]string{"CARLOS_PUBLIC_IP": "198.51.100.7"}), provider: provider}).BoxAddresses(); ok || got != (BoxAddresses{}) { |
| + t.Fatalf("got %+v, ok %v; want unknown", got, ok) |
| + } |
| + full := 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"} |
| + if _, ok := (envAddresses{getenv: envOf(full), provider: provider}).BoxAddresses(); !ok { |
| + t.Fatal("host.env answered every field; the provider was not needed") |
| + } |
| +} |
| + |
| +// CARLOS_PRIVATE_CIDR alone is not an address. |
| +func TestEnvAddressesCIDRWithoutIP(t *testing.T) { |
| + got := addrs(envAddresses{getenv: envOf(map[string]string{"CARLOS_PRIVATE_CIDR": "10.0.0.0/16", "CARLOS_PUBLIC_IP": "198.51.100.7"})}) |
| + if got != (BoxAddresses{Public: "198.51.100.7"}) { |
| + t.Fatalf("got %+v", got) |
| + } |
| +} |
| + |
| +// IMDS is decided once, when the loop arms. A box where it does not answer |
| +// reads host.env only and makes no metadata request on any later pass. |
| +func TestBoxAddressSourceWithNoIMDSAtArmNeverQueriesIt(t *testing.T) { |
| + tokens, leaves := 0, 0 |
| + imds := imdsAddresses{ |
| + token: func() string { tokens++; return "" }, |
| + leaf: func(token, path string) (string, int) { leaves++; return "10.0.1.5", http.StatusOK }, |
| + } |
| + src := boxAddressSourceFrom(envOf(map[string]string{"CARLOS_PUBLIC_IP": "198.51.100.7"}), imds) |
| + for range 3 { |
| + if got := addrs(src); got != (BoxAddresses{Public: "198.51.100.7"}) { |
| + t.Fatalf("got %+v", got) |
| + } |
| + } |
| + if tokens != 1 || leaves != 0 { |
| + t.Fatalf("token requests %d (want the one at arm), leaf requests %d (want 0)", tokens, leaves) |
| + } |
| + // And a box where it does answer asks on every pass. |
| + tokens = 0 |
| + imds.token = func() string { tokens++; return "tok" } |
| + src = boxAddressSourceFrom(envOf(nil), imds) |
| + addrs(src) |
| + addrs(src) |
| + if tokens != 3 { |
| + t.Fatalf("token requests %d, want 1 at arm and 1 per pass", tokens) |
| + } |
| +} |
| + |
| +// An unknown read (a metadata hiccup) writes nothing: the previous file |
| +// stands, the pass reports an error so the loop retries, and a later good |
| +// read publishes again. |
| +func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(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.6", PrivateCIDR: "10.0.0.0/16", Public: "203.0.113.9"}, |
| + }, |
| + unknown: map[int]bool{1: true}, |
| + } |
| + claims.addrs = src |
| + var last []byte |
| + ident := edgesFile{Box: "annie", BoxID: "annie"} |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _, err := s.Get(context.Background(), edgesFileKey("annie")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + lastBefore := string(last) |
| + // A forced timed pass, which would rewrite an unchanged body. |
| + err = edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims) |
| + if !errors.Is(err, errBoxAddressesUnknown) { |
| + t.Fatalf("unknown read: err %v, want errBoxAddressesUnknown", err) |
| + } |
| + after, _, err := s.Get(context.Background(), edgesFileKey("annie")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(after) != string(before) || string(last) != lastBefore { |
| + t.Fatalf("an unknown read changed the published file:\nbefore %s\nafter %s", before, after) |
| + } |
| + if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "10.0.1.5" { |
| + t.Fatalf("published file lost its addresses: %+v", f) |
| + } |
| + // The retry reads again and publishes what it now reports. |
| + if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f := readEdgesFile(t, s, "annie"); f.PrivateIP != "10.0.1.6" { |
| + t.Fatalf("after the retry: %+v", f) |
| + } |
| + if src.reads != 3 { |
| + t.Fatalf("source read %d times, want once per attempt", src.reads) |
| + } |
| +} |
diff --git a/internal/host/edgesclaims.go b/internal/host/edgesclaims.go| index 96749ffdc..9fac525e4 100644 |
| --- a/internal/host/edgesclaims.go |
| +++ b/internal/host/edgesclaims.go |
| @@ -74,13 +74,15 @@ type edgesClaims struct { |
| 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. |
| + // exactly when region HA is on. It starts as host.env alone, so |
| + // building the cache makes no metadata request; the loop sets |
| + // boxAddressSource when it arms. |
| 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, addrs: boxAddressSource(os.Getenv)} |
| + passDeadline: edgesClaimPassDeadline, logf: log.Printf, addrs: envAddresses{getenv: os.Getenv}} |
| } |
| // relocatableRow is spec 1 §2's relocatable host as a registry row shows |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index c188a054a..384ccbe95 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "log" |
| "math/rand/v2" |
| @@ -380,6 +381,10 @@ func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry |
| return edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, nil) |
| } |
| +// errBoxAddressesUnknown fails an edges file pass whose address source could |
| +// not tell what this box holds (region HA on). Nothing has been written. |
| +var errBoxAddressesUnknown = errors.New("edges file: the box's addresses could not be read") |
| + |
| // edgesFileWriteClaims is edgesFileWrite with the claim reader region HA |
| // adds (edgesclaims.go). claims is nil unless CARLOS_REGION_HA is on, and |
| // nil publishes exactly what edgesFileWrite always has. |
| @@ -601,10 +606,17 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re |
| 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() |
| + // source reports now. An authoritative blank publishes no address, |
| + // never the last one: that may be the region's EIP, which the load |
| + // balancer has since taken. An unknown read (a metadata hiccup) |
| + // writes nothing: the previous file stands and the loop retries, |
| + // so one failed request does not hide a healthy box from its |
| + // readers. |
| + read, ok := claims.addrs.BoxAddresses() |
| + if !ok { |
| + return errBoxAddressesUnknown |
| + } |
| + private = read |
| eip = private.Public |
| } else if eip == "" && *last != nil { |
| var prior edgesFile |
| @@ -823,8 +835,15 @@ func edgesFileLoop(reg *registry.Registry) { |
| fpErrs, passErrs := 0, 0 |
| // The EIP cannot change under a running box, so it is read once here and |
| // re-read only on the timer's passes, never on every change pass: off |
| - // EC2 each read can block for the probe's whole timeout. |
| - eip := readPublicIP() |
| + // EC2 each read can block for the probe's whole timeout. With claims |
| + // (region HA on) the address source supplies it afresh on every |
| + // publication and this reading is never used, so it is not taken. |
| + eip := "" |
| + if claims == nil { |
| + eip = readPublicIP() |
| + } else { |
| + claims.addrs = boxAddressSource(os.Getenv) |
| + } |
| for now := range tick.C { |
| fp, err := reg.Fingerprint() |
| if err != nil { |
| @@ -840,7 +859,7 @@ func edgesFileLoop(reg *registry.Registry) { |
| if !state.due(now, fp, err == nil) { |
| continue |
| } |
| - if state.timed() { |
| + if claims == nil && state.timed() { |
| eip = readPublicIP() |
| } |
| ident.Headroom = headroomFor(ident.Capabilities, dataDir, reg) |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 4c5bc4c89..24e963023 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -797,7 +797,9 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - seal = "5d1b9b6addf6c9964ed18b417ee5b3d0d49fd43e1acabb775e054bba4b6e314e" |
| + // Task 7's fix round re-digests edgesFileLoop at its accepted disposition |
| + // (class, task, basis and read sites unchanged), so the seal moves. |
| + seal = "ede89d37d408863fd01a395afe791d587cbafac40e9b5db03d5b7089e6c4787a" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
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 0088c44b0..d6e4160ad 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -4,11 +4,13 @@ |
| # 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. |
| +# 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. Fix round 1: edgesFileLoop (accepted-legacy, unchanged class, task, basis and kinds; digest and legacy fingerprint re-pinned, so the legacy seal moves) arms the address source once and no longer reads the public address per pass when region HA is on; the source reports unknown separately from an authoritative blank, and an unknown read fails the pass without writing. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| @coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf |
| -@coverage internal/host 38011bf89a668507ccd9bfac2430c431dd4b3d9ca6ad4ebfe88efc7be41b453b |
| +@coverage internal/host 3fedcb518fb123d97a8d90775f1303048768c5f32756fab62575a325b8d19602 |
| @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 |
| +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 |
| +@legacy 8cd94c1313fbc3c08923f74a4e9f23528e953ddeb81b77699c195fd0751b2db0 internal/host/edgesfile.go edgesFileLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with the box's seeds fields added to the edges file it publishes (withSeedsFields); a report, not a decision file:Read@2587 02247880844913b821a4cad9d024ee164bd705e3197b8a2fa6b76b02fb6253d4 |
| +internal/host/edgesfile.go edgesFileLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with the box's seeds fields added to the edges file it publishes (withSeedsFields); a report, not a decision file:Read@2587 02247880844913b821a4cad9d024ee164bd705e3197b8a2fa6b76b02fb6253d4 |