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`, composed into its container from the
node's settings by a placeholder that names a seat, `${seat:mesh-store:5432}`,
and read on top of the sealed value by the store, the broker, the management API
and the bus connection. The answer is empty when the mesh has nothing to add,
so what genesis wrote stands until the node says otherwise.
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. 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
now come from one derivation, with 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):
|
|
}
|
|
}
|
|
}
|