carlos / platform Public

appconfig: the instance config layer's key, host rule and etag-returning reader (plan 4, task 1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 11e20e4e34892e084c1c653e04a0389e2b75a114 parent ce24be0
3 files changed, +227 −9
  • internal/appconfig/instances.go +102 −0
  • internal/appconfig/instances_test.go +105 −0
  • internal/appconfig/store.go +20 −9
diff --git a/internal/appconfig/instances.go b/internal/appconfig/instances.go
new file mode 100644
index 000000000..e6a43d332
--- /dev/null
+++ b/internal/appconfig/instances.go
@@ -0,0 +1,102 @@
+// The instance config layer (tenant-email senders design §6.2): a third
+// bundle, one object per route host, resolved after the named environment.
+// Same Manifest shape, same sealed envelopes, same name and value rules as
+// the other two — a per-instance bundle is not a new format, just a third
+// bucket key the same shape is written to.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/instances-config/<host>.json CAS-guarded, schema-1 manifest
+//
+// It exists so a credential can be scoped to ONE instance. #148's shape is
+// one instance per customer under a single app, and everything the platform
+// delivers today lands in a bundle every instance of the app renders, so a
+// per-customer secret delivered through the default bundle reaches every
+// other customer's process.
+//
+// Manifest.ExecDelivery is NOT read from this layer. That is the ruling at
+// appconfig.go's ExecDelivery doc comment, unchanged: a per-route delivery
+// flag would need a registry column, a record field and reconcile
+// convergence, and two routes resolving through the same bundle could
+// disagree about whether they are delivered. A per-host bundle is exactly
+// the granularity that ruling refused, so resolveExecDelivery is never
+// handed one of these manifests and a field that somehow appears in one is
+// inert.
+package appconfig
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// InstancesConfigPrefix is the list prefix behind "which instances of this
+// app have an overlay" — every instances-config/<host>.json key shares it.
+// Inside apps/<acct>/<app>/ on purpose: release.PurgeApp's prefix sweep
+// reaches it, so a purged app cannot leave an object full of sealed
+// secrets behind.
+func InstancesConfigPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "instances-config/"
+}
+
+// InstanceConfigKey is the bucket key of one instance's overlay. host is a
+// route's Host column verbatim, which is what makes the key derivable from
+// a registry row with nothing to look up.
+func InstanceConfigKey(acct, app, host string) string {
+ return InstancesConfigPrefix(acct, app) + host + ".json"
+}
+
+// instanceHostRe is a bare lowercase hostname: labels of letters, digits
+// and hyphens, joined by dots, never starting or ending with a hyphen or a
+// dot. Case-restricted like ValidEnvironmentName and for the same reason,
+// with one more: this string is also the file name sync derives on the box
+// (sync.go's filepath.Base guard), so one convention keeps a bucket key and
+// a file name from disagreeing about which host they belong to.
+var instanceHostRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
+
+// ValidInstanceHost reports whether host is usable as an instance overlay
+// key. A separator or a traversal is refused at the one gate every caller
+// passes through, because this string is concatenated into a bucket key: a
+// host of "../config" would otherwise name the app's default bundle.
+func ValidInstanceHost(host string) bool {
+ if host == "" || len(host) > 253 || strings.Contains(host, "..") {
+ return false
+ }
+ // Per-label bound as well as the whole-name one, matching DNS and
+ // launchctx/controlname's label rule. Without it a single 200-character
+ // label passes here and is refused everywhere else that handles the same
+ // host, which is a disagreement between two gates rather than one rule.
+ for _, label := range strings.Split(host, ".") {
+ if len(label) > 63 {
+ return false
+ }
+ }
+ return instanceHostRe.MatchString(host)
+}
+
+// LoadInstanceConfig reads one instance's overlay, returning the manifest,
+// the etag it was read at, and whether the object exists at all.
+//
+// The etag is the point of this signature. The delivery ack (design §6.5)
+// carries the etag the agent actually rendered, and the console compares it
+// with the one its own Put returned: that comparison is decidable, where a
+// map of delivered key names cannot tell an old agent apart from a new one
+// rendering a route with no overlay vars.
+//
+// Not-found is (nil, "", false, nil): an instance with no overlay is the
+// normal case and the state every instance is in until something writes one.
+func LoadInstanceConfig(ctx context.Context, s release.Store, acct, app, host string) (*Manifest, string, bool, error) {
+ if !release.ValidAccount(acct) {
+ return nil, "", false, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, "", false, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidInstanceHost(host) {
+ return nil, "", false, fmt.Errorf("config: invalid instance host %q", host)
+ }
+ return getIfExistsAtETag(ctx, s, InstanceConfigKey(acct, app, host))
+}
diff --git a/internal/appconfig/instances_test.go b/internal/appconfig/instances_test.go
new file mode 100644
index 000000000..3175460e9
--- /dev/null
+++ b/internal/appconfig/instances_test.go
@@ -0,0 +1,105 @@
+package appconfig
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putInstanceRaw writes an instance-layer object without going through the
+// package's own writer, which does not exist until task 2. Tests in this
+// file that only READ the layer use it, so task 1 can be verified on its
+// own.
+func putInstanceRaw(t *testing.T, ctx context.Context, s release.Store, acct, app, host string, m *Manifest) string {
+ t.Helper()
+ body, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ t.Fatalf("marshal instance manifest: %v", err)
+ }
+ etag, err := s.Put(ctx, InstanceConfigKey(acct, app, host), body, "")
+ if err != nil {
+ t.Fatalf("put instance manifest: %v", err)
+ }
+ return etag
+}
+
+// The layer sits under the app's own prefix, beside config.json and
+// environments/, so release.PurgeApp's prefix sweep reaches it and a purged
+// app leaves no instance overlay behind holding sealed secrets.
+func TestInstanceConfigKeysSitUnderTheAppsOwnPrefix(t *testing.T) {
+ if got, want := InstancesConfigPrefix("bab", "titogo"), "apps/bab/titogo/instances-config/"; got != want {
+ t.Fatalf("InstancesConfigPrefix = %q, want %q", got, want)
+ }
+ if got, want := InstanceConfigKey("bab", "titogo", "book.customer.example.com"),
+ "apps/bab/titogo/instances-config/book.customer.example.com.json"; got != want {
+ t.Fatalf("InstanceConfigKey = %q, want %q", got, want)
+ }
+}
+
+// A host becomes a bucket key segment here, so the one gate every caller
+// passes through is where a separator, a traversal or an empty name is
+// refused. The shape is a bare lowercase hostname, the same thing a route
+// row's Host column holds and the same thing sync.go's own path guard
+// (rt.Host != filepath.Base(rt.Host)) already insists on before it derives
+// a file name from one.
+func TestValidInstanceHostRefusesAnythingThatIsNotABareHost(t *testing.T) {
+ for _, bad := range []string{
+ "", ".", "..", "a/b", "../etc", "Book.example.com", "book..example.com",
+ "-lead.example.com", "trail-.example.com", "book.example.com/",
+ strings.Repeat("a", 254),
+ } {
+ if ValidInstanceHost(bad) {
+ t.Errorf("ValidInstanceHost(%q) = true", bad)
+ }
+ }
+ for _, good := range []string{"book.customer.example.com", "inst1", "a-b.c-d.example.com", "127-0-0-1.example.com"} {
+ if !ValidInstanceHost(good) {
+ t.Errorf("ValidInstanceHost(%q) = false", good)
+ }
+ }
+}
+
+// The reader answers three ways, and the caller needs all three apart: a
+// manifest and the etag it was read at, "no object" without an error (an
+// instance with no overlay is the normal case, not a fault), and an error
+// for anything else. The etag is what the delivery ack carries (design §6.5),
+// so a read that dropped it would leave the agent unable to say which
+// version of the layer it rendered.
+func TestLoadInstanceConfigReadsTheLayerAndItsETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+
+ m, etag, found, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com")
+ if err != nil || found || m != nil || etag != "" {
+ t.Fatalf("absent overlay = %+v %q %v %v, want (nil, \"\", false, nil)", m, etag, found, err)
+ }
+
+ want := putInstanceRaw(t, ctx, s, "bab", "titogo", "book.customer.example.com",
+ &Manifest{Schema: 1, App: "titogo", Vars: map[string]Var{"TENANT_SMTP_USER": {Kind: "env", Value: "book"}}})
+ m, etag, found, err = LoadInstanceConfig(ctx, s, "bab", "titogo", "book.customer.example.com")
+ if err != nil || !found || m == nil {
+ t.Fatalf("present overlay = %+v %v %v", m, found, err)
+ }
+ if etag == "" || etag != want {
+ t.Fatalf("etag = %q, want the object's own %q", etag, want)
+ }
+ if m.Vars["TENANT_SMTP_USER"].Value != "book" {
+ t.Fatalf("overlay lost its var: %+v", m.Vars)
+ }
+
+ // The same newer-schema refusal every other bundle read makes: acting on
+ // a manifest whose unknown fields json.Unmarshal has already dropped
+ // would silently discard whatever they meant.
+ putInstanceRaw(t, ctx, s, "bab", "titogo", "newer.example.com", &Manifest{Schema: 2, App: "titogo"})
+ if _, _, _, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "newer.example.com"); err == nil {
+ t.Fatal("a schema-2 overlay was accepted, want a refusal naming the upgrade")
+ }
+
+ // A host that is not a bare hostname never reaches the bucket at all.
+ if _, _, _, err := LoadInstanceConfig(ctx, s, "bab", "titogo", "../config"); err == nil {
+ t.Fatal("LoadInstanceConfig accepted a traversal host")
+ }
+}
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go
index de97f2217..815c99506 100644
--- a/internal/appconfig/store.go
+++ b/internal/appconfig/store.go
@@ -48,28 +48,39 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
// getIfExistsAt is getIfExists generalized to any manifest key — Key(acct,
// app) for the default bundle, EnvironmentKey(acct, app, name) for a named
-// one (see environments.go). Same shape either way: a config.json and a
-// named environment bundle are the identical manifest format at sibling
-// bucket paths, so one read path serves both.
+// one (see environments.go), InstanceConfigKey(acct, app, host) for an
+// instance overlay (instances.go). Same shape either way: all three are the
+// identical manifest format at sibling bucket paths, so one read path
+// serves them.
func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) {
- raw, _, err := s.Get(ctx, key)
+ m, _, found, err := getIfExistsAtETag(ctx, s, key)
+ return m, found, err
+}
+
+// getIfExistsAtETag is getIfExistsAt plus the object's etag, which the
+// instance layer's reader needs and the other two bundles' readers do not:
+// the delivery ack names the etag of the overlay the agent rendered
+// (senders design §6.5), and an etag dropped here could not be recovered
+// later without a second read racing the first.
+func getIfExistsAtETag(ctx context.Context, s release.Store, key string) (*Manifest, string, bool, error) {
+ raw, etag, err := s.Get(ctx, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
- return nil, false, nil
+ return nil, "", false, nil
}
- return nil, false, err
+ return nil, "", false, err
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, false, fmt.Errorf("config %s: %w", key, err)
+ return nil, "", false, fmt.Errorf("config %s: %w", key, err)
}
// json.Unmarshal has already dropped any fields this binary doesn't
// know about, so acting on a newer-schema manifest would silently
// discard whatever those fields meant. Refuse instead.
if m.Schema > 1 {
- return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
+ return nil, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
- return &m, true, nil
+ return &m, etag, true, nil
}
// getForWriteAt is Get plus the two bits a caller needs to write back: the