Files
mesh-host/internal/bootstrap/phase_packages.go
T
jochen 7283209d1a
mesh/merge-gate head 7283209d1abc merged into trunk 64f0ab405101 — pass: the change touches no module of the mesh's graph
mesh/repo-check head 7283209d1abc merged into trunk 64f0ab405101 — pass: its merge-check.sh passed
mesh/delivery delivered
Place the bus's users after every push until the bus is adopted (hq issue 608)
The bus reads its user list from a file that, until the broker step adopts it as the nats module,
only the installer writes, and the installer wrote it only at enrolment. The build agent's account,
minted with step 13's push, never reached the bus; the agent was refused (Authorization Violation)
and the sdk step's build waited until it was killed. Every push genesis makes now places the composed
list and has the bus re-read it, until the nats module is registered (ADR 0331 §5, ADR 0333 §4).
2026-10-13 06:58:39 +02:00

435 lines
21 KiB
Go

package bootstrap
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Raising the package registry, before the base is built.
//
// The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than
// cloning it from a git URL (novox/hq ADR 0076, issue 053). So the registry has to answer, and the
// SDK has to be in it, before the base build runs. That is a pivot like the control plane's: gitea's
// SERVER is raised directly here, on the foundation's own postgres, and adopted as an ordinary module
// only after the base exists (which is what lets its provisioner image — built on the base — run).
//
// Nothing here is the steady state. It is the smallest set of acts that puts a working npm registry
// in front of the base build: a database, a server, an admin, one org, the builder's own account,
// and the SDK published under it. The gitea MODULE, installed after the base, takes all of this over.
const (
// foundationStore is the foundation's postgres container — the mesh's own memory, raised from the
// bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051).
foundationStore = "mesh-store"
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module —
// under the name the gitea MODULE declares for its container, so the module finds it and holds
// it rather than raising a second forge beside it (novox/hq ADR 0163, rule 7; issue 090).
giteaBootstrap = "gitea"
// giteaImage is the image the gitea module declares for that container, pinned to the same
// digest, so taking the module over is not a downgrade and not an upgrade. **Moves with the
// module's pin**: the two are compared by a take, and a difference is said there — but a
// genesis that raised an older image than the module declares would be taken over as an
// upgrade on first push, which a forge holding the mesh's packages must not have done to it
// unannounced. Checked in TestGenesisRaisesTheForgeAsTheModuleDeclaresIt against the module's
// manifest where the catalogue is beside this checkout.
giteaImage = "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
// giteaDataDir is where the module's `data` directory resolves on a machine with the default
// layout (<data root>/<module>/<id>, novox/hq ADR 0112): mounted at /data as the module mounts
// it, so the repositories, attachments and indexes the bootstrap forge accumulates are the
// module's the day it is taken — before this, the forge had no volume and its data was the
// container's, lost with it.
giteaDataDir = "/var/lib/gitea/data"
// packagesOrg is the npm owner: every module consumes `@novox/*` from this gitea org.
packagesOrg = "novox"
// packagesTeam is the org team whose members may read and write the org's packages.
packagesTeam = "packages"
// giteaAdminUser is the admin the bootstrap creates and the provisioner later authenticates as.
giteaAdminUser = "mesh-admin"
// builderGiteaUser is the gitea account the build agent publishes and pulls with at genesis: the
// `as` of the genesis document (novox/hq ADR 0336 §3). Retiring it is issue 602.
builderGiteaUser = "mesh-builder"
// giteaDBRole/giteaDBName is gitea's own database in the foundation store.
giteaDBRole = "mesh_gitea"
giteaDBName = "mesh_gitea"
// defaultGiteaPort is where the raised server answers on the machine unless the node gave the
// package registry another port (novox/hq ADR 0100).
defaultGiteaPort = 3000
)
// ForgeModule is the module that owns the package registry — the one the bootstrap forge above is
// a stand-in for, and which takes it over once the base exists.
//
// Named here because the port given for the package registry is that module's setting on this node
// and not this installer's private number (novox/hq 04-ISSUES/085). What follows that setting is
// what the mesh derives from a module's ports: the forge's container mapping, its rule in the
// filter, its opening on an adopted node, and what it says it serves — so a consumer the mesh binds
// is told where the machine actually put the registry.
//
// What does NOT follow it, and is not this change's to fix: the address of itself the forge's
// runtime is given, which the catalogue writes as a literal (novox/hq 04-ISSUES/088), and the port
// in its route contribution. Both are already wrong for any machine port the mesh assigned, with a
// given port or without one.
const ForgeModule = "gitea"
// RaisePackageRegistry puts a working npm registry in front of the base build. It is idempotent:
// every step tolerates having been done, because genesis is safe to run again.
func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control controlPlane,
say func(string)) error {
run := d.Run
// The passwords this pivot mints, kept once so a re-run is the same run: gitea already holds
// them, so regenerating would lock the mesh out of the forge it just raised.
dbPassword, adminPassword, builderPassword, err := packagePasswords()
if err != nil {
return err
}
say(" seeding gitea's database in the foundation store")
ports := o.Ports.orDefaults()
if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil {
return err
}
say(" raising the gitea server on that database")
if err := raiseGiteaServer(ctx, run, o.Timeout, dbPassword, ports, say); err != nil {
return err
}
say(" waiting for gitea to answer")
base := fmt.Sprintf("http://127.0.0.1:%d", ports.Packages)
if err := waitForGitea(ctx, d, o, base, say); err != nil {
return err
}
say(" creating the gitea admin")
if err := createGiteaAdmin(ctx, run, o.Timeout, adminPassword, say); err != nil {
return err
}
admin := &giteaAdmin{base: base, user: giteaAdminUser, password: adminPassword,
client: &http.Client{Timeout: o.Timeout}}
say(" ensuring the npm org, its package team, and the builder's account")
if err := admin.ensureOrg(ctx, packagesOrg); err != nil {
return err
}
teamID, err := admin.ensureTeam(ctx, packagesOrg, packagesTeam)
if err != nil {
return err
}
if err := admin.ensureUser(ctx, builderGiteaUser, builderPassword); err != nil {
return err
}
if err := admin.addToTeam(ctx, teamID, builderGiteaUser); err != nil {
return err
}
say(" recording the port it was given as the forge module's own")
if err := recordTheForgesPort(ctx, o, control, say); err != nil {
return err
}
say(" sealing the build agent the genesis document for the forge it raised")
if err := deliverGenesisRegistry(ctx, o, control, genesisRegistry(ports, builderPassword), say); err != nil {
return err
}
return nil
}
// recordTheForgesPort makes the port given for the package registry a setting of the module that
// serves it, on this node (novox/hq 04-ISSUES/085, ADR 0100).
//
// **The forge is the one foundation port that was not a setting.** The store's, the bus's and the
// registry's each become a `ports` setting of the module that
// binds them, set where that module is registered and assigned; the forge is raised by hand here,
// long before its module can be built, so there was no registration to hang it on and the number
// lived in rewritten manifest text instead. So the manifest is registered here — not assigned, and
// nothing of it runs — for the one thing registering buys: a settings row needs the module row to
// exist. Whenever somebody later assigns the forge on this node, it comes up on the port this
// machine was given rather than on the catalogue's.
//
// **Registered only when the mesh does not already know it, which is the opposite of every other
// `module add` here.** The rest of the installer registers every run on purpose: the manifest is
// what changes between runs, and skipping it would pin the mesh to a previous image. This one
// changes nothing about the forge and wants nothing of the checkout's manifest — and registering
// is an overwrite, so a re-run of genesis pointed at an older catalogue would replace the manifest
// of a forge that is built and assigned, with a push a few lines later. The port is the only thing
// this is here to record, and the setting does not need the manifest to be this checkout's.
//
// A node given the catalogue's own port records nothing and registers nothing, so a genesis on the
// defaults does exactly what it did before.
func recordTheForgesPort(ctx context.Context, o Options, control controlPlane,
say func(string)) error {
if o.Ports.orDefaults().Packages == DefaultPorts().Packages {
return nil
}
known, err := control.tell(ctx, "module", "list")
if err != nil {
return err
}
if mentions(known, ForgeModule) {
say(" known " + ForgeModule + " — left as the mesh has it; only its port is set here")
} else {
manifest, err := readManifest(o.Catalogue, ForgeModule)
if err != nil {
return fmt.Errorf("%w\n"+
"This is the module that owns the forge genesis just raised. Without it the port this "+
"machine was given for the package registry is nobody's setting, and taking the forge "+
"over would put it back on the catalogue's port", err)
}
remote := "/" + ForgeModule + "-module.json"
if err := control.carrying(ctx, ForgeModule+"-module.json", manifest, remote); err != nil {
return err
}
if _, err := control.tell(ctx, "module", "add", remote); err != nil {
return err
}
say(" registered " + ForgeModule + " — registered, not assigned: nothing of it runs yet")
}
return setFoundationSettings(ctx, o, control, ForgeModule, say)
}
// seedGiteaDatabase creates gitea's role and database inside the foundation postgres, the same way
// the foundation creates its own — psql run through the store container (the map's Route B). The role
// is created before the database because the database is owned by it. Both are tolerant of already
// existing, so a re-run changes nothing.
func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, password string,
say func(string)) error {
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Single statements through psql -c, not one script: CREATE DATABASE cannot run in a
// transaction and \gexec does not parse through -c. The password is base64url, so it carries no
// quote or backslash to escape inside a SQL literal.
psql := func(sql string) (string, error) {
return run(asking, "docker", "exec", foundationStore, "psql", "-U", "postgres", "-tAc", sql)
}
// The role: create it, and if it is already there (create fails) reset its password so a re-run
// converges on this run's credential.
create := fmt.Sprintf("CREATE ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password)
if _, err := psql(create); err != nil {
alter := fmt.Sprintf("ALTER ROLE %s LOGIN PASSWORD '%s'", giteaDBRole, password)
if _, err := psql(alter); err != nil {
return fmt.Errorf("could not create gitea's role in %s: %w", foundationStore, err)
}
}
// The database: created only if absent, because CREATE DATABASE has no IF NOT EXISTS and a
// second create is an error rather than a no-op.
present, err := psql(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", giteaDBName))
if err != nil {
return fmt.Errorf("could not check for gitea's database in %s: %w", foundationStore, err)
}
if strings.TrimSpace(present) != "1" {
if _, err := psql(fmt.Sprintf("CREATE DATABASE %s OWNER %s", giteaDBName, giteaDBRole)); err != nil {
return fmt.Errorf("could not create gitea's database in %s: %w", foundationStore, err)
}
}
return nil
}
// raiseGiteaServer starts the gitea server container against the foundation store. It runs on the
// machine's own network, so `127.0.0.1` reaches the store where it publishes its port, and it binds
// its own port there for the builder and this installer. Started if absent, left alone if present.
func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, dbPassword string,
ports FoundationPorts, say func(string)) error {
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Already there: a re-run does not raise a second one. `docker start` is a no-op on a running
// container and revives a stopped one. `container inspect`, not the bare form: a name is not
// unique across object kinds, and `.Id` resolves on a network or volume too.
if out, _ := run(asking, "docker", "container", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
_, _ = run(asking, "docker", "start", giteaBootstrap)
return nil
}
env := []string{
"-e", "GITEA__database__DB_TYPE=postgres",
// The store is reached on the shared network namespace's loopback.
"-e", fmt.Sprintf("GITEA__database__HOST=127.0.0.1:%d", ports.Store),
"-e", "GITEA__database__NAME=" + giteaDBName,
"-e", "GITEA__database__USER=" + giteaDBRole,
"-e", "GITEA__database__PASSWD=" + dbPassword,
// Skip the install wizard: the mesh configures gitea, not a person at a browser.
"-e", "GITEA__security__INSTALL_LOCK=true",
// The forge answers on the machine's loopback, and its own links must say so: gitea's
// package metadata hands npm a tarball URL built from ROOT_URL, and a client only sends its
// stored credential to the host it was stored for. A default ROOT_URL of localhost is a
// different host than the binding's 127.0.0.1, so the credential would not be sent.
"-e", fmt.Sprintf("GITEA__server__ROOT_URL=http://127.0.0.1:%d/", ports.Packages),
"-e", "USER_UID=1000", "-e", "USER_GID=1000",
}
if ports.Packages != defaultGiteaPort {
// On the machine's network the server binds its own port, so a port given for it is
// the one it is told to listen on.
env = append(env, "-e", fmt.Sprintf("GITEA__server__HTTP_PORT=%d", ports.Packages))
}
args := append([]string{
"run", "-d", "--name", giteaBootstrap,
// Host network, like the control plane: it reaches the foundation store on the machine's
// loopback (where the store publishes 5432) and answers on the machine's own 3000, which is
// where mesh-bootstrap and the builder's build containers look for it. The module runs
// bridged and publishes its ports; that is the one difference a take still has to say
// (ADR 0163, rule 7) — the data, the name and the image are the module's already.
"--network", "host",
"--restart", "unless-stopped",
// The module's data directory, so what the forge accumulates is the module's when taken.
"--volume", giteaDataDir + ":/data",
}, env...)
args = append(args, giteaImage)
if _, err := run(asking, "docker", args...); err != nil {
return fmt.Errorf("could not raise the gitea server: %w", err)
}
return nil
}
// waitForGitea polls gitea's version endpoint until it answers or the wait runs out. A container
// that is up is not a forge that serves; `/api/v1/version` is the question whose answer means it is.
func waitForGitea(ctx context.Context, d Deps, o Options, base string, say func(string)) error {
deadline := time.Now().Add(o.Wait)
url := base + "/api/v1/version"
for {
status, _, err := d.Fetch(ctx, url)
if err == nil && status == http.StatusOK {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("gitea did not answer at %s within %s", url, o.Wait)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
// createGiteaAdmin creates the mesh's gitea admin through the server's own CLI. Tolerant of the
// admin already existing, because a re-run must not fail on it — and it resets the password every
// run, so a rotated admin secret takes.
func createGiteaAdmin(ctx context.Context, run Runner, timeout time.Duration, password string,
say func(string)) error {
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Create once, with the password kept across re-runs, and do not follow with change-password:
// change-password re-enables must-change-password, which then refuses every API call as
// "you must change your password". Tolerant of "already exists", because the kept password
// means the existing admin is already the one this run authenticates as.
create := fmt.Sprintf(
"gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false",
giteaAdminUser, giteaAdminUser, password)
if _, err := run(asking, "docker", "exec", "-u", "git", giteaBootstrap,
"sh", "-c", create+" || true"); err != nil {
return fmt.Errorf("could not create the gitea admin: %w", err)
}
return nil
}
// packagePasswords loads the pivot's three passwords, minting and keeping them the first time. Kept
// on the machine because gitea, once raised, holds them: a second genesis that minted fresh ones
// would raise a forge it cannot then log into.
func packagePasswords() (db, admin, builder string, err error) {
const dir = "/var/lib/mesh/packages"
const file = dir + "/bootstrap.env"
if raw, e := os.ReadFile(file); e == nil {
vals := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
vals[k] = v
}
}
if vals["DB"] != "" && vals["ADMIN"] != "" && vals["BUILDER"] != "" {
return vals["DB"], vals["ADMIN"], vals["BUILDER"], nil
}
}
db, admin, builder = newPassword(), newPassword(), newPassword()
if err = os.MkdirAll(dir, 0o700); err != nil {
return "", "", "", err
}
content := fmt.Sprintf("DB=%s\nADMIN=%s\nBUILDER=%s\n", db, admin, builder)
if err = os.WriteFile(file, []byte(content), 0o600); err != nil {
return "", "", "", err
}
return db, admin, builder, nil
}
// GenesisRegistrySecret is the build agent's own-secret the genesis document is sealed as (novox/hq
// ADR 0336 §3). The build-agent manifest declares it, issued outside the mesh: only this installer
// gives one, on genesis's node.
const GenesisRegistrySecret = "genesis-package-registry"
// GenesisRegistry is the genesis document (novox/hq ADR 0336 §3): the forge this step raised, the port
// this node gave the package registry, its npm path, and the account this step made with its password.
// mesh-controller's cmd/mesh-builder reads it under the same field names while the build agent's
// npm-package-registry requirement is unbound, and prefers the binding once the gitea module is
// assigned and it binds.
type GenesisRegistry struct {
Scheme string `json:"scheme"`
At string `json:"at"`
Port int `json:"port"`
NpmPath string `json:"npm-path"`
As string `json:"as"`
Password string `json:"password"`
}
// genesisRegistry is the document for the forge raised here: on the machine's own loopback, where it
// answers on the host network and where its ROOT_URL says it is (raiseGiteaServer), on the port this
// node gave the package registry.
func genesisRegistry(ports FoundationPorts, password string) GenesisRegistry {
return GenesisRegistry{Scheme: "http", At: "127.0.0.1", Port: ports.Packages,
NpmPath: "/api/packages/" + packagesOrg + "/npm/", As: builderGiteaUser, Password: password}
}
// deliverGenesisRegistry seals the genesis document to the build agent on this node as its
// `genesis-package-registry` own-secret (novox/hq ADR 0336 §3), the same way the control plane's store
// connections are delivered — carry the value in, `secret accept`, and the next push writes it sealed
// where the build agent reads it.
//
// **The build agent's one way to the forge until the registry binds.** Its npm-package-registry
// requirement is optional (ADR 0336 §1), and nothing provides it until the gitea module is assigned
// after the base, so the sdk step (15) publishes and the base step (16) resolves with the account in
// this document. It replaces the retired builder's `npm-password` own-secret and the `serves.port`
// setting its carried binding took, neither of which the build agent declares.
func deliverGenesisRegistry(ctx context.Context, o Options, control controlPlane, doc GenesisRegistry,
say func(string)) error {
raw, err := json.Marshal(doc)
if err != nil {
return err
}
at := "/accepting-" + GenesisRegistrySecret
if err := control.carryingSecret(ctx, "mesh-accepting-"+GenesisRegistrySecret, raw, at); err != nil {
return err
}
if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, GenesisRegistrySecret, "--from", at); err != nil {
return err
}
// Push so the sealed document reaches the build agent, which reads it at its next build — and,
// like every push before the adoption, the bus hears the users it carries (issue 608).
_, err = pushNode(ctx, o, control, say)
return err
}
// PublishTheSDK dispatches a build of the SDK to the build agent. The agent has the genesis document by
// now (novox/hq ADR 0336 §7), so the build's `npm publish` authenticates; the artifact is a `package`, built
// on a public base, so this needs no toolchain — which is the whole point of doing it before the base.
func PublishTheSDK(ctx context.Context, o Options, control controlPlane, say func(string)) error {
if o.SDKSource.Repository == "" {
return fmt.Errorf("raising the registry needs --sdk-source: the SDK is built from its own " +
"repository, and an installer told nothing cannot know where that is")
}
say(" publishing " + o.SDKSource.Repository + " at " + refOr(o.SDKSource.Ref))
_, err := control.within(buildWait).tell(ctx,
"build", o.SDKSource.Repository, "--ref", refOr(o.SDKSource.Ref), "--wait", "1200s")
return err
}