// 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): } } }