package provision import ( "context" "errors" "fmt" "log" "regexp" "strings" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) // importShape is the legacy row's shape check (spec §5.4, codex 10, R1-10): // "" when the row is an addr-backed instance or service this import may // take over, else what makes it not one (the column's name). // // Addr-backed means the edge serves the row from its addr or addrs and // from nothing else. The edge's dispatch (edge.Reload) takes a tunnel, // then a fleet placement, then a socket, before it reaches the addrs, and // an instance flagged hibernate goes through the activator, which starts // a process of its own. So each of those is refused here, beside the // columns shapeMismatch refuses adoption over and a claim's ownership, // which the import journal cannot put back (registry.SwapForImport). func importShape(rt registry.Route) string { switch { case rt.Kind != "instance" && rt.Kind != "service": return "kind " + rt.Kind case rt.Socket != "": return "socket" case rt.Addr == "" && rt.Addrs == "": return "no address" case rt.Provisioned: return "provisioned" case rt.Backing != "": return "backing" case rt.Tunnel != "": return "tunnel" case rt.Caps != "": return "caps" case rt.Platform: return "platform" case rt.Canary: return "canary" case rt.FleetBox != "": return "fleet_box" case rt.Kind == "instance" && rt.Hibernate: return "hibernate" case rt.ClaimOwnership != (registry.ClaimOwnership{}): return "claim_owner" } if _, fleet := registry.FleetPlacement(rt.Region); fleet { return "region" } return "" } // importAddr is the address the row serves, as the edge reads it // (registry.Route.AddrList): addrs when set, else addr. A row whose addr // is Replaces but whose addrs names something else serves the addrs, so // it is not the route the import recorded. func importAddr(rt registry.Route) string { return strings.Join(rt.AddrList(), ",") } // importRefusal is §5.4's predicates in order: "" to swap, else the // refusal reason the record keeps. owners is consulted only for a row // filed under the operator account: release.RouteAccount folds a legacy // row's "" (and any account that is not a sqid) into release.OpsAccount, // so such a row is the operator's too (preflight M5). func importRefusal(rec Record, existing *registry.Route, cfg ReconcileConfig, owners *seeds.LegacyOwners, nested bool) string { switch { case cfg.ClaimEnabled: return "claim control is on for this box" case !nested: return "this box does not run instances nested" case existing == nil: return fmt.Sprintf("there is no route at %s to replace", rec.Host) } if what := importShape(*existing); what != "" { return fmt.Sprintf("the route is not an address route this import can take over (%s)", what) } if addr := importAddr(*existing); addr != rec.Seed.Replaces { return fmt.Sprintf("the route's address is %s, not %s", addr, rec.Seed.Replaces) } if existing.App != rec.App { return fmt.Sprintf("the route belongs to app %s", existing.App) } switch { case release.RouteAccount(*existing) == rec.Account: return "" case operatorFiled(rec, existing): if o, ok := owners.Lookup(existing.App); ok && o.Account == rec.Account && o.App == rec.App { return "" } return fmt.Sprintf("the route is filed under the operator account, and no assignment maps %s to this app", existing.App) } return "the route is filed under another account" } // operatorFiled reports a row the swap may take only through the operator // assignment: filed under the operator account, for a record that is not // the operator's own. importRefusal's account rule and swapImport's // decision to read the assignment both ask this one question. func operatorFiled(rec Record, rt *registry.Route) bool { if rt == nil { return false } acct := release.RouteAccount(*rt) return acct != rec.Account && acct == release.OpsAccount } var verifyHashShape = regexp.MustCompile(`^[0-9a-f]{64}$`) // importUngateable is why the swap must not run for this record at all // (preflight M3), or "": the gate needs a hash (an empty one would swap in // an ungated row), the host and seed id name the snapshot and must pass // seeds.Check, and the new row must be one the activator manages, nested // and hibernating, since only that row places the snapshot before it // serves. These are not refusals: the record stays ready, logged, until // it is aborted. func importUngateable(rec Record, next registry.Route, dataDir string) string { if err := seeds.Check(rec.Host, rec.Seed.ID); err != nil { return fmt.Sprintf("its host or seed id is malformed (%v)", err) } switch { case !verifyHashShape.MatchString(rec.Seed.VerifyHash): return "its verify hash is not a sha256" case !rec.DerivesPaths() || !next.Hibernate || !NestedPaths(dataDir, next): return "its record does not describe a nested, hibernating instance" } return "" } // importRow is the exec row the swap installs (spec §5.4): the record's // route at the nested paths, explicitly, never NestedPaths(existing), // which reads the addr row as flat (codex 12); no seed command; filed // under the record's account; gated by the verify hash. Its seed column // already says swapped, the state the record's CAS moves it to. policy is // the app's update policy, as for any mint (RouteFor): the swap's row is // born with the record's effective update settings. func importRow(rec Record, dataDir string, policy *UpdatePolicy) registry.Route { swapped := *rec.Seed swapped.State = seeds.StateSwapped r := rec r.Seed = &swapped next := RouteFor(r, dataDir, "", true, policy) next.ImportGate = rec.Seed.VerifyHash return next } // swapImport is the swap (spec §5.4): the journal first, then the // predicates, then one registry transaction, then the record's CAS to // swapped. Transient failures defer to the next pass and write nothing; a // failed predicate is a durable refusal. policy is the app's update policy, // known (Reconcile defers the swap, as it defers a mint, while it cannot be // read). func swapImport(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig, rec Record, existing *registry.Route, nested func() (bool, error), policy *UpdatePolicy) { if cfg.DryRun { log.Printf("provision: dry-run: would consider swapping %s for import %s", rec.Host, rec.Seed.ID) return } // The journal comes first (codex R2-3): a row there means this box has // already swapped, so the legacy-row predicates never judge the row it // made. This seed's own row at swapped is a crash between the swap's // transaction and the record's CAS: only the CAS is redone. // // Reconcile's journalPass decides every journaled host before the swap // is reached, so in a pass this check finds nothing. It is kept as a // backstop: without it, a journal row this function did not expect // (another caller, or a row written after the pass read the journal) // would be judged as a legacy row and refused, terminally. j, err := reg.ImportJournal(rec.Host) if err != nil { log.Printf("provision: %s: import deferred: the import journal could not be read: %v", rec.Host, err) return } if j != nil { if j.SeedID != rec.Seed.ID || j.State != registry.JournalSwapped || j.RecordKey != RecordKey(rec.Account, rec.App, rec.Host) { log.Printf("provision: %s: import %s left alone: the import journal holds seed %s at %s", rec.Host, rec.Seed.ID, j.SeedID, j.State) return } markSwapped(ctx, s, rec) return } next := importRow(rec, cfg.DataDir, policy) if why := importUngateable(rec, next, cfg.DataDir); why != "" { log.Printf("provision: %s: not swapping for import %s: %s", rec.Host, rec.Seed.ID, why) return } isNested := false if !cfg.ClaimEnabled { ok, err := nested() if err != nil { log.Printf("provision: %s: import deferred: the box's layout intent could not be read: %v", rec.Host, err) return } // Only the intent refuses (SD-N97). The live probe can fail for a // moment, and a refusal is terminal: an error waits for the next // pass (preflight M4). if ok && cfg.Seeds.NestedOK != nil { if err := cfg.Seeds.NestedOK(); err != nil { log.Printf("provision: %s: import deferred: the box's nested probe did not answer: %v", rec.Host, err) return } } isNested = ok } if existing != nil && existing.HoldOp != "" { log.Printf("provision: %s: import deferred: the route is held by operation %s", rec.Host, existing.HoldOp) return } // The assignment is read only when it can decide: claims off (SD-N98 // comes first) and the box nested (SD-N97 next). var owners *seeds.LegacyOwners if !cfg.ClaimEnabled && isNested && operatorFiled(rec, existing) { if cfg.Seeds.LegacyOwners == nil { log.Printf("provision: %s: import deferred: this box cannot read the legacy route assignments", rec.Host) return } o, err := cfg.Seeds.LegacyOwners(ctx) if err != nil { log.Printf("provision: %s: import deferred: the legacy route assignments could not be read: %v", rec.Host, err) return } owners = o } if reason := importRefusal(rec, existing, cfg, owners, isNested); reason != "" { if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRefuse(rec.Seed.ID, reason)); err != nil { log.Printf("provision: %s: record the import's refusal (%s): %v", rec.Host, reason, err) return } log.Printf("provision: %s: import %s refused: %s", rec.Host, rec.Seed.ID, reason) return } if cfg.ConfigGate != nil { if err := cfg.ConfigGate(ctx, next); err != nil { return // the gate logs its own refusal; retried every pass } } // What the host's own env file holds before the import's row exists, // for the journal: the rollback retires only the instance key the // import's launches created (merge review S1). Read last, so nothing // can launch the import between this read and the transaction. if cfg.Seeds.EnvBefore == nil { log.Printf("provision: %s: import deferred: this box cannot read the instance's env file", rec.Host) return } envBefore, err := cfg.Seeds.EnvBefore(next.DBPath, rec.Host) if err != nil { log.Printf("provision: %s: import deferred: the instance's env file could not be read: %v", rec.Host, err) return } now := time.Now if cfg.Now != nil { now = cfg.Now } journal := registry.ImportJournal{Host: rec.Host, SeedID: rec.Seed.ID, Original: *existing, RecordKey: RecordKey(rec.Account, rec.App, rec.Host), At: now().Unix(), EnvBefore: envBefore} if err := reg.SwapForImport(journal, next); err != nil { log.Printf("provision: %s: import swap deferred: %v", rec.Host, err) return } log.Printf("provision: %s: swapped for import %s (the original route is journaled)", rec.Host, rec.Seed.ID) markSwapped(ctx, s, rec) } // markSwapped is the swap's record CAS, ready to swapped. It runs only // after the registry transaction committed; a failure leaves the journal // at swapped and the record at ready, which the next pass resumes. func markSwapped(ctx context.Context, s release.Store, rec Record) { if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedSwapped(rec.Seed.ID)); err != nil { if errors.Is(err, ErrSeedConflict) { log.Printf("provision: %s: import %s swapped, but the record moved on: %v", rec.Host, rec.Seed.ID, err) return } log.Printf("provision: %s: import %s swapped, record CAS deferred to the next pass: %v", rec.Host, rec.Seed.ID, err) } }