package steering // deployment.go — this package's ONE exported converge entry point. // // Everything else here is unexported by design: desiredState is a pure fold // (steering.go) and converge takes a set that only that fold can build // (route53.go), so a caller outside this package cannot assemble the argument // converge needs. ConvergeDeployment is the seam that closes that gap for the // host housekeeper (internal/host/steering_loop.go) — it takes what a BOX has // (registry rows and raw edges-file bodies), folds, and converges, in one // call that cannot be told to converge a set the fold did not produce. // // Note on the client parameter's type. route53API stays unexported even // though this function is exported: every method in it is an exported SDK // method name, so any type implementing them satisfies it from any package — // Go checks method sets, not the interface's own visibility. The host caller // therefore needs nothing more than NewClient (route53.go), whose concrete // *route53.Client it can pass straight in, and no test outside this package // ever needs to fake AWS. Exporting the interface would add surface with no // consumer; the constructor is the seam, and it already exists. import ( "context" "log" "github.com/carlosframework/platform/internal/registry" ) // ConvergeDeployment folds this deployment's routes and edges files into the // desired steering state and converges the zone to it. // // edgesFiles are the RAW bodies of control/edges/.json, one per armed // box, in the caller's listing order (the host loader sorts by key, so the // order — and therefore the fold — is stable tick to tick). // // The two failure classes an edges file can have are handled in DIFFERENT // places on purpose, and the split is the durable half of Task 5's shrink- // guard finding: // // - a file that could not be READ (the listing failed, or a Get failed) is // a VISIBILITY problem: the deployment cannot see all of its edges, so a // fold built from what came back is SHORT, and a short fold deletes the // records of every box that went missing. That case must never reach this // function — the caller fails closed and returns the error instead (see // internal/host/steering_loop.go's loadEdgesFileBodies). // - a file that was read but does not PARSE is a CONTENT problem, and it is // handled HERE by skipping it and saying so. A single box publishing a // corrupt or future-shaped body must not wedge the whole deployment's // steering, and the skip is bounded: the box simply contributes no // records this pass, which is the same as being unarmed. // // The distinction is not cosmetic. Both end in "one fewer edge in the fold", // but only the read failure means the deployment does not KNOW what it has — // and converge's own guards (the empty-set refusal and the proportional // shrink limit) are the last line, not the first. This is the first. func ConvergeDeployment(ctx context.Context, client route53API, zoneID string, routes []registry.Route, edgesFiles [][]byte, appsDomain string) error { edges := make([]edgesFileView, 0, len(edgesFiles)) for i, body := range edgesFiles { f, err := parseEdgesFileView(body) if err != nil { // The bodies arrive without their keys (the caller hands over // []byte by design, so this package never touches a store), so // the position is the handle an operator gets: bodies are passed // in the caller's sorted listing order, making "file 2 of 3" the // 2nd key under control/edges/. The length disambiguates an empty // object from a truncated one. log.Printf("steering: skipping edges file %d of %d (%d bytes): %v — that box contributes no records this pass", i+1, len(edgesFiles), len(body), err) continue } edges = append(edges, f) } return converge(ctx, client, zoneID, desiredState(routes, edges, appsDomain)) }