carlos / platform Public

host: domains reconcile rides the status tick — inert until PLATFORM_APPS_DOMAIN

domainsLoop on the 5-minute cadence (the console promises DNS checks
"every few minutes"; the adopt hour would read as broken), config built
by the pure domainsTickConfig: PLATFORM_APPS_DOMAIN gates everything,
PLATFORM_EDGE_IPS parses comma-separated with the box's IMDSv2 public IP
as fallback, and with no edge IP knowable at all DNS stamping stands
down rather than guess. AcmeDir follows the edge's own -acme-cache flag
and $CARLOS_DATA_DIR/acme default, so cert stamps read the cache the
edge actually mints into.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 32189f0a3a61ffb5c49f4066355d7d053e1ff9f8 parent 5475b52
2 files changed, +217 −0
  • internal/host/agent.go +160 −0
  • internal/host/host_test.go +57 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 16455f514..3a41e9dbb 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -46,6 +46,18 @@
// with a public hostname; recorded here only so the fleet status
// page's entire config surface is documented in one place rather than
// split across this file and the tofu module.
+//
+// Domains config surface (read by domainsLoop / domainsTickConfig):
+//
+// - PLATFORM_APPS_DOMAIN — the deployment's platform apps domain
+// (flagship: oncarlos.com). UNSET = the domains feature is inert: no
+// platform domains minted, no bucket manifests read, no health stamps —
+// zero behavior change, same litestream-convention gate as the loops
+// above.
+// - PLATFORM_EDGE_IPS — comma-separated edge IPs custom-domain DNS checks
+// compare against. Default: this box's public IPv4 from IMDS
+// (best-effort; if neither is knowable, DNS stamping stands down
+// entirely rather than guess "elsewhere" about every host).
package host
import (
@@ -55,6 +67,8 @@ import (
"io"
"log"
"math/rand/v2"
+ "net"
+ "net/http"
"os"
"os/exec"
"path"
@@ -63,6 +77,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/registry"
@@ -119,6 +134,7 @@ func RunAgent(args []string) {
go adoptLoop(reg)
go statusLoop(reg)
+ go domainsLoop(reg, peekAcmeCache(args))
// The same serving loop as `carlos edge`, with the activator injected.
edge.RunWithActivator(args, act)
@@ -181,6 +197,150 @@ func adoptLoop(reg *registry.Registry) {
}
}
+// peekAcmeCache pre-parses the agent args for -acme-cache the same way
+// peekRegistryPath peeks -registry: the edge does the authoritative parse,
+// this just lets the domains loop stamp cert expiry from the SAME cache dir
+// the edge actually mints into when the operator moved it off the default.
+// "" = flag not given; domainsTickConfig then derives the edge's own default
+// ($CARLOS_DATA_DIR/acme).
+func peekAcmeCache(args []string) string {
+ fs := flag.NewFlagSet("agent", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ fs.String("registry", "", "")
+ acme := fs.String("acme-cache", "", "")
+ fs.String("https", "", "")
+ fs.String("http", "", "")
+ fs.String("fallback", "", "")
+ fs.Bool("dev", false, "")
+ _ = fs.Parse(args)
+ return *acme
+}
+
+// domainsTickConfig builds one reconcile pass's Config from the environment
+// — pure (getenv + the already-read public IP injected) so the whole env
+// contract is testable without t.Setenv. PLATFORM_APPS_DOMAIN unset returns
+// the zero Config, on which Reconcile is inert by contract. When no edge IP
+// is knowable at all (no PLATFORM_EDGE_IPS, no IMDS answer), Resolve stays
+// nil so DNS stamping stands down: with nothing to compare a resolution
+// against, "pointing" vs "elsewhere" would be a guess, and unknowns are
+// never guessed.
+func domainsTickConfig(getenv func(string) string, publicIP string) domains.Config {
+ domain := strings.TrimSpace(getenv("PLATFORM_APPS_DOMAIN"))
+ if domain == "" {
+ return domains.Config{}
+ }
+ var ips []string
+ for _, ip := range strings.Split(getenv("PLATFORM_EDGE_IPS"), ",") {
+ if ip = strings.TrimSpace(ip); ip != "" {
+ ips = append(ips, ip)
+ }
+ }
+ if len(ips) == 0 && publicIP != "" {
+ ips = []string{publicIP}
+ }
+ dd := getenv("CARLOS_DATA_DIR")
+ if dd == "" {
+ dd = "/data"
+ }
+ cfg := domains.Config{
+ AppsDomain: domain,
+ EdgeIPs: ips,
+ // The same default the edge's -acme-cache flag carries
+ // (edge.RunWithActivator): $CARLOS_DATA_DIR/acme. domainsLoop
+ // overrides this when the flag was given explicitly.
+ AcmeDir: filepath.Join(dd, "acme"),
+ Now: time.Now,
+ }
+ if len(ips) > 0 {
+ cfg.Resolve = resolveHost
+ }
+ return cfg
+}
+
+// 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) {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ return net.DefaultResolver.LookupHost(ctx, host)
+}
+
+// readPublicIP best-effort reads this box's public IPv4 from EC2 IMDSv2
+// (token PUT, then the public-ipv4 leaf). Any failure — not on EC2, IMDS
+// disabled, no public address — returns "", and the caller treats that as
+// "unknown", never as an error: a box without a discoverable public IP is a
+// valid deployment whose DNS checks simply stand down.
+func readPublicIP() string {
+ client := &http.Client{Timeout: 2 * time.Second}
+ req, err := http.NewRequest(http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
+ if err != nil {
+ return ""
+ }
+ req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60")
+ resp, err := client.Do(req)
+ if err != nil {
+ return ""
+ }
+ token, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return ""
+ }
+ req, err = http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data/public-ipv4", nil)
+ if err != nil {
+ return ""
+ }
+ req.Header.Set("X-aws-ec2-metadata-token", strings.TrimSpace(string(token)))
+ resp, err = client.Do(req)
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return ""
+ }
+ ip := strings.TrimSpace(string(body))
+ if net.ParseIP(ip) == nil {
+ return ""
+ }
+ return ip
+}
+
+// domainsLoop runs the domains reconcile pass forever on the STATUS tick's
+// cadence (5min + jitter), not the adopt loop's hour: the console's
+// custom-domain guidance promises "Checks run every few minutes", and a DNS
+// fix that takes up to an hour to turn green would read as broken. Thin and
+// untested by convention (like adoptLoop/statusLoop) — everything with
+// behavior lives in domainsTickConfig and domains.Reconcile. Missing config
+// disables the loop with one log line, the same inert-until-configured
+// shape as its siblings.
+func domainsLoop(reg *registry.Registry, acmeCacheFlag string) {
+ ctx := context.Background()
+ cfg := domainsTickConfig(os.Getenv, readPublicIP())
+ if cfg.AppsDomain == "" {
+ log.Printf("agent: domains loop disabled: PLATFORM_APPS_DOMAIN is not set")
+ return
+ }
+ if acmeCacheFlag != "" {
+ cfg.AcmeDir = acmeCacheFlag
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: domains loop disabled: %v", err)
+ return
+ }
+ log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v", cfg.AppsDomain, cfg.EdgeIPs)
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ if err := domains.Reconcile(ctx, store, reg, cfg); err != nil {
+ log.Printf("agent: domains 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 31965f120..44b880ede 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -5,10 +5,12 @@ import (
"net"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -293,6 +295,61 @@ func TestWrapListeningProbeNullsUnitBackedSockets(t *testing.T) {
}
}
+// ---- domains: the reconcile loop's pure config builder ----------------------
+
+// TestDomainsTickConfig pins the env contract: PLATFORM_APPS_DOMAIN unset
+// means a zero Config (Reconcile is inert on it — the feature does not
+// exist); set means every seam is populated, with PLATFORM_EDGE_IPS parsed
+// comma-separated and the box's public IP as the fallback. When NO edge IP
+// is known at all, Resolve stays nil: with nothing to compare against,
+// "pointing" vs "elsewhere" would be a guess, and unknowns are never guessed.
+func TestDomainsTickConfig(t *testing.T) {
+ env := func(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+ }
+
+ if got := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) {
+ t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, want zero", got)
+ }
+
+ got := domainsTickConfig(env(map[string]string{
+ "PLATFORM_APPS_DOMAIN": "oncarlos.test",
+ "PLATFORM_EDGE_IPS": "203.0.113.7, 198.51.100.9",
+ "CARLOS_DATA_DIR": "/custom",
+ }), "9.9.9.9")
+ if got.AppsDomain != "oncarlos.test" {
+ t.Errorf("AppsDomain = %q", got.AppsDomain)
+ }
+ if !reflect.DeepEqual(got.EdgeIPs, []string{"203.0.113.7", "198.51.100.9"}) {
+ t.Errorf("EdgeIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", got.EdgeIPs)
+ }
+ if got.AcmeDir != filepath.Join("/custom", "acme") {
+ t.Errorf("AcmeDir = %q, want the edge's cache dir under CARLOS_DATA_DIR", got.AcmeDir)
+ }
+ if got.Resolve == nil || got.Now == nil {
+ t.Error("Resolve/Now seams not populated on a configured deployment")
+ }
+
+ // No explicit edge IPs: the box's own public IP (IMDS) is the default.
+ got = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9")
+ if !reflect.DeepEqual(got.EdgeIPs, []string{"9.9.9.9"}) {
+ t.Errorf("EdgeIPs = %v, want the public-IP fallback", got.EdgeIPs)
+ }
+ if got.AcmeDir != filepath.Join("/data", "acme") {
+ t.Errorf("AcmeDir = %q, want the /data default", got.AcmeDir)
+ }
+
+ // No edge IPs knowable at all: DNS stamping stands down (Resolve nil),
+ // but minting still runs — the domain still works, only health is unknown.
+ got = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "")
+ if got.EdgeIPs != nil || got.Resolve != nil {
+ t.Errorf("no known edge IPs: EdgeIPs = %v Resolve nil? %v — stamping must stand down, not guess", got.EdgeIPs, got.Resolve == nil)
+ }
+ if got.AppsDomain != "oncarlos.test" {
+ t.Error("minting must stay configured even without edge IPs")
+ }
+}
+
func TestBootstrapIdempotent(t *testing.T) {
root := t.TempDir()
if err := bootstrap(root); err != nil {