carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package steering
2
3// route53.go — the converger. This is the ONLY file in the tree that holds
4// write credentials to a production DNS zone, so its whole design is about
5// what it REFUSES to touch. Read the managed-set rule (isManaged) and the
6// health-check ownership rule (ownedHealthChecks) before changing anything
7// here: everything else is bookkeeping around those two boundaries.
8//
9// converge is UNEXPORTED: ConvergeDeployment (deployment.go) is this
10// package's only entry point, so no caller outside it can converge a desired
11// set the fold did not build. It is reached, from Task 6's housekeeper duty
12// (internal/host/steering_loop.go) — but only on a deployment that sets
13// CARLOS_STEERING_ZONE_ID, which no box does today: unset keeps the duty
14// dormant, zero API calls, zero writes.
15
16import (
17 "context"
18 "fmt"
19 "log"
20 "os"
21 "regexp"
22 "sort"
23 "strings"
24 "time"
25
26 "github.com/aws/aws-sdk-go-v2/aws"
27 awsconfig "github.com/aws/aws-sdk-go-v2/config"
28 "github.com/aws/aws-sdk-go-v2/service/route53"
29 r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
30 "github.com/carlosframework/platform/internal/rootaws"
31)
32
33const (
34 // steeringZoneEnv names the deployment's steering hosted zone. Unset =
35 // dormant, which is every box today.
36 steeringZoneEnv = "CARLOS_STEERING_ZONE_ID"
37
38 // steeringTagKey / its value (the zone id) is the ONLY thing that makes
39 // a Route53 health check eligible for deletion by this code. An untagged
40 // check, or one tagged for another deployment's zone, is invisible to
41 // the GC — see ownedHealthChecks.
42 steeringTagKey = "carlos-steering"
43
44 // recordTTL is short on purpose: the health check withdraws a dead
45 // edge's answer in ~90s, and a long TTL would keep resolvers handing it
46 // out for far longer than that.
47 recordTTL = 60
48
49 // health check shape. HTTPS on 443 with SNI: the name in
50 // FullyQualifiedDomainName is what the edge sees in the handshake, so it
51 // must be a name the edge holds a certificate for.
52 healthCheckPort = 443
53 healthCheckPath = "/"
54 healthCheckInterval = 30 // seconds
55 healthCheckFailureThresh = 3 // 3 x 30s ~= the spec's 90s withdrawal
56 healthCheckTagBatch = 10 // ListTagsForResources' own cap
57 healthCheckCallerRefPrefix = "carlos-steering-"
58
59 // changeBatchLimit chunks ChangeResourceRecordSets well under Route53's
60 // own 1000-change cap. Smaller batches mean a failure retries less work
61 // and names a smaller blast radius in the log.
62 changeBatchLimit = 100
63
64 // shrinkFloor is the number of managed records a pass may always remove,
65 // however small the zone. Without it, the proportional guard below would
66 // make a 2-record zone unable to lose a single record. See shrinkLimit.
67 shrinkFloor = 2
68)
69
70// steeringZonePattern is the region-host tofu module's own zone-id shape
71// (infra/modules/region-host/variables.tf: `^Z[A-Z0-9]+$`) — a BARE id, never
72// the "/hostedzone/Z..." ARN-ish form the API returns in some places. A typo
73// that slips this gate would be a write against some other zone, so the gate
74// runs before any client call, not after.
75var steeringZonePattern = regexp.MustCompile(`^Z[A-Z0-9]+$`)
76
77// route53API is the slice of the Route53 client this converger uses — an
78// interface so tests substitute an in-memory fake and never reach AWS
79// (route53_test.go). Same seam as internal/edge/route53dns.go's own
80// route53API.
81type route53API interface {
82 ListResourceRecordSets(ctx context.Context, in *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
83 ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
84 ListHealthChecks(ctx context.Context, in *route53.ListHealthChecksInput, optFns ...func(*route53.Options)) (*route53.ListHealthChecksOutput, error)
85 ListTagsForResources(ctx context.Context, in *route53.ListTagsForResourcesInput, optFns ...func(*route53.Options)) (*route53.ListTagsForResourcesOutput, error)
86 CreateHealthCheck(ctx context.Context, in *route53.CreateHealthCheckInput, optFns ...func(*route53.Options)) (*route53.CreateHealthCheckOutput, error)
87 DeleteHealthCheck(ctx context.Context, in *route53.DeleteHealthCheckInput, optFns ...func(*route53.Options)) (*route53.DeleteHealthCheckOutput, error)
88 ChangeTagsForResource(ctx context.Context, in *route53.ChangeTagsForResourceInput, optFns ...func(*route53.Options)) (*route53.ChangeTagsForResourceOutput, error)
89}
90
91// route53Methods satisfies route53API from method values of one route53 client.
92// Storing the *route53.Client itself in the interface would make the linker keep every exported Route53 method once html/template is linked in (discussion #275); method values keep only the calls made here.
93type route53Methods struct {
94 listResourceRecordSets func(ctx context.Context, in *route53.ListResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
95 changeResourceRecordSets func(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)
96 listHealthChecks func(ctx context.Context, in *route53.ListHealthChecksInput, optFns ...func(*route53.Options)) (*route53.ListHealthChecksOutput, error)
97 listTagsForResources func(ctx context.Context, in *route53.ListTagsForResourcesInput, optFns ...func(*route53.Options)) (*route53.ListTagsForResourcesOutput, error)
98 createHealthCheck func(ctx context.Context, in *route53.CreateHealthCheckInput, optFns ...func(*route53.Options)) (*route53.CreateHealthCheckOutput, error)
99 deleteHealthCheck func(ctx context.Context, in *route53.DeleteHealthCheckInput, optFns ...func(*route53.Options)) (*route53.DeleteHealthCheckOutput, error)
100 changeTagsForResource func(ctx context.Context, in *route53.ChangeTagsForResourceInput, optFns ...func(*route53.Options)) (*route53.ChangeTagsForResourceOutput, error)
101}
102
103func (m route53Methods) ListResourceRecordSets(ctx context.Context, in *route53.ListResourceRecordSetsInput, opts ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {
104 return m.listResourceRecordSets(ctx, in, opts...)
105}
106
107func (m route53Methods) ChangeResourceRecordSets(ctx context.Context, in *route53.ChangeResourceRecordSetsInput, opts ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {
108 return m.changeResourceRecordSets(ctx, in, opts...)
109}
110
111func (m route53Methods) ListHealthChecks(ctx context.Context, in *route53.ListHealthChecksInput, opts ...func(*route53.Options)) (*route53.ListHealthChecksOutput, error) {
112 return m.listHealthChecks(ctx, in, opts...)
113}
114
115func (m route53Methods) ListTagsForResources(ctx context.Context, in *route53.ListTagsForResourcesInput, opts ...func(*route53.Options)) (*route53.ListTagsForResourcesOutput, error) {
116 return m.listTagsForResources(ctx, in, opts...)
117}
118
119func (m route53Methods) CreateHealthCheck(ctx context.Context, in *route53.CreateHealthCheckInput, opts ...func(*route53.Options)) (*route53.CreateHealthCheckOutput, error) {
120 return m.createHealthCheck(ctx, in, opts...)
121}
122
123func (m route53Methods) DeleteHealthCheck(ctx context.Context, in *route53.DeleteHealthCheckInput, opts ...func(*route53.Options)) (*route53.DeleteHealthCheckOutput, error) {
124 return m.deleteHealthCheck(ctx, in, opts...)
125}
126
127func (m route53Methods) ChangeTagsForResource(ctx context.Context, in *route53.ChangeTagsForResourceInput, opts ...func(*route53.Options)) (*route53.ChangeTagsForResourceOutput, error) {
128 return m.changeTagsForResource(ctx, in, opts...)
129}
130
131func newRoute53Methods(c *route53.Client) route53Methods {
132 return route53Methods{
133 listResourceRecordSets: c.ListResourceRecordSets,
134 changeResourceRecordSets: c.ChangeResourceRecordSets,
135 listHealthChecks: c.ListHealthChecks,
136 listTagsForResources: c.ListTagsForResources,
137 createHealthCheck: c.CreateHealthCheck,
138 deleteHealthCheck: c.DeleteHealthCheck,
139 changeTagsForResource: c.ChangeTagsForResource,
140 }
141}
142
143// SteeringZoneID returns the deployment's validated steering hosted-zone id,
144// or "" when the deployment is dormant (env unset, blank, or malformed —
145// malformed is logged, because a typo'd zone id is a configuration error
146// somebody needs to see, not a quiet no-op).
147func SteeringZoneID() string {
148 raw := strings.TrimSpace(os.Getenv(steeringZoneEnv))
149 if raw == "" {
150 return ""
151 }
152 if !steeringZonePattern.MatchString(raw) {
153 log.Printf("steering: %s=%q is not a bare hosted-zone id (want e.g. Z0ABCDEF1234) — staying dormant", steeringZoneEnv, raw)
154 return ""
155 }
156 return raw
157}
158
159// NewClient builds a Route53 client from the default AWS credential chain —
160// the box instance profile in production, exactly like
161// internal/edge/route53dns.go's newRoute53DNS. Route53 is a global service
162// but the SDK still needs a signing region, so default to us-east-1 when the
163// environment names none; an explicit AWS_REGION still wins.
164func NewClient(ctx context.Context) (route53API, error) {
165 var opts []func(*awsconfig.LoadOptions) error
166 if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" {
167 opts = append(opts, awsconfig.WithRegion("us-east-1"))
168 }
169 cfg, err := rootaws.LoadDefaultConfig(ctx, opts...)
170 if err != nil {
171 return nil, fmt.Errorf("steering: load aws config: %w", err)
172 }
173 return newRoute53Methods(route53.NewFromConfig(cfg)), nil
174}
175
176// ownedCheck is one Route53 health check this deployment owns: tagged
177// carlos-steering=<zoneID>, and therefore the only kind this code will ever
178// delete.
179type ownedCheck struct {
180 id string
181 eip string
182}
183
184// converge makes the zone's carlos-managed latency records match want.
185//
186// The order is deliberate and is a safety property, not an implementation
187// detail:
188//
189// 1. read ownership (which health checks are OURS) — a failure here aborts
190// before anything is written, and an empty result means the managed
191// record set is empty, which means NOTHING is deletable;
192// 2. read the zone;
193// 3. drop names Route53 will not let us write (plainConflicts) and decide
194// what the pass would delete — BOTH REFUSALS RUN BEFORE THE FIRST WRITE,
195// so a refused pass is genuinely zero-write, not merely zero-delete;
196// 4. CREATE any missing health check — capacity before records need it;
197// 5. UPSERT wanted records, DELETE managed-but-unwanted ones;
198// 6. GC health checks nothing references any more — after the records that
199// referenced them are gone, never before.
200//
201// A failure at any step returns loudly and leaves the rest of the pass
202// undone; the housekeeper's next tick re-runs the whole thing from a fresh
203// read. Every step is individually idempotent, so a half-applied pass is a
204// state this function converges from, not a state it gets stuck in.
205func converge(ctx context.Context, client route53API, zoneID string, want []desiredRecord) error {
206 if !steeringZonePattern.MatchString(strings.TrimSpace(zoneID)) {
207 return fmt.Errorf("steering: refusing to converge: %q is not a bare hosted-zone id", zoneID)
208 }
209 zoneID = strings.TrimSpace(zoneID)
210
211 // An EMPTY desired set is never an instruction to tear down production
212 // DNS. desiredState only returns nothing when zero edges are armed (or
213 // there are no steered hosts AND no apps domain), and the overwhelmingly
214 // likelier cause of that on a live deployment is a degraded read — a
215 // control-bucket blip, a listing that came back short — not an operator
216 // deciding every edge should stop answering. Deleting the whole managed
217 // set on that signal would turn a transient read failure into a global
218 // outage. So: refuse, loudly, and let the next tick decide again.
219 // A genuine full teardown is an operator action.
220 //
221 // This is the degenerate case of the proportional guard below; both
222 // exist because "delete everything" and "delete most things" are the
223 // same mistake at different sizes.
224 if len(want) == 0 {
225 log.Printf("steering: empty desired set for zone %s — refusing to remove any record (a wholesale teardown is an operator action, not a converge outcome)", zoneID)
226 return nil
227 }
228
229 owned, err := ownedHealthChecks(ctx, client, zoneID)
230 if err != nil {
231 return err
232 }
233 ownedIDs := make(map[string]bool, len(owned))
234 checkByEIP := make(map[string]string, len(owned))
235 for _, c := range owned {
236 ownedIDs[c.id] = true
237 // Deterministic winner when a leak has left two checks on one EIP:
238 // lowest id. The loser is unreferenced after this pass and the GC
239 // below collects it — the leak self-heals.
240 if have, ok := checkByEIP[c.eip]; !ok || c.id < have {
241 checkByEIP[c.eip] = c.id
242 }
243 }
244
245 existing, err := listRecords(ctx, client, zoneID)
246 if err != nil {
247 return err
248 }
249
250 // 3a. names Route53 forbids us from writing at all.
251 conflicted := conflictedNames(want, existing)
252 if len(conflicted) > 0 {
253 want = dropNames(want, conflicted)
254 if len(want) == 0 {
255 log.Printf("steering: every steered name in zone %s collides with an existing record — refusing to change anything", zoneID)
256 return nil
257 }
258 }
259
260 // 3b. the proportional shrink guard.
261 managed, unwanted := partitionManaged(want, existing, ownedIDs, conflicted)
262 if limit := shrinkLimit(managed); len(unwanted) > limit {
263 for _, rr := range unwanted {
264 log.Printf("steering: would delete %s (set %s, value %s)", aws.ToString(rr.Name), aws.ToString(rr.SetIdentifier), firstValue(rr))
265 }
266 log.Printf("steering: refusing to converge zone %s: the pass would delete %d of %d managed records (limit %d) — a shrink that large is far likelier a short read of edges or routes than an intent to withdraw those names, and a genuine teardown that size is an operator action, exactly like the empty-desired-set case; the intended deletions are listed above",
267 zoneID, len(unwanted), managed, limit)
268 return nil
269 }
270
271 // 4. health checks for every wanted EIP.
272 sni := steeringHostname(want)
273 loggedSNIFallback := false
274 for _, eip := range distinctEIPs(want) {
275 if _, ok := checkByEIP[eip]; ok {
276 continue
277 }
278 fqdnFor := sni
279 if fqdnFor == "" {
280 fqdnFor = fallbackHostname(want, eip)
281 // The shared steering hostname is the RIGHT SNI name because
282 // every edge holds a certificate for it — a check on it tests
283 // the path every visitor takes. Falling back to one member's
284 // hostname is a real downgrade: the check now depends on that
285 // one member's name and certificate, so if the member later
286 // moves or its route is removed, the check starts failing for
287 // reasons that have nothing to do with the edge's health.
288 //
289 // Note the interaction worth seeing in a log: if the synthetic
290 // edge.<apps-domain> name was DROPPED for a record conflict just
291 // above, this is where that shows up — a name-level conflict
292 // silently degrading every health check created afterwards.
293 if !loggedSNIFallback {
294 log.Printf("steering: no edge.<apps-domain> record in the desired set — new health checks fall back to a member hostname (%s) for SNI instead of the shared steering hostname; if that name was skipped for a record conflict above, THAT is the cause, and the check now rides one member's name and certificate", fqdnFor)
295 loggedSNIFallback = true
296 }
297 }
298 id, err := createHealthCheck(ctx, client, zoneID, eip, fqdnFor)
299 if err != nil {
300 return err
301 }
302 checkByEIP[eip] = id
303 ownedIDs[id] = true
304 owned = append(owned, ownedCheck{id: id, eip: eip})
305 }
306
307 // 5. records.
308 changes, err := recordChanges(want, existing, checkByEIP, ownedIDs, unwanted)
309 if err != nil {
310 return err
311 }
312 if err := applyChanges(ctx, client, zoneID, changes); err != nil {
313 return err
314 }
315
316 // 6. GC. Runs only once the record batches have landed.
317 return gcHealthChecks(ctx, client, owned, want, existing, changes)
318}
319
320// shrinkLimit is how many managed records one pass may delete, given how many
321// it currently manages: half of them, but never fewer than shrinkFloor.
322//
323// FINDING 1 (review, 2026-08-22). The empty-desired-set refusal above only
324// catches a read that returned NOTHING. A SHORT read — three of five edges'
325// files listed, or a routes query that came back missing a steered host — has
326// a non-empty want and sails straight past it, and the converger would
327// dutifully delete every record belonging to the members that went missing.
328// For a live steered host that is NXDOMAIN until the next tick: not a slow
329// failover, a hard outage, caused by a transient read rather than by anything
330// an operator did.
331//
332// The durable fix is fail-closed reads upstream (Task 6's half: a partial
333// listing must produce an ERROR, not a smaller slice). This is the half that
334// belongs here — a blast-radius cap that holds even if a caller one day hands
335// us a quietly-truncated set. A pass that would remove most of what it manages
336// stops and describes itself instead.
337//
338// The floor keeps small zones workable: a 2-record zone losing one record is
339// ordinary, and a purely proportional rule would refuse it forever. The floor
340// is small enough that the cap still bites at any size worth protecting.
341func shrinkLimit(managed int) int {
342 if half := managed / 2; half > shrinkFloor {
343 return half
344 }
345 return shrinkFloor
346}
347
348// ---------------------------------------------------------------------------
349// ownership
350// ---------------------------------------------------------------------------
351
352// ownedHealthChecks returns the account's health checks that THIS deployment
353// owns, i.e. that carry carlos-steering=<zoneID>.
354//
355// Two narrowings, both in the safe direction:
356//
357// - the shape prefilter (HTTPS on 443 with an IPAddress, and not owned by
358// another AWS service) only ever SHRINKS the set of checks that can be
359// deleted. Every check this code creates has exactly that shape, so a
360// tagged check of some other shape is one we did not create — ignoring it
361// costs a stranded check; deleting it could cost someone else's
362// monitoring.
363// - the tag is then checked for real, per candidate. Shape alone never
364// confers ownership: the tag is the authority.
365//
366// A listing or tag call that errors aborts the whole pass. That matters: if
367// this function returned a partial set, checks it failed to see would look
368// unowned (so records bound to them would look unmanaged — harmless) but,
369// worse, their EIPs would look uncovered and a duplicate check would be
370// created. Failing loudly is the only correct response.
371func ownedHealthChecks(ctx context.Context, client route53API, zoneID string) ([]ownedCheck, error) {
372 type candidate struct{ id, eip string }
373 var candidates []candidate
374
375 var marker *string
376 for {
377 out, err := client.ListHealthChecks(ctx, &route53.ListHealthChecksInput{Marker: marker})
378 if err != nil {
379 return nil, fmt.Errorf("steering: list health checks: %w", err)
380 }
381 for _, hc := range out.HealthChecks {
382 cfg := hc.HealthCheckConfig
383 if cfg == nil || hc.LinkedService != nil {
384 continue
385 }
386 if cfg.Type != r53types.HealthCheckTypeHttps || aws.ToInt32(cfg.Port) != healthCheckPort {
387 continue
388 }
389 eip := aws.ToString(cfg.IPAddress)
390 if eip == "" {
391 continue
392 }
393 candidates = append(candidates, candidate{id: aws.ToString(hc.Id), eip: eip})
394 }
395 if !out.IsTruncated || aws.ToString(out.NextMarker) == "" {
396 break
397 }
398 marker = out.NextMarker
399 }
400 if len(candidates) == 0 {
401 return nil, nil
402 }
403 sort.Slice(candidates, func(i, j int) bool { return candidates[i].id < candidates[j].id })
404
405 eipByID := make(map[string]string, len(candidates))
406 ids := make([]string, 0, len(candidates))
407 for _, c := range candidates {
408 eipByID[c.id] = c.eip
409 ids = append(ids, c.id)
410 }
411
412 var owned []ownedCheck
413 for start := 0; start < len(ids); start += healthCheckTagBatch {
414 end := start + healthCheckTagBatch
415 if end > len(ids) {
416 end = len(ids)
417 }
418 out, err := client.ListTagsForResources(ctx, &route53.ListTagsForResourcesInput{
419 ResourceType: r53types.TagResourceTypeHealthcheck,
420 ResourceIds: ids[start:end],
421 })
422 if err != nil {
423 return nil, fmt.Errorf("steering: list health-check tags: %w", err)
424 }
425 for _, set := range out.ResourceTagSets {
426 id := aws.ToString(set.ResourceId)
427 for _, tg := range set.Tags {
428 if aws.ToString(tg.Key) == steeringTagKey && aws.ToString(tg.Value) == zoneID {
429 owned = append(owned, ownedCheck{id: id, eip: eipByID[id]})
430 break
431 }
432 }
433 }
434 }
435 sort.Slice(owned, func(i, j int) bool { return owned[i].id < owned[j].id })
436 return owned, nil
437}
438
439// createHealthCheck mints one check for eip and tags it for this zone.
440//
441// Create-then-tag is two calls and Route53 offers no atomic form. An
442// UNTAGGED check is the worst outcome available: this code would never reuse
443// it (untagged is not ours) and never GC it (the GC is tag-scoped), so every
444// subsequent pass would mint another one — an unbounded leak at the
445// housekeeper's cadence. So a tagging failure best-effort deletes the check
446// it just made before failing the pass. If that delete ALSO fails, the check
447// is named in the log so an operator can find it.
448func createHealthCheck(ctx context.Context, client route53API, zoneID, eip, fqdn string) (string, error) {
449 // CallerReference must be unique per creation: Route53 remembers it for
450 // deleted checks for a while, so a name derived only from the EIP would
451 // make a delete-then-recreate (edge disarms, then rearms) fail with
452 // HealthCheckAlreadyExists. The timestamp keeps it under the 64-char cap
453 // while staying unique. Ownership is NEVER read from this string — the
454 // tag is the authority — so its only job is uniqueness plus a human hint
455 // in the console.
456 ref := fmt.Sprintf("%s%s-%d", healthCheckCallerRefPrefix, eip, time.Now().UnixNano())
457 out, err := client.CreateHealthCheck(ctx, &route53.CreateHealthCheckInput{
458 CallerReference: aws.String(ref),
459 HealthCheckConfig: &r53types.HealthCheckConfig{
460 Type: r53types.HealthCheckTypeHttps,
461 Port: aws.Int32(healthCheckPort),
462 IPAddress: aws.String(eip),
463 FullyQualifiedDomainName: aws.String(fqdn),
464 ResourcePath: aws.String(healthCheckPath),
465 EnableSNI: aws.Bool(true),
466 RequestInterval: aws.Int32(healthCheckInterval),
467 FailureThreshold: aws.Int32(healthCheckFailureThresh),
468 MeasureLatency: aws.Bool(false),
469 },
470 })
471 if err != nil {
472 return "", fmt.Errorf("steering: create health check for %s: %w", eip, err)
473 }
474 id := ""
475 if out.HealthCheck != nil {
476 id = aws.ToString(out.HealthCheck.Id)
477 }
478 if id == "" {
479 return "", fmt.Errorf("steering: create health check for %s: no id returned", eip)
480 }
481
482 if _, err := client.ChangeTagsForResource(ctx, &route53.ChangeTagsForResourceInput{
483 ResourceType: r53types.TagResourceTypeHealthcheck,
484 ResourceId: aws.String(id),
485 AddTags: []r53types.Tag{{Key: aws.String(steeringTagKey), Value: aws.String(zoneID)}},
486 }); err != nil {
487 if _, delErr := client.DeleteHealthCheck(ctx, &route53.DeleteHealthCheckInput{HealthCheckId: aws.String(id)}); delErr != nil {
488 log.Printf("steering: LEAKED health check %s for %s: tagging failed (%v) and so did the cleanup delete (%v) — delete it by hand", id, eip, err, delErr)
489 }
490 return "", fmt.Errorf("steering: tag health check %s for %s: %w", id, eip, err)
491 }
492 log.Printf("steering: created health check %s for %s (HTTPS:%d, SNI %s), tagged %s=%s", id, eip, healthCheckPort, fqdn, steeringTagKey, zoneID)
493 return id, nil
494}
495
496// gcHealthChecks deletes owned checks that nothing references any more.
497//
498// Two independent gates, both of which must pass:
499//
500// - the check is OURS (it is in owned, which is tag-derived — see
501// ownedHealthChecks). This is the gate that makes it structurally
502// impossible to delete a check belonging to another deployment, another
503// tool, or a person.
504// - no record SURVIVING this pass references it: not one of the records we
505// just wrote, and not one of the zone's records we left alone (which
506// includes every static and foreign record). Route53 would refuse a
507// delete of an in-use check anyway, but failing the whole pass on that
508// is worse than skipping the one check and saying so.
509//
510// A delete that fails is logged, not fatal: the records are already correct,
511// and next tick will try again.
512func gcHealthChecks(ctx context.Context, client route53API, owned []ownedCheck, want []desiredRecord, existing []r53types.ResourceRecordSet, applied []r53types.Change) error {
513 if len(owned) == 0 {
514 return nil
515 }
516
517 wantedEIP := make(map[string]bool, len(want))
518 for _, rec := range want {
519 wantedEIP[rec.EIP] = true
520 }
521
522 // Every check id still referenced once this pass has landed.
523 deleted := make(map[string]bool)
524 for _, c := range applied {
525 if c.Action == r53types.ChangeActionDelete && c.ResourceRecordSet != nil {
526 deleted[recordKey(*c.ResourceRecordSet)] = true
527 }
528 }
529 referenced := make(map[string]bool)
530 for _, rr := range existing {
531 if deleted[recordKey(rr)] {
532 continue
533 }
534 if id := aws.ToString(rr.HealthCheckId); id != "" {
535 referenced[id] = true
536 }
537 }
538 for _, c := range applied {
539 if c.Action == r53types.ChangeActionUpsert && c.ResourceRecordSet != nil {
540 if id := aws.ToString(c.ResourceRecordSet.HealthCheckId); id != "" {
541 referenced[id] = true
542 }
543 }
544 }
545
546 for _, c := range owned {
547 if wantedEIP[c.eip] && referenced[c.id] {
548 continue
549 }
550 if referenced[c.id] {
551 log.Printf("steering: health check %s (%s) is no longer wanted but a surviving record still references it — leaving it in place", c.id, c.eip)
552 continue
553 }
554 if _, err := client.DeleteHealthCheck(ctx, &route53.DeleteHealthCheckInput{HealthCheckId: aws.String(c.id)}); err != nil {
555 log.Printf("steering: could not delete unreferenced health check %s (%s): %v — retrying next pass", c.id, c.eip, err)
556 continue
557 }
558 log.Printf("steering: deleted unreferenced health check %s (%s)", c.id, c.eip)
559 }
560 return nil
561}
562
563// ---------------------------------------------------------------------------
564// records
565// ---------------------------------------------------------------------------
566
567// listRecords pages the whole zone. Every page matters: a converger that
568// stopped early would see records it manages as absent and rewrite them, and
569// would see managed-but-unwanted ones as already gone.
570func listRecords(ctx context.Context, client route53API, zoneID string) ([]r53types.ResourceRecordSet, error) {
571 var out []r53types.ResourceRecordSet
572 in := &route53.ListResourceRecordSetsInput{HostedZoneId: aws.String(zoneID)}
573 for {
574 page, err := client.ListResourceRecordSets(ctx, in)
575 if err != nil {
576 return nil, fmt.Errorf("steering: list record sets in %s: %w", zoneID, err)
577 }
578 out = append(out, page.ResourceRecordSets...)
579 if !page.IsTruncated || aws.ToString(page.NextRecordName) == "" {
580 return out, nil
581 }
582 in = &route53.ListResourceRecordSetsInput{
583 HostedZoneId: aws.String(zoneID),
584 StartRecordName: page.NextRecordName,
585 StartRecordType: page.NextRecordType,
586 StartRecordIdentifier: page.NextRecordIdentifier,
587 }
588 }
589}
590
591// recordKey is a record set's identity: Route53 keys a resource record set on
592// (name, type, set identifier), and so does this converger. Names are
593// lowercased because DNS is case-insensitive and desiredRecord.Name is
594// already lowercase by contract (steering.go's fqdn) — the listing side is
595// the only place a stray case could enter.
596func recordKey(rr r53types.ResourceRecordSet) string {
597 return strings.ToLower(aws.ToString(rr.Name)) + "|" + string(rr.Type) + "|" + aws.ToString(rr.SetIdentifier)
598}
599
600func wantKey(rec desiredRecord) string {
601 return rec.Name + "|" + string(r53types.RRTypeA) + "|" + rec.Box
602}
603
604// isManaged is THE deletion boundary. A record in the steering zone is
605// carlos-managed — and therefore something this code may delete — only if ALL
606// of the following hold:
607//
608// - it is an A record. Nothing else is ever written here.
609// - it carries a SetIdentifier. A record WITHOUT one is a plain,
610// single-answer record: the zone's apex, its www, its MX/NS/SOA, an
611// operator's hand-made host. Those are STATIC / OPERATOR TERRITORY and
612// are never listed as deletable, full stop. This is the rule the brief
613// names and the one that must never be relaxed.
614// - it is a LATENCY record (Region set). A weighted, failover, geo or
615// multivalue record also carries a SetIdentifier and is somebody else's
616// routing policy.
617// - it is not an alias. This converger writes plain A records to EIPs;
618// an alias record under our key was made by something else.
619// - it is bound to a health check WE own (tag-derived, ownedHealthChecks).
620// This is the strongest of the five: every record this code writes is
621// bound to one of our tagged checks, so the binding is a creation
622// signature. It is what makes the answer to "can converge delete a
623// record it did not create?" a structural no rather than a naming
624// convention.
625//
626// The last gate has a cost, taken deliberately: a managed record whose health
627// check was deleted out from under us becomes invisible to this code and will
628// linger. It is logged by name every pass (see recordChanges) so it is loud
629// rather than silent, and a stale record is a far cheaper failure than a
630// wrongly-deleted one.
631func isManaged(rr r53types.ResourceRecordSet, ownedIDs map[string]bool) bool {
632 if rr.Type != r53types.RRTypeA {
633 return false
634 }
635 if aws.ToString(rr.SetIdentifier) == "" {
636 return false
637 }
638 if rr.Region == "" {
639 return false
640 }
641 if rr.AliasTarget != nil {
642 return false
643 }
644 return ownedIDs[aws.ToString(rr.HealthCheckId)]
645}
646
647// desiredRRSet renders one desiredRecord as the record set Route53 should
648// hold. TTL 60, type A, plain value (never an AliasTarget), latency routing
649// keyed on the box label, bound to the box's EIP health check.
650func desiredRRSet(rec desiredRecord, checkID string) r53types.ResourceRecordSet {
651 return r53types.ResourceRecordSet{
652 Name: aws.String(rec.Name),
653 Type: r53types.RRTypeA,
654 SetIdentifier: aws.String(rec.Box),
655 Region: r53types.ResourceRecordSetRegion(rec.Region),
656 TTL: aws.Int64(recordTTL),
657 ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(rec.EIP)}},
658 HealthCheckId: aws.String(checkID),
659 }
660}
661
662// sameRRSet reports whether the zone already holds exactly what we want. It
663// is what makes a steady-state pass write NOTHING: every field this converger
664// sets is compared, and every field it never sets is required to be absent
665// (an unexpected Weight or GeoLocation on our key means somebody changed the
666// routing policy underneath us, and we should rewrite it).
667func sameRRSet(have, want r53types.ResourceRecordSet) bool {
668 if !strings.EqualFold(aws.ToString(have.Name), aws.ToString(want.Name)) {
669 return false
670 }
671 if have.Type != want.Type || aws.ToString(have.SetIdentifier) != aws.ToString(want.SetIdentifier) {
672 return false
673 }
674 if have.Region != want.Region || aws.ToInt64(have.TTL) != aws.ToInt64(want.TTL) {
675 return false
676 }
677 if aws.ToString(have.HealthCheckId) != aws.ToString(want.HealthCheckId) {
678 return false
679 }
680 if have.AliasTarget != nil || have.Weight != nil || have.GeoLocation != nil ||
681 have.GeoProximityLocation != nil || have.CidrRoutingConfig != nil ||
682 have.MultiValueAnswer != nil || have.Failover != "" {
683 return false
684 }
685 if len(have.ResourceRecords) != len(want.ResourceRecords) {
686 return false
687 }
688 for i := range have.ResourceRecords {
689 if aws.ToString(have.ResourceRecords[i].Value) != aws.ToString(want.ResourceRecords[i].Value) {
690 return false
691 }
692 }
693 return true
694}
695
696// conflictedNames returns the wanted names this converger cannot write a
697// latency A record at, lowercased with the trailing dot, because an existing
698// record there makes the write illegal.
699//
700// FINDING 2 (review round 1) + FINDING A (round 2, 2026-08-22). Three record
701// shapes at a wanted name are all rejected by Route53, and change batches are
702// ATOMIC — so the rejection takes the whole batch with it. A single such
703// record would wedge EVERY steering record in the zone, on every tick,
704// forever: the pass would fail, retry identically, and fail again, while
705// unrelated names never got their records. conflictReason enumerates the
706// three; they share one failure mode and therefore one cure.
707//
708// The cure is to skip the conflicting NAME entirely — no upsert, no delete —
709// and say so loudly, naming what is in the way. Never the alternative of
710// deleting the offending record to make room: it is an operator's record
711// (a plain record has no SetIdentifier, which is static/operator territory
712// under the same rule isManaged enforces; a CNAME or a weighted set is
713// somebody's deliberate routing), and taking a name away from its owner
714// because a member typed it into a steering field is not a call this code
715// gets to make.
716//
717// Scope note: this is a NAME-level refusal, and deliberately so — all three
718// shapes make the whole name unwritable. It is not the right shape for a
719// KEY-level conflict such as an existing ALIAS latency record at exactly one
720// (name, SetIdentifier) we want, which Route53 also refuses to convert in
721// place via UPSERT; that one is left open on the report rather than guessed
722// at here, because dropping the whole name for it would refuse to steer names
723// that are in fact steerable.
724func conflictedNames(want []desiredRecord, existing []r53types.ResourceRecordSet) map[string]bool {
725 wantNames := make(map[string]bool, len(want))
726 for _, rec := range want {
727 wantNames[strings.ToLower(rec.Name)] = true
728 }
729 conflicted := map[string]bool{}
730 for _, rr := range existing {
731 name := strings.ToLower(aws.ToString(rr.Name))
732 if !wantNames[name] || conflicted[name] {
733 continue
734 }
735 reason := conflictReason(rr)
736 if reason == "" {
737 continue
738 }
739 conflicted[name] = true
740 log.Printf("steering: SKIPPING %s entirely: %s — steering this name needs that record changed by its owner first; every other steered name in this zone is unaffected",
741 aws.ToString(rr.Name), reason)
742 }
743 return conflicted
744}
745
746// conflictReason names why an existing record makes its name unwritable by
747// this converger, or "" if it does not. Three shapes, all with the SAME
748// failure mode — the write is rejected and takes the whole atomic batch with
749// it — and therefore all with the same cure: skip the name.
750func conflictReason(rr r53types.ResourceRecordSet) string {
751 setID := aws.ToString(rr.SetIdentifier)
752 switch {
753 // A CNAME is EXCLUSIVE in DNS: a name holding one may hold no other
754 // record type at all. A member steering a hostname that already CNAMEs
755 // somewhere in the steering zone is not exotic — it is the ordinary
756 // custom-domain shape the spec itself recommends (CNAME to
757 // edge.<apps-domain>), pointed at a name inside the same zone.
758 case rr.Type == r53types.RRTypeCname:
759 return fmt.Sprintf("the zone already holds a CNAME there (-> %s, TTL %d), and a name holding a CNAME may hold no other record type",
760 firstValue(rr), aws.ToInt64(rr.TTL))
761
762 // A plain (simple) A record and a routing-policy record set cannot share
763 // a name+type.
764 case rr.Type == r53types.RRTypeA && setID == "":
765 return fmt.Sprintf("the zone already holds a plain A record there (value %s, TTL %d), and Route53 forbids a name from holding both a simple record and a routing-policy record set",
766 firstValue(rr), aws.ToInt64(rr.TTL))
767
768 // One routing POLICY per name+type: a weighted, failover, geo, geo-
769 // proximity, CIDR or multivalue A set at this name cannot coexist with a
770 // latency set. Latency records are the one policy we write, and they are
771 // identified by Region being set.
772 case rr.Type == r53types.RRTypeA && setID != "" && rr.Region == "":
773 return fmt.Sprintf("the zone already holds a NON-LATENCY A record set there (set %q, %s), and Route53 allows only one routing policy per name and type",
774 setID, otherPolicyName(rr))
775 }
776 return ""
777}
778
779// otherPolicyName describes which non-latency routing policy a record set
780// uses, for the conflict log — an operator reading it should not have to go
781// look the record up to know what they are being told about.
782func otherPolicyName(rr r53types.ResourceRecordSet) string {
783 switch {
784 case rr.Weight != nil:
785 return "weighted"
786 case rr.Failover != "":
787 return "failover"
788 case rr.GeoLocation != nil:
789 return "geolocation"
790 case rr.GeoProximityLocation != nil:
791 return "geoproximity"
792 case rr.CidrRoutingConfig != nil:
793 return "CIDR"
794 case aws.ToBool(rr.MultiValueAnswer):
795 return "multivalue-answer"
796 }
797 return "a routing policy this converger does not write"
798}
799
800// dropNames removes every desired record whose Name is in the set.
801func dropNames(want []desiredRecord, names map[string]bool) []desiredRecord {
802 out := make([]desiredRecord, 0, len(want))
803 for _, rec := range want {
804 if names[strings.ToLower(rec.Name)] {
805 continue
806 }
807 out = append(out, rec)
808 }
809 return out
810}
811
812// partitionManaged counts the zone's carlos-managed records and collects the
813// ones no desired key wants — the pass's would-be deletions. It is separated
814// from recordChanges so converge can weigh the deletions BEFORE it creates a
815// single health check, which is what makes the shrink guard's refusal
816// genuinely zero-write.
817//
818// Records at a conflicted name are excluded from both halves: the pass has
819// already promised not to touch that name. That exclusion is a STRUCTURAL
820// BACKSTOP and is deliberately untested — every conflict shape
821// (conflictReason) is one Route53 forbids from coexisting with a latency
822// record set at that name, so a MANAGED record at a conflicted name is a
823// state the service cannot produce. It is kept, in the same spirit as
824// dedupeRecords in steering.go, so that a misread listing fails by leaving
825// records alone rather than by deleting our own.
826func partitionManaged(want []desiredRecord, existing []r53types.ResourceRecordSet, ownedIDs, conflicted map[string]bool) (managed int, unwanted []r53types.ResourceRecordSet) {
827 wanted := make(map[string]bool, len(want))
828 for _, rec := range want {
829 wanted[wantKey(rec)] = true
830 }
831 for _, rr := range existing {
832 if conflicted[strings.ToLower(aws.ToString(rr.Name))] {
833 continue
834 }
835 if !isManaged(rr, ownedIDs) {
836 // Say so when it LOOKS like ours but fails the ownership gate —
837 // a latency A record with a SetIdentifier whose health check we
838 // do not own is either an operator's or an orphan of ours whose
839 // check went missing. Either way we do not touch it, and either
840 // way somebody should know it is there.
841 //
842 // NOT when this pass WANTS that key, though: recordChanges is
843 // about to adopt it, so "leaving it alone" would be a lie about
844 // the one record here that does get taken over. (The wanted
845 // check was lost when this loop was split out of recordChanges
846 // in review round 1; round 2 caught the regression.)
847 if rr.Type == r53types.RRTypeA && aws.ToString(rr.SetIdentifier) != "" && rr.Region != "" && rr.AliasTarget == nil &&
848 !wanted[recordKey(rr)] {
849 log.Printf("steering: leaving latency record %s (set %s) alone: health check %q is not one this deployment owns",
850 aws.ToString(rr.Name), aws.ToString(rr.SetIdentifier), aws.ToString(rr.HealthCheckId))
851 }
852 continue
853 }
854 managed++
855 if !wanted[recordKey(rr)] {
856 unwanted = append(unwanted, rr)
857 }
858 }
859 return managed, unwanted
860}
861
862// firstValue renders a record set's first value for a log line.
863func firstValue(rr r53types.ResourceRecordSet) string {
864 if len(rr.ResourceRecords) == 0 {
865 return "?"
866 }
867 return aws.ToString(rr.ResourceRecords[0].Value)
868}
869
870// recordChanges diffs want against the zone. The deletions were decided
871// already, by partitionManaged, and are passed in — this function only turns
872// them into changes.
873//
874// UPSERTs come first in the returned slice and DELETEs last, so that when the
875// changes are chunked, capacity is added before any is taken away.
876//
877// A wanted key that already holds a record we do NOT manage (a foreign
878// latency record at exactly the (name, SetIdentifier) we would create) is
879// ADOPTED by the UPSERT — CREATE would collide with it and there is no third
880// option. It is logged, because taking over a record is the one write here
881// that changes something we did not make. Note the difference from the
882// plain-record conflict above: adoption replaces a record of the SAME routing
883// policy at the SAME key, which Route53 permits and which is the record we
884// would otherwise have created; the plain-record case is a different policy
885// at the same name, which Route53 refuses outright.
886func recordChanges(want []desiredRecord, existing []r53types.ResourceRecordSet, checkByEIP map[string]string, ownedIDs map[string]bool, unwanted []r53types.ResourceRecordSet) ([]r53types.Change, error) {
887 byKey := make(map[string]r53types.ResourceRecordSet, len(existing))
888 for _, rr := range existing {
889 byKey[recordKey(rr)] = rr
890 }
891
892 var upserts []r53types.Change
893 for _, rec := range want {
894 checkID, ok := checkByEIP[rec.EIP]
895 if !ok || checkID == "" {
896 // Unreachable: converge creates a check for every distinct EIP
897 // before calling this. Loud rather than silently minting a
898 // record with no health check, which would never withdraw.
899 return nil, fmt.Errorf("steering: no health check for %s (record %s / %s)", rec.EIP, rec.Name, rec.Box)
900 }
901 desired := desiredRRSet(rec, checkID)
902 have, present := byKey[wantKey(rec)]
903 if present && sameRRSet(have, desired) {
904 continue
905 }
906 if present && !isManaged(have, ownedIDs) {
907 log.Printf("steering: adopting existing record %s (set %s) — it sits at a name/identifier this deployment steers", rec.Name, rec.Box)
908 }
909 upserts = append(upserts, r53types.Change{Action: r53types.ChangeActionUpsert, ResourceRecordSet: &desired})
910 }
911
912 deletes := make([]r53types.Change, 0, len(unwanted))
913 for _, rr := range unwanted {
914 set := rr // DELETE must resubmit the record set verbatim
915 deletes = append(deletes, r53types.Change{Action: r53types.ChangeActionDelete, ResourceRecordSet: &set})
916 }
917
918 return append(upserts, deletes...), nil
919}
920
921// applyChanges submits the changes in chunks.
922//
923// Partial failure is explicit: chunk N lands, chunk N+1 fails, the pass
924// returns the error and stops. That is safe because each chunk is
925// independently valid — an UPSERT is idempotent and a DELETE is of a record
926// nothing wants — so the next housekeeper tick re-reads the zone and issues
927// exactly the changes still outstanding. The one thing that must NOT happen
928// after a failed batch is the health-check GC, and converge returns before
929// reaching it.
930func applyChanges(ctx context.Context, client route53API, zoneID string, changes []r53types.Change) error {
931 if len(changes) == 0 {
932 return nil
933 }
934 for start := 0; start < len(changes); start += changeBatchLimit {
935 end := start + changeBatchLimit
936 if end > len(changes) {
937 end = len(changes)
938 }
939 chunk := changes[start:end]
940 if _, err := client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
941 HostedZoneId: aws.String(zoneID),
942 ChangeBatch: &r53types.ChangeBatch{Changes: chunk},
943 }); err != nil {
944 return fmt.Errorf("steering: change batch %d-%d of %d in %s: %w", start, end, len(changes), zoneID, err)
945 }
946 for _, c := range chunk {
947 log.Printf("steering: %s %s (set %s)", c.Action, aws.ToString(c.ResourceRecordSet.Name), aws.ToString(c.ResourceRecordSet.SetIdentifier))
948 }
949 }
950 return nil
951}
952
953// ---------------------------------------------------------------------------
954// naming
955// ---------------------------------------------------------------------------
956
957// distinctEIPs returns want's EIPs, sorted, so health checks are created in a
958// stable order.
959func distinctEIPs(want []desiredRecord) []string {
960 seen := make(map[string]bool, len(want))
961 out := make([]string, 0, len(want))
962 for _, rec := range want {
963 if rec.EIP == "" || seen[rec.EIP] {
964 continue
965 }
966 seen[rec.EIP] = true
967 out = append(out, rec.EIP)
968 }
969 sort.Strings(out)
970 return out
971}
972
973// steeringHostname finds the synthetic edge.<apps-domain> name in want and
974// returns it dot-trimmed, for use as a health check's
975// FullyQualifiedDomainName — an SNI/Host value, not a Route53 record name, so
976// the trailing dot comes off.
977//
978// It is the right SNI name because every edge holds a certificate covering
979// it, so the check exercises the same TLS path a real visitor takes rather
980// than one host's certificate in particular.
981//
982// A member route literally NAMED edge.<apps-domain> produces the identical
983// desired records, and desiredState dedupes on (Name, Box) — so this
984// converger only ever sees one such name and needs no special case: whether
985// it came from the synthetic mint or from a real route, it is the same record
986// and the same SNI name.
987//
988// The prefix-only match here relies on steeredHosts' in-zone check
989// (steering.go): every per-host record in want already lies under
990// appsDomain, so an "edge."-prefixed Name reaching this function can only
991// ever be the one true edge.<apps-domain> hostname, never some other
992// zone's "edge.something-else.com" riding along by coincidence of prefix.
993// That in-zone filter is what makes this match order-independent; it was
994// added for a different reason (a steered host outside the zone wedges
995// Route53's whole batch) but closes this hostname's own would-be
996// ambiguity as a side effect.
997func steeringHostname(want []desiredRecord) string {
998 for _, rec := range want {
999 if strings.HasPrefix(rec.Name, "edge.") {
1000 return strings.TrimSuffix(rec.Name, ".")
1001 }
1002 }
1003 return ""
1004}
1005
1006// fallbackHostname picks an SNI name for eip when want carries no
1007// edge.<apps-domain> record (no apps domain configured — steering.go logs
1008// that case): the first wanted record's own name for that EIP. want's order
1009// is deterministic (desiredState sorts and dedupes), so the choice is stable
1010// across passes.
1011func fallbackHostname(want []desiredRecord, eip string) string {
1012 for _, rec := range want {
1013 if rec.EIP == eip {
1014 return strings.TrimSuffix(rec.Name, ".")
1015 }
1016 }
1017 return ""
1018}
1019