carlos / platform Public

appconfig: per-route config acknowledgement naming rendered relay identities (spec §5)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 46f7c832c0526ffab83d51a0b475c7d5311a86fd parent 3db9761
14 files changed, +813 −119
  • apimode.go +11 −0
  • internal/appconfig/configack.go +173 −0
  • internal/appconfig/configack_test.go +180 −0
  • internal/appconfig/sync.go +49 −8
  • internal/appconfig/sync_test.go +28 −28
  • internal/appconfig/syncexec.go +26 −3
  • internal/appconfig/syncexec_test.go +42 −42
  • internal/console/apirelay.go +87 −25
  • internal/console/apirelay_test.go +97 −2
  • internal/console/relayops.go +36 −0
  • internal/host/agent.go +23 −2
  • internal/relay/config_appconfig_test.go +34 −0
  • internal/relay/config_test.go +7 −5
  • relay_test.go +20 −4
diff --git a/apimode.go b/apimode.go
index 8e4c850df..8f1a667ab 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1944,6 +1944,17 @@ type apiRelayRoute struct {
Delivery string `json:"delivery"`
AckedAt string `json:"acked_at,omitempty"`
Cutover bool `json:"cutover"`
+ // ConfigAckAt and ConfigAckShape echo config sync's own per-route
+ // acknowledgement (spec §5, internal/appconfig/configack.go) when one
+ // names the identities currently issued — distinct from AckedAt/Cutover
+ // above, which are a MEMBER's own `carlos relay adopted`. Neither this
+ // CLI's printRelayStatus nor anything else here reads these fields
+ // today: Delivery already carries the full sentence either state earns
+ // (see internal/console/apirelay.go's relayMatchingAck); they exist so
+ // a future consumer of the wire reply is not left inventing the ack's
+ // own timestamp out of Delivery's prose.
+ ConfigAckAt string `json:"config_acked_at,omitempty"`
+ ConfigAckShape string `json:"config_ack_shape,omitempty"`
}
type apiRelayRegion struct {
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go
new file mode 100644
index 000000000..eb094d0af
--- /dev/null
+++ b/internal/appconfig/configack.go
@@ -0,0 +1,173 @@
+// Config adoption, observed per route (relay spec §5). Config sync writes
+// an additive acknowledgement after it converges a route: the shape it
+// delivered into, whether a restart happened and succeeded, and — for a
+// route whose rendered CARLOS_RELAY carries relay config — the identities
+// it just wrote. Nothing reads the ack today but `carlos relay status`
+// (internal/console/apirelay.go); it is the first per-route statement of
+// "this is what the process has" the platform keeps.
+//
+// The ack names IDENTITIES, not kids: RelaySuffixes carries each region's
+// full username_suffix (account:app:incarnation:kid), parsed from the
+// rendered CARLOS_RELAY value with relay.ParseConfig. A kid alone would not
+// do — a disable and re-enable between two host syncs changes the
+// incarnation and the key while keeping the kid, and an ack naming only
+// kids would report the old credentials as adopted.
+//
+// internal/appconfig importing internal/relay is one-directional and safe:
+// internal/relay does not import internal/appconfig.
+package appconfig
+
+import (
+ "context"
+ "crypto/ecdh"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/relay"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ConfigAck is one route's config-adoption acknowledgement, written after
+// config sync converges its env/config file — see ConfigAckKey for where.
+type ConfigAck struct {
+ Box string `json:"box"`
+ Host string `json:"host"`
+ // Shape is one of "unit", "socket-exec" or "tcp-exec-file" — spec §5's
+ // three delivery shapes.
+ Shape string `json:"shape"`
+ At time.Time `json:"at"`
+ // Restarted and RestartError describe the restart/bounce this sync pass
+ // attempted for the route, if it could attempt one at all: a unit route
+ // that try-restarted cleanly sets Restarted true; one whose try-restart
+ // failed sets RestartError instead. syncExecHost (the exec-file and
+ // socket-exec shapes) always leaves both zero — its bounce is
+ // best-effort and fire-and-forget (bounceLogged swallows the error),
+ // and a TCP-exec-file route's process belongs to an external supervisor
+ // this platform cannot restart at all; its cutover is a member
+ // acknowledgement instead (carlos relay adopted).
+ Restarted bool `json:"restarted"`
+ RestartError string `json:"restart_error,omitempty"`
+ // Hibernating is set when the route was asleep at sync time, so its
+ // restart (a systemd try-restart on an inactive unit) was a no-op by
+ // construction — the config landed but nothing woke to run it. `carlos
+ // relay status` reports such a route as "hibernating, delivered on
+ // wake" rather than claiming an adoption that has not happened yet.
+ Hibernating bool `json:"hibernating,omitempty"`
+ // RelaySuffixes is the username_suffix (account:app:incarnation:kid)
+ // this pass rendered into CARLOS_RELAY, per region — nil if the route's
+ // manifest carries no CARLOS_RELAY entry at all.
+ RelaySuffixes map[string]string `json:"relay_suffixes,omitempty"`
+}
+
+// ConfigAckKey is where a route's config-adoption ack lives: additive, one
+// object per (box, route host), rewritten (never appended to) on every sync
+// that changes what was delivered.
+func ConfigAckKey(box, host string) string {
+ return "control/config-ack/" + box + "/" + host + ".json"
+}
+
+// WriteConfigAck writes ack, get-then-put with the CAS etag of whatever is
+// there now — "" (create) only when nothing is. An ack is rewritten on
+// every sync that changes delivery, unlike release.Store.Put's create-only
+// contract, so this can't just Put with "": the object almost always
+// already exists. Retries once on release.ErrCASConflict, the same shape as
+// relay.WriteHeartbeat (internal/relay/authority.go) — a conflict here can
+// only be this route's own previous ack, since ConfigAckKey is unique per
+// (box, host) and nothing else writes it.
+func WriteConfigAck(ctx context.Context, s release.Store, ack ConfigAck) error {
+ body, err := json.Marshal(ack)
+ if err != nil {
+ return err
+ }
+ key := ConfigAckKey(ack.Box, ack.Host)
+ var lastErr error
+ for attempt := 0; attempt < 2; attempt++ {
+ etag := ""
+ _, curEtag, err := s.Get(ctx, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ // no existing record: create-only Put below.
+ case err != nil:
+ return err
+ default:
+ etag = curEtag
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ lastErr = err
+ if errors.Is(err, release.ErrCASConflict) {
+ continue
+ }
+ return err
+ }
+ return nil
+ }
+ return lastErr
+}
+
+// carlosRelayVar is the well-known var name relay enable's config write
+// (spec §5 step 5) uses — appconfig.Apply, same as any other app var.
+const carlosRelayVar = "CARLOS_RELAY"
+
+// relaySuffixesFromRelayVar decrypts m's CARLOS_RELAY var, if it has one,
+// and parses it with relay.ParseConfig into a region -> username_suffix
+// map. Reads the decrypted manifest var directly — the same "env" vs
+// "secret" decoding RenderEnv applies to every var — never the rendered
+// EnvironmentFile bytes: CARLOS_RELAY is exactly as likely to be a sealed
+// secret entry as any other var, and re-parsing rendered KEY=value lines
+// would just repeat, worse, the decryption RenderEnv already performs.
+//
+// Returns nil, nil when the manifest carries no CARLOS_RELAY entry at all
+// — an ordinary route the relay was never enabled for, not an error.
+func relaySuffixesFromRelayVar(m *Manifest, priv *ecdh.PrivateKey) (map[string]string, error) {
+ v, ok := m.Vars[carlosRelayVar]
+ if !ok {
+ return nil, nil
+ }
+ var val string
+ switch v.Kind {
+ case "env":
+ val = v.Value
+ case "secret":
+ if priv == nil {
+ return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", carlosRelayVar)
+ }
+ plain, err := Open(priv, v.Envelope)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt %s: %w", carlosRelayVar, err)
+ }
+ val = string(plain)
+ default:
+ return nil, fmt.Errorf("var %s: unknown kind %q", carlosRelayVar, v.Kind)
+ }
+ entries, err := relay.ParseConfig(val)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", carlosRelayVar, err)
+ }
+ suffixes := make(map[string]string, len(entries))
+ for _, e := range entries {
+ suffixes[e.Region] = e.UsernameSuffix
+ }
+ return suffixes, nil
+}
+
+// shapeOf names which of spec §5's three delivery shapes rt is: "unit" for
+// a carlos-app@<host> unit tenant, "socket-exec" for an activator-spawned
+// route the platform bounces over a unix socket, "tcp-exec-file" for a
+// TCP-address route belonging to a process this platform does not own (the
+// config lands as a file; adoption is a member acknowledgement instead of
+// a restart). Shared by syncHost and syncExecHost so the two never name a
+// shape differently for the same route.
+func shapeOf(rt registry.Route) string {
+ switch {
+ case rt.Backing == registry.BackingUnit:
+ return "unit"
+ case rt.Socket != "":
+ return "socket-exec"
+ default:
+ return "tcp-exec-file"
+ }
+}
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go
new file mode 100644
index 000000000..cdc681ad3
--- /dev/null
+++ b/internal/appconfig/configack_test.go
@@ -0,0 +1,180 @@
+package appconfig
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/relay"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// syncRelayTestKeys generates and parses a config-crypto keypair, the same
+// shape TestSyncWritesFileAndRestartsOnChange (sync_test.go) uses.
+func syncRelayTestKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) {
+ t.Helper()
+ privB64, pubB64, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ pub, err := ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+ return priv, pub
+}
+
+// sealRelayConfig builds CARLOS_RELAY's value with relay.BuildConfig (the
+// same helper relayConfigValue, internal/console/relayops.go, calls) and
+// seals it into acct/app's config bundle, returning the suffixes
+// BuildConfig issued so tests can compare against what a sync pass's ack
+// names back.
+func sealRelayConfig(t *testing.T, ctx context.Context, s release.Store, pub *ecdh.PublicKey, acct, app string, rec *relay.Record, masters []relay.RegionMaster) map[string]string {
+ t.Helper()
+ value, suffixes, err := relay.BuildConfig(acct, app, rec, masters, func(region string) []string {
+ return []string{"turn:" + region + ".relay.example.com:3478"}
+ })
+ if err != nil {
+ t.Fatalf("relay.BuildConfig: %v", err)
+ }
+ envelope, err := Seal(pub, []byte(value))
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ if err := SetSecret(ctx, s, acct, app, map[string]string{"CARLOS_RELAY": envelope}); err != nil {
+ t.Fatalf("SetSecret: %v", err)
+ }
+ return suffixes
+}
+
+func TestSyncWritesConfigAckNamingRelaySuffixes(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+
+ priv, pub := syncRelayTestKeys(t)
+
+ rec := &relay.Record{Enabled: true, Incarnation: "inc00001", Regions: []string{"eu-west-1"}}
+ masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}}
+ wantSuffixes := sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters)
+ if wantSuffixes["eu-west-1"] == "" {
+ t.Fatalf("sealRelayConfig issued no suffix for eu-west-1: %v", wantSuffixes)
+ }
+
+ if err := reg.Upsert(registry.Route{
+ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
+ Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+
+ var acks []ConfigAck
+ ack := func(a ConfigAck) { acks = append(acks, a) }
+ restart := func(host string) error { return nil }
+ if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ if len(acks) != 1 {
+ t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
+ }
+ got := acks[0]
+ if got.Host != "meet.example.com" {
+ t.Fatalf("ack.Host = %q, want meet.example.com", got.Host)
+ }
+ if got.Shape != "unit" {
+ t.Fatalf("ack.Shape = %q, want unit", got.Shape)
+ }
+ if !got.Restarted {
+ t.Fatalf("ack.Restarted = false, want true")
+ }
+ if got.RestartError != "" {
+ t.Fatalf("ack.RestartError = %q, want empty", got.RestartError)
+ }
+ if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] {
+ t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"])
+ }
+ // The identity is account:app:incarnation:kid (spec §5) — a kid alone
+ // would not distinguish a re-enable, so the whole thing must be there.
+ if !strings.Contains(got.RelaySuffixes["eu-west-1"], "abc:meet:inc00001:k1") {
+ t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want it to name abc:meet:inc00001:k1", got.RelaySuffixes["eu-west-1"])
+ }
+}
+
+func TestSyncAckReportsRestartFailure(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+
+ priv, pub := syncRelayTestKeys(t)
+ rec := &relay.Record{Enabled: true, Incarnation: "inc00002", Regions: []string{"eu-west-1"}}
+ masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}}
+ sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters)
+
+ if err := reg.Upsert(registry.Route{
+ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
+ Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+
+ var acks []ConfigAck
+ ack := func(a ConfigAck) { acks = append(acks, a) }
+ restartErr := errors.New("systemctl try-restart carlos-app@meet.example.com: exit status 1")
+ restart := func(host string) error { return restartErr }
+ if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ if len(acks) != 1 {
+ t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
+ }
+ got := acks[0]
+ if got.Restarted {
+ t.Fatalf("ack.Restarted = true, want false on a failed restart")
+ }
+ if got.RestartError != restartErr.Error() {
+ t.Fatalf("ack.RestartError = %q, want %q", got.RestartError, restartErr.Error())
+ }
+}
+
+func TestSyncAckWithoutRelayHasNilSuffixes(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+
+ priv, _ := syncRelayTestKeys(t)
+ if err := SetEnv(ctx, s, "abc", "meet", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
+ Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+
+ var acks []ConfigAck
+ ack := func(a ConfigAck) { acks = append(acks, a) }
+ restart := func(host string) error { return nil }
+ if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+
+ if len(acks) != 1 {
+ t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
+ }
+ if acks[0].RelaySuffixes != nil {
+ t.Fatalf("ack.RelaySuffixes = %+v, want nil (no CARLOS_RELAY entry)", acks[0].RelaySuffixes)
+ }
+}
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 4fc477344..0fe6d4d6c 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -17,6 +17,7 @@ import (
"path/filepath"
"sort"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -39,7 +40,13 @@ const DefaultEnvDir = "/etc/carlos/apps"
// manifests (pipelines design §4a): keying on (acct, app) alone would render
// whichever route was seen first into both files. Distinct environments cost
// one extra resolve each, still one pass over the routes.
-func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error {
+//
+// ack, if non-nil, is called once per unit-backed route this pass actually
+// converges (wrote a changed file and attempted a restart) with that
+// route's config-adoption acknowledgement (spec §5, configack.go) — nil
+// means no acks, the shape every test and the exec-only loops that never
+// touch /etc/carlos/apps use.
+func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck)) error {
routes, err := reg.List()
if err != nil {
return err
@@ -165,7 +172,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host)
continue
}
- syncHost(rt, m, r.defaultMissing, priv, envDir, restart)
+ syncHost(rt, m, r.defaultMissing, priv, envDir, restart, ack)
}
// An empty route list is never authority to delete anything:
// registry.Open silently creates an empty database when handed a wrong
@@ -328,7 +335,12 @@ func UnitConfigApps(routes []registry.Route) [][2]string {
//
// defaultMissing says the merged view is a bound environment's bundle ALONE
// because the app has no config.json — see the guard below.
-func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) {
+//
+// ack, if non-nil, is called once this route's file has actually changed
+// and a restart was attempted — never on the "up to date" early return,
+// since nothing was delivered on that pass and the ack already on file (if
+// any) from a previous pass still describes what the route is running.
+func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck)) {
// The host is joined into a root-written path; a registry row carrying
// a separator must never place a file outside envDir. Same trust level
// as release's live/<host> symlinks, but this one is one line to pin.
@@ -431,11 +443,40 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
fmt.Printf(" %s: rename %s: %v\n", rt.Host, path, err)
return
}
- // No rt.Hibernate special case on purpose: try-restart is a no-op on an
- // inactive unit, so a hibernated route's file converges without waking it.
- if err := restart(rt.Host); err != nil {
+ // The config-adoption ack (spec §5): built from what THIS pass actually
+ // rendered and did, not re-derived later from the file on disk — the
+ // route's shape, the relay identities (if any) it just wrote, and
+ // whether the restart below woke a real process or found a hibernating
+ // one with nothing to wake.
+ a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()}
+ if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
+ // A CARLOS_RELAY entry that fails to decode is loud on its own —
+ // RenderEnv already refused this render if the failure were a
+ // decrypt error shared by every var, so this is specific to the
+ // relay var (a corrupt or foreign-shaped value) and must not stop
+ // the ack the rest of the route earned.
+ fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
+ } else {
+ a.RelaySuffixes = suffixes
+ }
+ // No rt.Hibernate special case in the restart CALL itself: try-restart
+ // is a no-op on an inactive unit, so a hibernated route's file converges
+ // without waking it. The ack still has to say so, though — an inactive
+ // unit's try-restart returns success with nothing actually restarted,
+ // and reporting that as an ordinary "restarted" would tell a member
+ // their sleeping route already runs a key it will only pick up on wake.
+ switch err := restart(rt.Host); {
+ case err != nil:
fmt.Printf(" %s: config updated but restart failed: %v\n", rt.Host, err)
- return
+ a.RestartError = err.Error()
+ case rt.Hibernate:
+ a.Hibernating = true
+ fmt.Printf(" %s: config updated, hibernating — delivered on wake\n", rt.Host)
+ default:
+ a.Restarted = true
+ fmt.Printf(" %s: config updated, restarted\n", rt.Host)
+ }
+ if ack != nil {
+ ack(a)
}
- fmt.Printf(" %s: config updated, restarted\n", rt.Host)
}
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go
index c331df0b5..2f4c10734 100644
--- a/internal/appconfig/sync_test.go
+++ b/internal/appconfig/sync_test.go
@@ -56,7 +56,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) {
var restarted []string
restart := func(host string) error { restarted = append(restarted, host); return nil }
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -74,7 +74,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) {
// A second pass with nothing changed must not write or restart again.
restarted = nil
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync (second pass): %v", err)
}
if len(restarted) != 0 {
@@ -102,7 +102,7 @@ func TestSyncSkipsRoutesWithNoConfigEverSet(t *testing.T) {
}
var restarted []string
restart := func(host string) error { restarted = append(restarted, host); return nil }
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
if _, err := os.Stat(filepath.Join(envDir, "bare.example.com.env")); !os.IsNotExist(err) {
@@ -145,7 +145,7 @@ func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -190,7 +190,7 @@ func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) {
}
var restarted []string
restart := func(h string) error { restarted = append(restarted, h); return nil }
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
path := filepath.Join(envDir, "keymail.example.com.env")
@@ -202,7 +202,7 @@ func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) {
t.Fatalf("Unset: %v", err)
}
restarted = nil
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync (after unset): %v", err)
}
got, err := os.ReadFile(path)
@@ -258,7 +258,7 @@ func TestSyncReapsEnvFilesForHostsWithNoRoute(t *testing.T) {
}
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -299,7 +299,7 @@ func TestSyncEmptyRegistryLeavesEnvFilesAlone(t *testing.T) {
t.Fatalf("stage env file: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -341,7 +341,7 @@ func TestSyncSkipsExecBackedRoutes(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
if _, err := os.Stat(filepath.Join(envDir, "execshape.example.com.env")); !os.IsNotExist(err) {
@@ -380,7 +380,7 @@ func TestSyncReapsStaleTmpFiles(t *testing.T) {
}
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -400,7 +400,7 @@ func TestSyncHostRefusesNonBareHostname(t *testing.T) {
}
m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
- syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil })
+ syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }, nil)
}
if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) {
t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err)
@@ -434,7 +434,7 @@ func TestSyncSurvivesAMissingEnvDir(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync with a missing env dir: %v", err)
}
}
@@ -488,7 +488,7 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) {
var restarted []string
restart := func(h string) error { restarted = append(restarted, h); return nil }
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -521,7 +521,7 @@ func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) {
before[host] = fi
}
restarted = nil
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync (second pass): %v", err)
}
if len(restarted) != 0 {
@@ -585,7 +585,7 @@ func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -649,7 +649,7 @@ func TestSyncDoesNotReapEnvFileWhenEnvironmentIsDeleted(t *testing.T) {
if err := os.WriteFile(path, []byte("KEEP=1\n"), 0o600); err != nil {
t.Fatalf("stage env file: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
got, err := os.ReadFile(path)
@@ -712,7 +712,7 @@ func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -778,7 +778,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T
}
var restarted []string
restart := func(h string) error { restarted = append(restarted, h); return nil }
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync (pass 1): %v", err)
}
path := filepath.Join(envDir, "fresh.example.com.env")
@@ -794,7 +794,7 @@ func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T
t.Fatalf("SaveEnvironment (edit): %v", err)
}
restarted = nil
- if err := Sync(ctx, reg, s, privKey, envDir, restart); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
t.Fatalf("Sync (pass 2): %v", err)
}
got, err := os.ReadFile(path)
@@ -845,7 +845,7 @@ func TestSyncRefusesToReplaceAnUnparseableFileWhenAppHasNoDefaultBundle(t *testi
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
got, err := os.ReadFile(path)
@@ -891,14 +891,14 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync (pass 1): %v", err)
}
path := filepath.Join(envDir, "shrink.example.com.env")
if _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "demo", []string{"GONE"}); err != nil {
t.Fatalf("UnsetEnvironment: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync (pass 2): %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\nCARLOS_PURGE=header\n" {
@@ -910,7 +910,7 @@ func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"KEEP": "1"}); err != nil {
t.Fatalf("SetEnv: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync (pass 3): %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "KEEP=1\nCARLOS_PURGE=header\n" {
@@ -961,7 +961,7 @@ func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) {
if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
restarted = append(restarted, h)
return nil
- }); err != nil {
+ }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
@@ -1011,7 +1011,7 @@ func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) {
if err := os.WriteFile(filepath.Join(envDir, "emptyfile.example.com.env"), nil, 0o600); err != nil {
t.Fatalf("stage empty env file: %v", err)
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
for _, host := range []string{"demo.example.com", "emptyfile.example.com"} {
@@ -1054,7 +1054,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
var restarted int
restart := func(string) error { restarted++; return nil }
- syncHost(rt, m, false, nil, envDir, restart)
+ syncHost(rt, m, false, nil, envDir, restart, nil)
path := filepath.Join(envDir, "steady.example.com.env")
got, err := os.ReadFile(path)
if err != nil {
@@ -1072,7 +1072,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
}
// Second, identical pass.
- syncHost(rt, m, false, nil, envDir, restart)
+ syncHost(rt, m, false, nil, envDir, restart, nil)
if restarted != 1 {
t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted)
}
@@ -1119,7 +1119,7 @@ func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) {
t.Fatalf("Upsert %s: %v", host, err)
}
}
- if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }); err != nil {
+ if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
t.Fatalf("Sync: %v", err)
}
for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} {
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 0f0f8e5b6..32eca0b6d 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -42,6 +42,7 @@ import (
"path/filepath"
"sort"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -61,7 +62,12 @@ const configEnvSuffix = ".config.env"
// (pipelines design §4a): two routes of ONE app bound to different
// environments resolve to different manifests, and keying on (acct, app)
// alone would render whichever route was seen first into both files.
-func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string) error {
+//
+// ack, if non-nil, is called once per route this pass actually converges
+// (wrote a changed or removed file) with that route's config-adoption
+// acknowledgement (spec §5, configack.go) — nil means no acks, same
+// contract as Sync's.
+func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, appsDomain string, ack func(ConfigAck)) error {
routes, err := reg.List()
if err != nil {
return err
@@ -413,7 +419,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
hostBounce = nil
header = execAddrConfigHeader
}
- syncExecHost(rt, r.m, Key(k.acct, k.app), priv, dataDir, hostBounce, header)
+ syncExecHost(rt, r.m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, ack)
}
// Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03
// review): an addr-shaped route whose app is DEFINITIVELY not opted in
@@ -844,7 +850,15 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos
// unified) — folded into the SAME diff/write/rename path as the vars
// themselves, so a header-only change (e.g. its text edited in a future
// release) converges exactly like a var change would, once.
-func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string) {
+// ack, if non-nil, is called once this route's file has actually changed
+// (written or removed) with its config-adoption acknowledgement (spec §5).
+// Restarted is always false and RestartError always empty here — unlike
+// syncHost's unit path, this shape's bounce is best-effort and
+// fire-and-forget (bounceLogged swallows its error), and a tcp-exec-file
+// route's process belongs to an external supervisor this platform never
+// restarts at all; that shape's cutover is a member acknowledgement
+// (`carlos relay adopted`) instead.
+func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string, ack func(ConfigAck)) {
if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) {
fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host)
return
@@ -910,6 +924,15 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private
}
fmt.Printf(" %s: config updated\n", rt.Host)
bounceLogged(rt.Host, bounce)
+ if ack != nil {
+ a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()}
+ if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
+ fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
+ } else {
+ a.RelaySuffixes = suffixes
+ }
+ ack(a)
+ }
}
// bounceLogged: bounce is best-effort — a failure is logged, never
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 372e83818..e97709589 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -94,7 +94,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) {
}
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "inst.example.com.config.env"))
@@ -112,7 +112,7 @@ func TestSyncExecWritesConfigFileAndBouncesOnChange(t *testing.T) {
}
// Second pass, nothing changed: no bounce.
bounced = nil
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec (second): %v", err)
}
if len(bounced) != 0 {
@@ -140,7 +140,7 @@ func TestSyncExecRemovesFileWhenManifestDeleted(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -185,7 +185,7 @@ func TestSyncExecMassRemovalGuardRefusesWhenEveryGroupResolvesAbsent(t *testing.
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(pathA); err != nil {
@@ -230,7 +230,7 @@ func TestSyncExecMassRemovalGuardDoesNotBlockNormalConvergence(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(pathA); err != nil {
@@ -282,7 +282,7 @@ func TestSyncExecRendersEachRoutesBoundEnvironment(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
want := map[string]string{
@@ -329,7 +329,7 @@ func TestSyncExecLeavesFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T)
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(path)
@@ -392,7 +392,7 @@ func TestSyncExecOverlayOnlyAppDropsStaleKeys(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(path)
@@ -440,7 +440,7 @@ func TestSyncExecMassRemovalGuardIgnoresErroredGroups(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
@@ -476,7 +476,7 @@ func TestSyncExecNeverTouchesInstanceEnvOrOutOfScopeRoutes(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if got, _ := os.ReadFile(own); string(got) != "CARLOS_ADMIN_TOKEN=tok\n" {
@@ -516,7 +516,7 @@ func TestSyncExecReapsOnlyItsOwnNamespace(t *testing.T) {
t.Fatalf("stage %s: %v", name, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for name, keep := range files {
@@ -564,7 +564,7 @@ func TestSyncExecReapsOnlyItsOwnNamespaceWithAddrShapeInTheMix(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
for name, keep := range files {
@@ -592,7 +592,7 @@ func TestSyncExecEmptyRegistryRemovesNothing(t *testing.T) {
if err := os.WriteFile(path, []byte("X=1\n"), 0o600); err != nil {
t.Fatalf("stage: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(path); err != nil {
@@ -605,7 +605,7 @@ func TestSyncExecRefusesNonBareHostname(t *testing.T) {
pk, _ := ParsePrivateKey(priv, "test")
m := &Manifest{Schema: 1, App: "eleven", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
- syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "")
+ syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "", nil)
}
ents, err := os.ReadDir(dd)
if err != nil {
@@ -640,7 +640,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -665,7 +665,7 @@ func TestSyncExecOptedInAddrRouteMaterializesFile(t *testing.T) {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond) // ensure a rewrite would move mtime
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec (second pass): %v", err)
}
after, err := os.Stat(path)
@@ -697,7 +697,7 @@ func TestSyncExecUnoptedAddrRouteIsSkipped(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(filepath.Join(dd, "demo.go.tito.io.config.env")); !os.IsNotExist(err) {
@@ -741,7 +741,7 @@ func TestSyncExecUnrecognizedExecDeliveryValueIgnoredNotFatal(t *testing.T) {
}
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "")
+ syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil)
})
if syncErr != nil {
t.Fatalf("SyncExec: %v", syncErr)
@@ -790,7 +790,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T)
}
// First pass materializes normally — this is the file a transient
// blip must leave exactly as it is.
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec (initial): %v", err)
}
path := filepath.Join(dd, "demo.go.tito.io.config.env")
@@ -805,7 +805,7 @@ func TestSyncExecOptedInAddrRouteFetchFailureLogsAndLeavesFileAsIs(t *testing.T)
bounce := func(h string) error { bounced = append(bounced, h); return nil }
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce, "")
+ syncErr = SyncExec(ctx, reg, failing, pk, dd, bounce, "", nil)
})
if syncErr != nil {
t.Fatalf("SyncExec (blip): %v", syncErr)
@@ -873,7 +873,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) {
bounced = append(bounced, h)
return nil
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec (initial write): %v", err)
}
if len(bounced) != 0 {
@@ -884,7 +884,7 @@ func TestSyncExecOptedInAddrRouteIsNeverBounced(t *testing.T) {
if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"TITOGO_DEMO_BANNER": "2"}); err != nil {
t.Fatalf("SetEnv (change): %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec (changed content): %v", err)
}
if len(bounced) != 0 {
@@ -933,7 +933,7 @@ func TestSyncExecSocketRouteStillBouncesAlongsideOptedInAddrRoute(t *testing.T)
bounced = append(bounced, h)
return nil
}
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if !slices.Contains(bounced, "inst.example.com") {
@@ -979,7 +979,7 @@ func TestSyncExecOptedInAddrRouteHonorsBoundEnvironment(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1018,7 +1018,7 @@ func TestSyncExecOptedInAddrAppWithNoVarsConvergesToEmptyFile(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1073,7 +1073,7 @@ func TestSyncExecMassRemovalGuardUnaffectedByOptedInAddrAppWithContent(t *testin
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, ""); err != nil {
+ }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1128,7 +1128,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec (opted in): %v", err)
}
path := filepath.Join(dd, "demo.go.tito.io.config.env")
@@ -1144,7 +1144,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
bounce := func(h string) error { bounced = append(bounced, h); return nil }
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "")
+ syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "", nil)
})
if syncErr != nil {
t.Fatalf("SyncExec (opted out): %v", syncErr)
@@ -1170,7 +1170,7 @@ func TestSyncExecOptOutLeavesStaleFileButWarnsEveryPass(t *testing.T) {
// changed must repeat it, since nothing about the risk (stale
// plaintext secrets, forever) has gone away.
out2 := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "")
+ syncErr = SyncExec(ctx, reg, s, pk, dd, bounce, "", nil)
})
if syncErr != nil {
t.Fatalf("SyncExec (opted out, second pass): %v", syncErr)
@@ -1205,7 +1205,7 @@ func TestSyncExecNoOptOutWarningWhenNeverOptedIn(t *testing.T) {
}
var syncErr error
out := captureStdout(t, func() {
- syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "")
+ syncErr = SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil)
})
if syncErr != nil {
t.Fatalf("SyncExec: %v", syncErr)
@@ -1245,7 +1245,7 @@ func TestSyncExecAddrFileCarriesHeaderSocketFileDoesNot(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
addrGot, err := os.ReadFile(filepath.Join(dd, "demo.go.tito.io.config.env"))
@@ -1299,7 +1299,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) {
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec (set): %v", err)
}
got, err := os.ReadFile(path)
@@ -1316,7 +1316,7 @@ func TestSyncExecConvergesToEmptyFileWhenAllVarsUnset(t *testing.T) {
t.Fatalf("Unset (all vars): %v", err)
}
bounced = nil
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec (unset): %v", err)
}
got, err = os.ReadFile(path)
@@ -1353,7 +1353,7 @@ func TestReapSparesLiveRoutesInstanceEnvWithConfigSuffix(t *testing.T) {
t.Fatalf("Upsert: %v", err)
}
out := captureStdout(t, func() {
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
})
@@ -1416,7 +1416,7 @@ func TestSyncExecRefusesConfigNamespaceCollision(t *testing.T) {
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
out = captureStdout(t, func() {
- if err := SyncExec(ctx, reg, s, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
})
@@ -1487,7 +1487,7 @@ func TestSyncExecFetchFailureLeavesOtherGroupsConverging(t *testing.T) {
failing := &failingKeyStore{Store: s, failKey: Key("bab", "appa"), err: blipErr}
var bounced []string
bounce := func(h string) error { bounced = append(bounced, h); return nil }
- if err := SyncExec(ctx, reg, failing, pk, dd, bounce, ""); err != nil {
+ if err := SyncExec(ctx, reg, failing, pk, dd, bounce, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(stalePath)
@@ -1708,7 +1708,7 @@ func TestSyncExecOptInResolvesPerEnvironment(t *testing.T) {
t.Fatalf("Upsert %s: %v", rt.Host, err)
}
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
assertConfigFile(t, dd, "connect.go.tito.io", tc.wantRelayFile)
@@ -1757,7 +1757,7 @@ func TestSyncExecEnvScopedOptInDeliversTheMergedBundle(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
got, err := os.ReadFile(filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix))
@@ -1802,7 +1802,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
path := filepath.Join(dd, "connect.go.tito.io"+configEnvSuffix)
@@ -1814,7 +1814,7 @@ func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
blip := &failingKeyStore{Store: s, failKey: EnvironmentKey("bab", "titogo", "relay"),
err: errors.New("503 SlowDown")}
out := captureStdout(t, func() {
- if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }, ""); err != nil {
+ if err := SyncExec(ctx, reg, blip, pk, dd, func(string) error { return nil }, "", nil); err != nil {
t.Fatalf("SyncExec (blip): %v", err)
}
})
@@ -1866,7 +1866,7 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) {
if err := SyncExec(ctx, reg, s, pk, dd, func(h string) error {
bounced = append(bounced, h)
return nil
- }, "oncarlos.test"); err != nil {
+ }, "oncarlos.test", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(path); err != nil {
@@ -1889,7 +1889,7 @@ func TestSyncExecHoldsConfigWhenHostNamesADifferentAccount(t *testing.T) {
if err := os.WriteFile(agreed, []byte("OLD=1\n"), 0o600); err != nil {
t.Fatalf("stage: %v", err)
}
- if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "oncarlos.test"); err != nil {
+ if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "oncarlos.test", nil); err != nil {
t.Fatalf("SyncExec: %v", err)
}
if _, err := os.Stat(agreed); !os.IsNotExist(err) {
diff --git a/internal/console/apirelay.go b/internal/console/apirelay.go
index c3a2854a3..b3a941747 100644
--- a/internal/console/apirelay.go
+++ b/internal/console/apirelay.go
@@ -22,16 +22,28 @@
// that this deployment runs a relay a caller might rotate — the same
// reasoning apiSystemOwner's family already applies to `carlos system`.
//
-// DELIVERY IS DELIBERATELY THIN FOR NOW. Spec §5 describes five delivery
-// states a route can be in, most of them read from a per-route config-ack
-// (`control/config-ack/<box>/<route host>.json`) that Task 12 has not written
-// yet. This file reports only what a route's SHAPE, its config bundle and the
-// record's own Cutovers can say today: that the key was written, that a route
-// this platform cannot restart is waiting on the member, that a route with no
-// config delivery will never receive one at all, and that a member's
-// acknowledgement either does or no longer does name the identities issued
-// now. It marks the place Task 12 fills in with a TODO rather than inventing
-// the ack's shape.
+// DELIVERY READS THE PER-ROUTE CONFIG-ACK. Spec §5 describes five delivery
+// states a route can be in; most of them come from a per-route config-ack
+// (`control/config-ack/<box>/<route host>.json`, internal/appconfig/
+// configack.go) config sync writes after it converges a route. There is no
+// per-host box list to narrow that read by (relayHeartbeats' own doc
+// comment says the same for control/relay-ack/<box>.json), so relayReply
+// reads every ack that exists (relayConfigAcks, relayops.go) and matches by
+// Host. An ack counts only when its RelaySuffixes equal exactly what the
+// record currently has issued — the same freshness rule relayCutoverFor's
+// maps.Equal already applies to a member's `carlos relay adopted`: an ack
+// that named an OLDER incarnation's identities must not read as "this route
+// runs what was just issued", so a stale ack falls back to "written,
+// awaiting delivery" rather than reporting an adoption that never happened
+// under the current keys.
+//
+// syncExecHost's ack (the socket-exec and tcp-exec-file shapes) always
+// leaves Restarted false and RestartError empty — its bounce is
+// best-effort and fire-and-forget, and a tcp-exec-file route's process is
+// never restarted by this platform at all — so "adopted at <time>,
+// restarted" is reachable only for a unit-backed route today; a
+// socket-exec route with a matching ack stays "written, awaiting delivery"
+// until its own restart signal exists to report.
//
// Spec: docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md §2, §5.
package console
@@ -46,6 +58,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/relay"
)
@@ -133,6 +146,16 @@ type apiRelayRoute struct {
// `carlos relay adopted --host` for this route (relay.RouteCutover).
AckedAt string `json:"acked_at,omitempty"`
Cutover bool `json:"cutover"`
+ // ConfigAckAt and ConfigAckShape echo config sync's own per-route
+ // acknowledgement (control/config-ack/<box>/<route host>.json) when one
+ // exists naming exactly the identities this record currently has
+ // issued — empty until then, and left empty for a stale ack (one
+ // naming an older incarnation's identities) exactly like AckedAt above.
+ // Distinct from AckedAt/Cutover: those describe a MEMBER's `carlos
+ // relay adopted`, this describes what the PLATFORM'S OWN config sync
+ // last rendered and, where it could, restarted.
+ ConfigAckAt string `json:"config_acked_at,omitempty"`
+ ConfigAckShape string `json:"config_ack_shape,omitempty"`
}
// apiRelayRegion is one region's health as this app's status sees it, built
@@ -263,6 +286,29 @@ func relayCutoverFor(rec *relay.Record, host string) *relay.RouteCutover {
return nil
}
+// relayMatchingAck finds the freshest config-ack among acks naming host
+// whose RelaySuffixes equal issued exactly — nil if none does. The equality
+// check is the same freshness rule relayCutoverFor's caller applies to a
+// member's Cutover (maps.Equal against rec.IssuedSuffixes): an ack written
+// before a rotation names the identities that rotation retired, and
+// reporting it as current would tell a member their route runs a key it
+// has never actually seen. "Freshest" (latest At) only matters if more than
+// one box's ack somehow matches, which config sync's own per-(box,host) key
+// makes rare in practice but not impossible across a route migration.
+func relayMatchingAck(acks []appconfig.ConfigAck, host string, issued map[string]string) *appconfig.ConfigAck {
+ var best *appconfig.ConfigAck
+ for i := range acks {
+ a := &acks[i]
+ if a.Host != host || !maps.Equal(a.RelaySuffixes, issued) {
+ continue
+ }
+ if best == nil || a.At.After(best.At) {
+ best = a
+ }
+ }
+ return best
+}
+
// relayReply assembles apiRelayReply from a record that has already been
// read or just been written. rec is never nil here — the callers that could
// see one (handleAPIRelayGet) answer their own 404 first.
@@ -276,6 +322,14 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
if err != nil {
return apiRelayReply{}, err
}
+ // Read once for the whole reply, not once per route: there is no
+ // per-host box list to narrow this by (see the file's own doc comment),
+ // so every route below filters this same slice by Host instead of each
+ // paying for its own List+Get pass over every box's acks.
+ acks, err := s.relayConfigAcks(ctx)
+ if err != nil {
+ return apiRelayReply{}, err
+ }
for _, rt := range routes {
if rt.Kind == "static" {
// A static route runs no process at all — relayDeliverable
@@ -312,13 +366,29 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
"opt it into exec_delivery file or move it to a unit"
default:
row.Delivery = "written, awaiting delivery"
- if shape == relayShapeFile {
- // The ONE shape whose process this platform cannot bounce.
- // The console cannot know the file has landed until Task 12's
- // ack exists, but it can say whose move it is — and leaving
- // that out is how a route sits un-adopted for a month and
- // blocks a retirement nobody can explain.
- row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it"
+ // A config-ack counts only when it names EXACTLY the identities
+ // this record has issued now (relayMatchingAck) — the same
+ // freshness rule the member-Cutover comparison just below makes
+ // for `carlos relay adopted`. No match at all (never synced yet,
+ // or a stale ack left over from before a rotation) leaves the
+ // route at the base "written, awaiting delivery" rather than
+ // reporting an adoption under keys the route may never have run.
+ if ca := relayMatchingAck(acks, rt.Host, rec.IssuedSuffixes); ca != nil {
+ row.ConfigAckAt = ca.At.UTC().Format(time.RFC3339)
+ row.ConfigAckShape = ca.Shape
+ switch {
+ case shape == relayShapeFile:
+ // The ONE shape whose process this platform cannot
+ // bounce: the ack proves the FILE landed, but adoption
+ // itself is still the member's move (below).
+ row.Delivery = "file delivered, awaiting `carlos relay adopted --host " + rt.Host + "` after you restart it"
+ case ca.RestartError != "":
+ row.Delivery = "restart failed: " + ca.RestartError
+ case ca.Hibernating:
+ row.Delivery = "hibernating, delivered on wake"
+ case ca.Restarted:
+ row.Delivery = "adopted at " + row.ConfigAckAt + ", restarted"
+ }
}
if cut := relayCutoverFor(rec, rt.Host); cut != nil {
row.AckedAt = cut.At.UTC().Format(time.RFC3339)
@@ -336,14 +406,6 @@ func (s *Server) relayReply(ctx context.Context, acct *Account, app string, rec
}
}
}
- // TODO(task 12): once internal/appconfig/sync.go writes
- // control/config-ack/<box>/<route host>.json, read it here and
- // report the fuller set of states spec §5 describes for a unit or
- // socket-exec route ("adopted at <time>, restarted", "restart
- // failed", "hibernating, delivered on wake") instead of the states
- // above, which is all the console can see without it — including
- // whether the FILE half of a tcp-exec-file route's sentence has
- // actually happened yet.
out.Routes = append(out.Routes, row)
}
diff --git a/internal/console/apirelay_test.go b/internal/console/apirelay_test.go
index 105a816dc..b39fc9557 100644
--- a/internal/console/apirelay_test.go
+++ b/internal/console/apirelay_test.go
@@ -442,11 +442,29 @@ func TestAPIRelayStatusNamesEachRouteShapeAndWhoseMoveItIs(t *testing.T) {
}
wantRoute(t, rows, "a.titogo.oncarlos.com", "unit", "written, awaiting delivery")
wantRoute(t, rows, "b.titogo.oncarlos.com", "socket-exec", "written, awaiting delivery")
- wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file",
- "carlos relay adopted --host c.titogo.oncarlos.com")
+ // No box has synced yet, so no config-ack exists — c reads exactly like
+ // a and b until one does (Task 12: a tcp-exec-file route's "file
+ // delivered, awaiting adopted" sentence is earned by an ack, not
+ // assumed from its shape alone).
+ wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file", "written, awaiting delivery")
wantRoute(t, rows, "d.titogo.oncarlos.com", "no-delivery",
"no config delivery", "exec_delivery file")
+ // Now a box's config sync delivers the file and writes its ack
+ // (appconfig.WriteConfigAck, seeded here exactly as syncExecHost would)
+ // — only then does c's row say whose move it is.
+ issued := relayRecord(t, fx, "titogo").IssuedSuffixes
+ if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{
+ Box: "box1", Host: "c.titogo.oncarlos.com", Shape: "tcp-exec-file", At: time.Now(),
+ RelaySuffixes: cloneSuffixes(issued),
+ }); err != nil {
+ t.Fatalf("seed ack c: %v", err)
+ }
+ _, out = getJSON(t, relayURL(fx, "titogo"), token)
+ rows = relayRouteRows(t, out)
+ wantRoute(t, rows, "c.titogo.oncarlos.com", "tcp-exec-file",
+ "carlos relay adopted --host c.titogo.oncarlos.com")
+
// The undeliverable route cannot be acknowledged away.
status, out = postJSON(t, relayURL(fx, "titogo")+"/adopted",
map[string]any{"host": "d.titogo.oncarlos.com"}, token)
@@ -526,3 +544,80 @@ func TestAPIRelayRotateStagingRefusalsAreClientErrors(t *testing.T) {
t.Fatalf("error = %q, want the no-live-relay sentence", msg)
}
}
+
+// cloneSuffixes copies a suffix map so a test can hand one ack the record's
+// real IssuedSuffixes and another a value that merely LOOKS like it without
+// aliasing the record's own map.
+func cloneSuffixes(m map[string]string) map[string]string {
+ out := make(map[string]string, len(m))
+ for k, v := range m {
+ out[k] = v
+ }
+ return out
+}
+
+// TestAPIRelayStatusReadsConfigAcks is Task 12's console half: three
+// unit-backed routes, one config-ack each, seeded exactly as config sync
+// itself writes them (control/config-ack/<box>/<host>.json,
+// appconfig.WriteConfigAck) rather than through any sync machinery — the
+// door's whole job here is reading and matching what's already in the
+// bucket, not producing it. An ack naming the issued identities and a
+// successful restart reads as adopted; one naming them with a restart
+// error reads as failed; one naming identities from BEFORE this app's
+// enable (a stale ack, the shape a rotation or a re-enable would leave
+// behind) must not read as adopted at all — see relayMatchingAck's own
+// doc comment for why.
+func TestAPIRelayStatusReadsConfigAcks(t *testing.T) {
+ fx, _ := relayFixture(t, "code-relay-config-acks")
+ ctx := context.Background()
+ token := cliToken(t, fx, "alice@keymail.dev", "code-relay-config-acks-2")
+
+ fx.addRoute(t, registry.Route{Host: "a.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Unit: "titogo-a.service", Status: "ready"})
+ fx.addRoute(t, registry.Route{Host: "b.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Unit: "titogo-b.service", Status: "ready"})
+ fx.addRoute(t, registry.Route{Host: "c.titogo.oncarlos.com", App: "titogo",
+ Kind: "service", Unit: "titogo-c.service", Status: "ready"})
+
+ if status, out := postJSON(t, relayURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("enable = %d %v, want 200", status, out)
+ }
+ issued := relayRecord(t, fx, "titogo").IssuedSuffixes
+ if issued["eu-west-1"] == "" {
+ t.Fatalf("enable issued nothing for eu-west-1: %v", issued)
+ }
+
+ now := time.Now()
+ if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{
+ Box: "box1", Host: "a.titogo.oncarlos.com", Shape: "unit", At: now,
+ Restarted: true, RelaySuffixes: cloneSuffixes(issued),
+ }); err != nil {
+ t.Fatalf("seed ack a: %v", err)
+ }
+ if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{
+ Box: "box1", Host: "b.titogo.oncarlos.com", Shape: "unit", At: now,
+ RestartError: "systemctl try-restart carlos-app@b.titogo.oncarlos.com: exit status 1",
+ RelaySuffixes: cloneSuffixes(issued),
+ }); err != nil {
+ t.Fatalf("seed ack b: %v", err)
+ }
+ if err := appconfig.WriteConfigAck(ctx, fx.rel, appconfig.ConfigAck{
+ Box: "box1", Host: "c.titogo.oncarlos.com", Shape: "unit", At: now,
+ Restarted: true,
+ RelaySuffixes: map[string]string{"eu-west-1": "abc:titogo:stale0000000:k0"},
+ }); err != nil {
+ t.Fatalf("seed ack c: %v", err)
+ }
+
+ _, out := getJSON(t, relayURL(fx, "titogo"), token)
+ rows := relayRouteRows(t, out)
+ wantRoute(t, rows, "a.titogo.oncarlos.com", "unit", "adopted at", "restarted")
+ wantRoute(t, rows, "b.titogo.oncarlos.com", "unit", "restart failed",
+ "systemctl try-restart carlos-app@b.titogo.oncarlos.com: exit status 1")
+ if got := str(t, rows["c.titogo.oncarlos.com"], "delivery"); strings.Contains(got, "adopted") {
+ t.Fatalf("c delivery = %q, a stale-suffix ack must not read as adopted", got)
+ }
+ if got := str(t, rows["c.titogo.oncarlos.com"], "delivery"); got != "written, awaiting delivery" {
+ t.Fatalf("c delivery = %q, want the base written-awaiting-delivery sentence", got)
+ }
+}
diff --git a/internal/console/relayops.go b/internal/console/relayops.go
index ad0916ad3..57c199aa5 100644
--- a/internal/console/relayops.go
+++ b/internal/console/relayops.go
@@ -67,6 +67,10 @@ const (
relayIssuerKey = "control/relay-issuer.json"
// relayAckPrefix is where each armed relay writes its heartbeat.
relayAckPrefix = "control/relay-ack/"
+ // configAckPrefix is where each box's config sync writes a per-route
+ // config-adoption acknowledgement (spec §5, internal/appconfig/
+ // configack.go): configAckPrefix + "<box>/<route host>.json".
+ configAckPrefix = "control/config-ack/"
// The lease's two numbers, from spec §5: sixty seconds of validity,
// renewed every twenty. Three renewals inside one TTL means a single
@@ -1158,6 +1162,38 @@ func (s *Server) relayHeartbeats(ctx context.Context) ([]relay.Heartbeat, error)
return out, nil
}
+// relayConfigAcks reads every per-route config-adoption acknowledgement
+// under configAckPrefix, across every box — there is no per-host box list
+// to narrow the read by, the same limit relayHeartbeats accepts for
+// control/relay-ack/, so relayReply reads all of them and filters by Host
+// itself (relayMatchingAck, apirelay.go).
+func (s *Server) relayConfigAcks(ctx context.Context) ([]appconfig.ConfigAck, error) {
+ keys, err := s.cfg.Releases.List(ctx, configAckPrefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []appconfig.ConfigAck
+ for _, key := range keys {
+ if !strings.HasSuffix(key, ".json") {
+ continue
+ }
+ body, _, err := s.cfg.Releases.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, err
+ }
+ var a appconfig.ConfigAck
+ if err := json.Unmarshal(body, &a); err != nil {
+ log.Printf("console: relay config ack %s: %v", key, err)
+ continue
+ }
+ out = append(out, a)
+ }
+ return out, nil
+}
+
// relayRotate moves a region's issuer pointer to the master the operator
// staged, then re-issues every app enabled in that region (spec §5, step 4).
//
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a877cb113..da1151d8c 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -677,7 +677,28 @@ var configSyncMu sync.Mutex
func syncExecSerialized(ctx context.Context, reg *registry.Registry, store release.Store, priv *ecdh.PrivateKey, dd string, svc *activator.Service) error {
configSyncMu.Lock()
defer configSyncMu.Unlock()
- return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN"))
+ return appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc), os.Getenv("PLATFORM_APPS_DOMAIN"), configAckFunc(ctx, store, "agent"))
+}
+
+// configAckFunc builds the callback config sync's two passes (the root
+// pass's appconfig.Sync and this agent's own appconfig.SyncExec, called
+// through syncExecSerialized above) invoke for a route they just converged
+// (relay spec §5, internal/appconfig/configack.go): it stamps this box's
+// label onto the ack — BoxLabel (metrics_loop.go) is the same identity
+// every other per-box artifact this platform writes uses, so a config-ack
+// and this box's heartbeat/metrics rows agree about which box they belong
+// to — and writes it. A write failure is logged, never returned: an ack is
+// best-effort telemetry for `carlos relay status`, not something config
+// delivery itself depends on, and failing the whole sync pass over it would
+// make a bucket blip on the ack object cost every OTHER route its config.
+func configAckFunc(ctx context.Context, store release.Store, logPrefix string) func(appconfig.ConfigAck) {
+ box := BoxLabel()
+ return func(a appconfig.ConfigAck) {
+ a.Box = box
+ if err := appconfig.WriteConfigAck(ctx, store, a); err != nil {
+ log.Printf("%s: config ack %s: %v", logPrefix, a.Host, err)
+ }
+ }
}
// reconcileMu serializes the two provisioning loops' Reconcile passes
@@ -2954,7 +2975,7 @@ func RunAdopt(args []string) {
secretsPriv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
if err != nil {
log.Printf("adopt: config sync disabled: %v", err)
- } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart); err != nil {
+ } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart, configAckFunc(ctx, store, "adopt")); err != nil {
log.Printf("adopt: config sync: %v", err)
}
}
diff --git a/internal/relay/config_appconfig_test.go b/internal/relay/config_appconfig_test.go
new file mode 100644
index 000000000..b94fce28b
--- /dev/null
+++ b/internal/relay/config_appconfig_test.go
@@ -0,0 +1,34 @@
+package relay_test
+
+// This file, alone in the package, is relay_test (an EXTERNAL test
+// package) rather than relay: it asserts that BuildConfig's value is safe
+// to materialize into an EnvironmentFile, which means importing
+// internal/appconfig for ValidVarValue — and internal/appconfig imports
+// internal/relay for real since Task 12 (configack.go, relaySuffixesFromRelayVar).
+// An INTERNAL relay test importing appconfig would close relay ->
+// appconfig -> relay, which go vet refuses; this package, built separately
+// from both, does not.
+
+import (
+ "crypto/sha256"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/relay"
+)
+
+func TestBuildConfigValueIsEnvSafe(t *testing.T) {
+ m := sha256.Sum256([]byte("carlos-relay-test-master"))
+ r := &relay.Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}}
+ urls := func(region string) []string {
+ h := "relay." + region + ".oncarlos.com:3478"
+ return []string{"stun:" + h, "turn:" + h + "?transport=udp", "turn:" + h + "?transport=tcp"}
+ }
+ val, _, err := relay.BuildConfig("abc", "meet", r, []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: m[:]}}, urls)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.ValidVarValue(val); err != nil {
+ t.Fatalf("BuildConfig's value is not env-safe: %v", err)
+ }
+}
diff --git a/internal/relay/config_test.go b/internal/relay/config_test.go
index 3e0745d59..8965b0a86 100644
--- a/internal/relay/config_test.go
+++ b/internal/relay/config_test.go
@@ -3,10 +3,15 @@ package relay
import (
"crypto/sha256"
"testing"
-
- "github.com/carlosframework/platform/internal/appconfig"
)
+// The env-safety assertion this test used to make inline (appconfig.
+// ValidVarValue(val)) moved to config_appconfig_test.go, package
+// relay_test, once Task 12 gave internal/appconfig a real import of
+// internal/relay (configack.go): an INTERNAL test file here (package
+// relay, this file) importing appconfig would close relay -> appconfig ->
+// relay, which go vet refuses. An external test package can import both
+// without cycling, because it isn't part of either package's own build.
func TestBuildConfigMatchesContract(t *testing.T) {
m := sha256.Sum256([]byte("carlos-relay-test-master"))
r := &Record{Enabled: true, Incarnation: "0123456789abcdef", Regions: []string{"eu-west-1"}}
@@ -18,9 +23,6 @@ func TestBuildConfigMatchesContract(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := appconfig.ValidVarValue(val); err != nil {
- t.Fatalf("not env-safe: %v", err)
- }
entries, err := ParseConfig(val)
if err != nil || len(entries) != 1 {
t.Fatalf("parse = %+v %v", entries, err)
diff --git a/relay_test.go b/relay_test.go
index 58cf8343d..8fc848383 100644
--- a/relay_test.go
+++ b/relay_test.go
@@ -251,6 +251,16 @@ func TestPrintRelayStatus(t *testing.T) {
Delivery: "no config delivery: relay will not reach this route; opt it into exec_delivery file or move it to a unit"},
{Host: "e.meet", Shape: "tcp-exec-file", Cutover: true,
AckedAt: "2026-09-07T10:00:00Z", Delivery: "adopted by member at 2026-09-07T10:00:00Z"},
+ // f, g, h: the three states Task 12's config-ack adds for a
+ // unit or socket-exec route (internal/console/apirelay.go's
+ // relayMatchingAck) — printRelayStatus computes none of this,
+ // it only prints whatever Delivery sentence it was handed, so
+ // these pin that the sentences flow through unmangled.
+ {Host: "f.meet", Shape: "unit", ConfigAckAt: "2026-09-07T10:05:00Z",
+ Delivery: "adopted at 2026-09-07T10:05:00Z, restarted"},
+ {Host: "g.meet", Shape: "unit",
+ Delivery: "restart failed: systemctl try-restart carlos-app@g.meet: exit status 1"},
+ {Host: "h.meet", Shape: "unit", Delivery: "hibernating, delivered on wake"},
}
},
want: []string{
@@ -259,6 +269,9 @@ func TestPrintRelayStatus(t *testing.T) {
"route c.meet (tcp-exec-file): file delivered, awaiting `carlos relay adopted --host c.meet`",
"route d.meet (no-delivery): no config delivery",
"route e.meet (tcp-exec-file): adopted by member at 2026-09-07T10:00:00Z",
+ "route f.meet (unit): adopted at 2026-09-07T10:05:00Z, restarted",
+ "route g.meet (unit): restart failed: systemctl try-restart carlos-app@g.meet: exit status 1",
+ "route h.meet (unit): hibernating, delivered on wake",
},
// The lesson restart.go paid for: never say a box has acted.
wantNot: []string{"no routes yet"},
@@ -294,10 +307,13 @@ func TestPrintRelayStatus(t *testing.T) {
}
// TestPrintRelayStatusNeverClaimsDelivery is the output-honesty rule of this
-// whole file in one assertion: the console can say a key was WRITTEN and it
-// can repeat a member's own acknowledgement, but nothing here may claim a
-// running process has picked anything up. Only Task 12's per-route ack can
-// say that, and until it exists no sentence may imply it.
+// whole file in one assertion: given only "written, awaiting delivery" — no
+// config-ack, the state before Task 12's per-route ack (internal/appconfig/
+// configack.go) exists for a route — printRelayStatus must not claim a
+// running process has picked anything up. It is a pure function of
+// res.Routes[i].Delivery either way: what earns the fuller "adopted at
+// <time>, restarted" sentence is relayReply's own config-ack matching
+// (internal/console/apirelay.go), not anything this function decides.
func TestPrintRelayStatusNeverClaimsDelivery(t *testing.T) {
var buf bytes.Buffer
printRelayStatus(&buf, apiRelayReply{