Files
jschoubben e07b56ce43 An address is read from the node's settings where it is used, never recorded with a port
Three readers did not follow a moved foundation port (novox/hq 04-ISSUES/102),
and each took the control-node down in its own way: the control plane's own
store and broker connections, sealed at genesis with the port inside; and every
build the mesh ever recorded, kept as `<registry>:<port>/<module>/<artifact>@…`.

The control plane cannot open its own sealed connections to move a port, and it
cannot bind the store as a consumer would — a binding mints a credential. So its
settings get a third twin, `NAME_PORT`, read on top of the sealed value by the
store, the broker, the management API and the bus connection, and filled into
its container by a placeholder that names a seat, `${seat:mesh-store:5432}`,
from the node's given or mesh-assigned ports — never the manifest's number, and
empty when the mesh has nothing to add, so what genesis wrote stands. A value
that is still a placeholder is nothing said, aloud: the manifest naming it lands
in the next commit, once every control plane that composes it knows it.

A build is now recorded by digest and path — `artifact-store://<module>/<artifact>@…`
— and the store's address is composed in where a reference is used: the
declaration, the trust file, the bases a build is handed, a replay to the
catalogue. Over the network as `<node>.internal:<port>`; on the store's own node
before any network exists — every genesis push before its "network" step — by
loopback. A reference recorded before this, with an address, is re-routed the
same way when the mesh built it. The trust file and every provider's address
come from one derivation: the node's given port, over the mesh's assignment,
over the manifest's number.

novox/hq 04-ISSUES/102
2026-09-23 23:49:31 +02:00

250 lines
11 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"
"github.com/novox/mesh-controller/internal/envfile"
)
// 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" }
// PortVariable is the environment variable naming the PORT this machine put the store at — the
// third twin, beside the value and the file.
//
// **The connection string is sealed and the port inside it is genesis's** (novox/hq 04-ISSUES/102).
// The control plane cannot open its own store secret to move the port, and a node's settings can
// move the store (ADR 0100: the foundation's ports are the node's). Every consumer's binding
// followed that setting; the control plane's own connection did not, and the mesh was headless. So
// the port is composed into the control plane's environment from the node's settings, exactly as a
// consumer's binding is, and the connection string's own port stands only when this says nothing.
func PortVariable(context string) string { return envfile.PortVar(Variable(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, from, err := placed(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
}
// placed is a context's connection string with its port moved to where this machine put the
// store, when the node's settings say so (PortVariable), and as it was otherwise.
func placed(name string) (dsn, from string, err error) {
dsn, from, err = settingsFor(name)
if err != nil {
return "", "", err
}
port, err := envfile.Port(Variable(name))
if err != nil || port == "" {
return dsn, from, err
}
moved, err := envfile.WithPort(dsn, port)
if err != nil {
// The variable and the port are named; the connection string is not, for the same reason
// as everywhere else in this file.
return "", "", fmt.Errorf(
"%s says this machine put the %s store on port %s, and the connection settings in %s "+
"could not be read as something with a port in them: %w",
PortVariable(name), name, port, from, err)
}
return moved, from + ", on port " + port + " as " + PortVariable(name) + " says", 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):
}
}
}