Files
mesh-controller/internal/store/store.go
T
jschoubben 522d8be925 Read a context's store connection from a file, not only from the environment
A store connection string carries a password, and the control plane took it
from MESH_STORE_<CONTEXT> — an environment variable, which is readable in
`docker inspect`, in the process's own /proc entry, and in whatever composed
it. Every other module in the catalogue is given secret material as a file the
mesh sealed to the machine and the host wrote.

That difference is what stopped the control plane from being an ordinary module
(novox/hq ADR 0067). A manifest can put a sealed value into a file's `content`
with ${secret:…}; it has no substitution into a container's `env` at all. So a
control-plane manifest could be written with the password in it, or without the
setting — neither honest. The fix is not to change the manifest format but to
let the control plane read what everything else reads: a file.

MESH_STORE_<CONTEXT>_FILE names one. Exactly one of the two may be set; both is
refused rather than settled by precedence, because whichever won, the other
would still read as the setting in force and the process would be writing to a
store nobody expects. Trailing whitespace is trimmed — a file written by a
person or by a filled-in placeholder ends in a newline, and a newline inside a
URL is rejected several layers from anything that could explain it. Leading
whitespace is left, being a mangled value rather than a habit.

The no-leak property is kept and extended: a file that cannot be read names its
path, never its contents, and the parse failure now names whichever source was
used because a variable name and a path are not the secret.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 23:40:37 +02:00

214 lines
9.2 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,
// or from a file that variable's `_FILE` twin points at. A control plane process that has been
// granted `inventory` holds MESH_STORE_INVENTORY or MESH_STORE_INVENTORY_FILE 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 and files 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)
}
// FileVariable is the environment variable naming a FILE that holds a context's connection
// settings — the same value, kept out of the environment.
//
// **Because the connection string carries a password, and an environment variable is a poor place
// to keep one.** It is readable in `docker inspect`, in the process's own /proc entry, and in
// whatever composed it. A file is not: the mesh seals the value to the machine, the host writes it
// with a mode of its own, and nothing in between ever holds the plaintext (novox/hq ADR 0024).
// That is already how every other module in the catalogue is given secret material.
//
// It is also what lets the control plane be described by an ordinary module manifest at all
// (novox/hq ADR 0067). A manifest can put a sealed value into a *file's* content; it cannot put one
// into a container's environment, so a manifest for a process that reads its store connection from
// the environment could not be written honestly — only with the password in it, or without the
// setting at all.
//
// The plain variable remains, for a control plane a person starts by hand and for the bundle that
// raises the first one.
func FileVariable(context string) string { return Variable(context) + "_FILE" }
// 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, from, err := settingsFor(name)
if err != nil {
return nil, err
}
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. Where it came from can be said, because a variable name and a path
// are not the secret.
return nil, fmt.Errorf(
"the connection settings in %s could not be read; the value is not quoted here "+
"because it carries a password", from)
}
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
}
// settingsFor is a context's connection string, and the name of where it came from.
//
// **Where it came from is returned; what it says never is.** A variable name and a path are safe
// to put in a log and are exactly what somebody fixing this needs. The value is a password with a
// URL around it, and every error below is written on the assumption that it will be logged.
//
// A blank variable counts as unset, here as it always has: a container given an empty string was
// given nothing, and refusing it as *ambiguous* would turn an omission into a puzzle.
func settingsFor(name string) (dsn, from string, err error) {
direct := strings.TrimSpace(os.Getenv(Variable(name)))
path := strings.TrimSpace(os.Getenv(FileVariable(name)))
if direct != "" && path != "" {
// **Refused rather than settled by precedence.** The two can name different databases, and
// whichever one won, the other would still read as the setting in force — so the process
// would be writing to a store nobody reading its configuration expects, and every check
// would pass. Which one is meant is not a question this can answer, and answering it wrongly
// is worse than stopping.
return "", "", fmt.Errorf(
"this process has both %s and %s, and they may name different databases. Exactly one "+
"of them says where the %s store's connection settings come from — unset whichever "+
"is not the one you meant; picking one here would leave the other looking like the "+
"one in force",
Variable(name), FileVariable(name), name)
}
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
// The path may be named; what is in it may not — and a file that could not be read has
// disclosed nothing, so there is nothing here to withhold.
return "", "", fmt.Errorf(
"%s names %s as where the %s store's connection settings are, and it cannot be "+
"read: %w", FileVariable(name), path, name, err)
}
// Trailing whitespace goes; leading whitespace stays. A file written by a person, or by the
// host filling a ${secret:…} placeholder into one, ordinarily ends in a newline, and a
// newline inside a URL is rejected several layers from anything that could explain it.
// Nothing produces a leading space by accident, so one is a mangled value rather than a
// formatting habit, and quietly repairing it would hide that.
held := strings.TrimRight(string(raw), " \t\r\n")
if held == "" {
return "", "", fmt.Errorf(
"%s names %s as where the %s store's connection settings are, and that file holds "+
"nothing. Write the connection string into it, or unset %s and set %s instead",
FileVariable(name), path, name, FileVariable(name), Variable(name))
}
return held, fmt.Sprintf("%s (%s)", FileVariable(name), path), nil
}
if direct == "" {
return "", "", fmt.Errorf(
"this process has neither %s nor %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), FileVariable(name), name)
}
return direct, Variable(name), 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):
}
}
}