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
250 lines
11 KiB
Go
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):
|
|
}
|
|
}
|
|
}
|