carlos / platform Public

feat(host): instance-provisioning reconcile loop (CARLOS_REGION/CARLOS_POOLS)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fd9e85b69ce92a278245372f058d39ed7ef8146f parent 07d431e
2 files changed, +122 −0
  • internal/host/agent.go +62 −0
  • internal/host/host_test.go +60 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 49d4d016d..fa7da235a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -84,6 +84,7 @@ import (
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -147,6 +148,7 @@ func RunAgent(args []string) {
go canaryAdoptLoop(reg)
go statusLoop(reg)
go domainsLoop(reg, peekAcmeCache(args))
+ go instancesLoop(reg)
go metricsLoop()
go configSyncLoop(reg, svc, dd)
go socketAdvisoryLoop(reg, svc)
@@ -422,6 +424,35 @@ func domainsTickConfig(getenv func(string) string, publicIP string) domains.Conf
return cfg
}
+// provisionTickConfig builds one instances reconcile pass's Config from the
+// environment — pure (getenv-injected), mirroring domainsTickConfig's shape.
+// Unlike domainsTickConfig, there is no inert zero-Config case: an unset
+// CARLOS_REGION/CARLOS_POOLS is a LEGAL state, not a "feature not
+// configured" one — see provision.Reconcile's doc comment — a box that
+// hasn't been told its region/pools still serves only ""-scoped records,
+// never someone else's residency promise. So this always returns a
+// populated Config, and CARLOS_DATA_DIR is honored (default "/data")
+// regardless of whether region/pools are set.
+func provisionTickConfig(getenv func(string) string) provision.ReconcileConfig {
+ region := strings.TrimSpace(getenv("CARLOS_REGION"))
+ var pools []string
+ for _, p := range strings.Split(getenv("CARLOS_POOLS"), ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ pools = append(pools, p)
+ }
+ }
+ dd := getenv("CARLOS_DATA_DIR")
+ if dd == "" {
+ dd = "/data"
+ }
+ return provision.ReconcileConfig{
+ Region: region,
+ Pools: pools,
+ DataDir: dd,
+ Now: time.Now,
+ }
+}
+
// resolveHost is the production DNS seam: the default resolver with a hard
// 3s timeout, so one dead nameserver can't stall a whole reconcile pass.
func resolveHost(host string) ([]string, error) {
@@ -516,6 +547,37 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) {
}
}
+// instancesLoop runs the instance-provisioning reconcile pass forever on the
+// same cadence as domainsLoop (5min + jitter, first tick after 1 minute) —
+// turning bucket instance records into registry routes for THIS agent's
+// region/pool scope (provision.Reconcile). Unlike domainsLoop there is NO
+// apps-domain-style inert gate: an unset CARLOS_REGION/CARLOS_POOLS is a
+// legal, still-armed state (see provisionTickConfig and
+// provision.Reconcile's doc comment — a box with no region deliberately
+// still serves ""-scoped records), so this loop arms whenever the deploy
+// store opens, full stop. Missing deployment config disables the loop with
+// one log line, the same "inert until configured" shape adoptLoop and
+// domainsLoop use for their own missing config. Per-pass errors are logged;
+// the loop lives on.
+func instancesLoop(reg *registry.Registry) {
+ ctx := context.Background()
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: instances loop disabled: %v", err)
+ return
+ }
+ cfg := provisionTickConfig(os.Getenv)
+ log.Printf("agent: instances loop armed (5min + jitter), region %q pools %v", cfg.Region, cfg.Pools)
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ if err := provision.Reconcile(ctx, store, reg, cfg); err != nil {
+ log.Printf("agent: instances pass: %v", err)
+ }
+ }
+}
+
// resolveStatusBucket applies the status/deployment bucket defaulting
// documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET
// always wins, and when it's unset the status loop falls back to whatever
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index a94cc4d65..a25c8dfd6 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -354,6 +354,66 @@ func TestDomainsTickConfig(t *testing.T) {
}
}
+// ---- instance provisioning: the reconcile loop's pure config builder ------
+
+// TestProvisionTickConfig pins the CARLOS_REGION/CARLOS_POOLS env contract.
+// Unlike domainsTickConfig, there is no inert zero-Config case: an unset
+// region/pools is a legal, still-armed state — see provision.Reconcile's
+// doc comment (a box with no region still serves ""-scoped records) — so
+// DataDir always defaults to /data regardless of whether region/pools are
+// set.
+func TestProvisionTickConfig(t *testing.T) {
+ env := func(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+ }
+
+ got := provisionTickConfig(env(map[string]string{}))
+ if got.Region != "" {
+ t.Errorf("unset CARLOS_REGION: Region = %q, want empty", got.Region)
+ }
+ if got.Pools != nil {
+ t.Errorf("unset CARLOS_POOLS: Pools = %v, want nil", got.Pools)
+ }
+ if got.DataDir != "/data" {
+ t.Errorf("DataDir = %q, want /data default", got.DataDir)
+ }
+ if got.Now == nil {
+ t.Error("Now seam not populated")
+ }
+
+ got = provisionTickConfig(env(map[string]string{
+ "CARLOS_REGION": "ie",
+ "CARLOS_POOLS": "tito, shared",
+ }))
+ if got.Region != "ie" {
+ t.Errorf("Region = %q, want ie", got.Region)
+ }
+ if !reflect.DeepEqual(got.Pools, []string{"tito", "shared"}) {
+ t.Errorf("Pools = %v, want [tito shared] (whitespace trimmed)", got.Pools)
+ }
+
+ // Whitespace-only and empty entries between commas are dropped, not
+ // preserved as "".
+ got = provisionTickConfig(env(map[string]string{
+ "CARLOS_POOLS": " , tito ,, shared ,",
+ }))
+ if !reflect.DeepEqual(got.Pools, []string{"tito", "shared"}) {
+ t.Errorf("Pools = %v, want empties dropped and whitespace trimmed", got.Pools)
+ }
+
+ got = provisionTickConfig(env(map[string]string{"CARLOS_DATA_DIR": "/custom"}))
+ if got.DataDir != "/custom" {
+ t.Errorf("DataDir = %q, want /custom", got.DataDir)
+ }
+
+ // Region set with no pools: Pools stays nil (the shared "" pool only —
+ // NOT "every pool").
+ got = provisionTickConfig(env(map[string]string{"CARLOS_REGION": "ie"}))
+ if got.Region != "ie" || got.Pools != nil {
+ t.Errorf("Region = %q Pools = %v, want ie / nil", got.Region, got.Pools)
+ }
+}
+
func TestBootstrapIdempotent(t *testing.T) {
root := t.TempDir()
if err := bootstrap(root); err != nil {