Files
mesh-host/internal/bootstrap/phase_packages.go
jschoubben 5dd439df50 inspect by kind, not the ambiguous bare form — a same-named network stops a container from ever being found
docker inspect <name> resolves across every object kind, not just
containers. A module regularly names a network the same as the
container that joins it (keycloak does this today, ordinarily) — so
when the container does not exist yet but the same-named network
already does, the bare form answers with the network's JSON instead
of reporting the container absent, and the template these callers use
(.State.Running) fails to execute against it entirely.

Live on novox tonight: minio's LB container, named the same as its
network ("minio"), could never be created — every apply crashed on
"the container runtime could not say whether minio is here", stuck
since first push, because the check itself never got a clean answer.

Fixed at every call site asking a container's state by name
(containerState, inspectFound, NamesFree, raiseGiteaServer,
containerRunning) by scoping to `docker container inspect`, matching
the type-scoped form this codebase already uses correctly for
networks, volumes and images elsewhere. Also scoped the one image
inspect that was still bare (publish.go), for the same reason.

mesh-host runs as a host-level service (nox-mesh-host.service), not a
Docker module — merging this does not redeploy it. The live novox
failure persists until the service itself is rebuilt and updated.
2026-09-24 19:50:16 +02:00

380 lines
18 KiB
Go

package bootstrap
import (
"context"
"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.
giteaBootstrap = "mesh-gitea-server"
// giteaImage is the same upstream image the gitea module runs, pinned identically so the module
// adopts the running server rather than replacing it.
giteaImage = "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c"
// 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 builder publishes and pulls with at genesis. It is
// the `as` the builder's static package binding names.
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(" delivering the builder its registry credential")
if err := deliverBuilderNpm(ctx, o, control, 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, the
// broker's management port 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.
"--network", "host",
"--restart", "unless-stopped",
}, 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
}
// deliverBuilderNpm seals the builder's registry password to this node as its `npm-password`
// own-secret, 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 builder reads it.
func deliverBuilderNpm(ctx context.Context, o Options, control controlPlane, password string,
say func(string)) error {
at := "/accepting-npm-password"
if err := control.carryingSecret(ctx, "mesh-accepting-npm-password", []byte(password), at); err != nil {
return err
}
if _, err := control.tell(ctx, "secret", "accept", o.Node, BuilderModule, "npm-password", "--from", at); err != nil {
return err
}
// Push so the sealed secret reaches the builder, which restarts on it and comes back credentialed.
if _, err := control.tell(ctx, "push", o.Node); err != nil {
return err
}
return nil
}
// PublishTheSDK dispatches a build of the SDK to the builder. The builder has its registry
// credential by now, 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
}