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
214 lines
9.2 KiB
Go
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):
|
|
}
|
|
}
|
|
}
|