Files
mesh-controller/internal/store/store.go
T
jschoubben 306c4ca13b The control plane, as far as identity
Tier 2 exists now. It holds one context of seven, inventory, and does one
thing with it: brings its schema up to date. That is step 3 of the substrate
bootstrap -- the step the first node cannot get past.

Verified against a real PostgreSQL, with the built binary: applied 0001-nodes,
reported 'already up to date' on the second run, and the node table is there
with the index and the unique constraint the migration asks for.

Written in Go, and the image is FROM scratch holding one file. Confirmed by
unpacking it. That is the whole argument of ADR 0024: the bundle pins this
image by digest and runs it where nothing can check it, so everything in it is
something a person has to audit before trusting a first node.

Exclusive store ownership is built as a rule about credentials rather than
about intentions. There is no mesh-wide connection setting and no way to ask
for one -- a context reads MESH_STORE_<ITS OWN NAME> and holds nothing else, so
reaching another context's store needs a new variable, which is visible in the
declaration that runs it.

The migration runner is mostly refusals: an edited migration that already ran,
a migration numbered below one that has run, duplicate numbers, misnamed files,
empty files. All stop rather than warn, because at the moment any of them is
true nobody knows what the database holds.

It stops before identity, deliberately. What a node presents to prove who it is
has not been decided anywhere, and a migration is the most expensive place in
this system to guess.

Two tests did not defend what they claimed, and both are fixed rather than
removed. One asked only whether Open returned an error, which it did either way
-- a bad context name and a missing credential both fail, so deleting the name
check changed nothing. The other claimed to prove the migration runs in a
transaction, but PostgreSQL already wraps a multi-statement query in one of its
own, so it passed with the transaction taken out. What the transaction actually
buys is that the schema change and the row recording it commit together, and
there is now a test for that which fails when they are split.
2026-08-29 02:44:09 +02:00

136 lines
5.0 KiB
Go

// Package store is how a context reaches the database it exclusively owns.
//
// novox/hq ADR 0008: a context is granted only what it exclusively owns — no shared writes, no
// read-only role on another context's store. That is a rule about credentials, so this package
// makes it a rule about credentials rather than a rule about intentions.
//
// There is no mesh-wide connection string and no way to ask for one. A store is opened by naming
// a context, and the settings for that context come from an environment variable named after it.
// A control plane process that has been granted `inventory` holds MESH_STORE_INVENTORY and
// nothing else, so reaching another context's store is not a matter of restraint — the process
// has no address for it and no credential to present.
//
// Which is also how the rule is *checked*: what a context can reach is visible in the
// declaration that runs it, as the list of variables it was given.
package store
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// contextName is what a context may be called.
//
// Constrained because the name becomes part of an environment variable and part of a database
// name, and a name that is valid in one and not the other is a fault discovered at bootstrap on
// a machine with no mesh on it.
var contextName = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
// Store is one context's database.
type Store struct {
context string
pool *pgxpool.Pool
}
// Variable is the environment variable holding a context's connection settings.
//
// Exported because the bootstrap has to set it and the declaration has to name it, and both
// should read it from here rather than spell it out again.
func Variable(context string) string {
return "MESH_STORE_" + strings.ToUpper(context)
}
// Database is what a context's database is called.
//
// Named after the context, so that a person looking at a PostgreSQL server can see which
// contexts exist without a map. There is deliberately no database named for the mesh as a whole:
// novox/hq ADR 0006 records that *the mesh database* names a thing that will not exist.
func Database(context string) string { return context }
// Open connects to the database a context owns.
//
// The settings are read from the environment rather than passed in, which is not indirection for
// its own sake: it means no caller anywhere can hand a context a connection to something else.
func Open(ctx context.Context, name string) (*Store, error) {
if !contextName.MatchString(name) {
return nil, fmt.Errorf(
"%q is not a usable context name: it becomes an environment variable and a database "+
"name, so it must be lower-case letters and digits, starting with a letter", name)
}
dsn := os.Getenv(Variable(name))
if strings.TrimSpace(dsn) == "" {
return nil, fmt.Errorf(
"this process has no %s, so it was not granted the %s store. A context reaches only "+
"the store it exclusively owns (novox/hq ADR 0008), so this is either the wrong "+
"context or a missing grant — it is never something to work around by reusing "+
"another context's connection", Variable(name), name)
}
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
// Deliberately not wrapping the driver's error verbatim into a message that gets logged:
// a malformed DSN often *is* the password, and the value is the one thing here that must
// not be quoted back.
return nil, fmt.Errorf(
"the connection settings in %s could not be read; the value is not quoted here "+
"because it carries a password", Variable(name))
}
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("cannot open the %s store: %w", name, err)
}
return &Store{context: name, pool: pool}, nil
}
// Context is which context this store belongs to.
func (s *Store) Context() string { return s.context }
// Pool is the connection pool, for the context that owns it.
func (s *Store) Pool() *pgxpool.Pool { return s.pool }
// Close releases the connections.
func (s *Store) Close() {
if s.pool != nil {
s.pool.Close()
}
}
// Ready waits until the database answers, or gives up.
//
// A read-back rather than a connect: pgxpool connects lazily, so a Store that was opened without
// error proves only that a string parsed. The bootstrap raises PostgreSQL and the control plane
// moments later, and "the container is running" is not "the database will answer" — that
// distinction has already cost a debugging session on this project once.
func (s *Store) Ready(ctx context.Context, within time.Duration) error {
deadline := time.Now().Add(within)
var last error
for {
err := s.pool.Ping(ctx)
if err == nil {
return nil
}
last = err
if ctx.Err() != nil {
return errors.Join(ctx.Err(), last)
}
if time.Now().After(deadline) {
return fmt.Errorf(
"the %s store did not answer within %s: %w", s.context, within, last)
}
select {
case <-ctx.Done():
return errors.Join(ctx.Err(), last)
case <-time.After(250 * time.Millisecond):
}
}
}