Every foundation port given at genesis became a per-node setting of the module
that binds it, except the package registry's: that one was fixed by rewriting
the builder's manifest when the installer registered it. Registering the builder
again from the catalogue undid it, and the forge's own module, when it took the
bootstrap forge over, came up on the catalogue's port — which on a machine where
a predecessor holds 3000 points the builder at the predecessor's forge.
So the rewrite is gone, and the port is recorded twice as a setting, both from
the one input:
- the forge's module is registered at genesis — not assigned, nothing of it runs
— so the controller has something to hold `{"ports": {"3000": <given>}}`
against. Assigning the forge later raises it on the port this machine was
given, and its container, its filter rule, its opening, what it serves and
what consumers are told all read it from there.
- the builder is given `{"serves": {"port": <given>}}`, which merges into the
binding it carries in place of one nothing can resolve yet.
A genesis on the catalogue's port records nothing and registers nothing, so it
does exactly what it did before.
novox/hq 04-ISSUES/085, ADR 0100
358 lines
16 KiB
Go
358 lines
16 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): the forge's own container, its
|
|
// filter rule, its opening, what it says it serves and what consumers are told all read it from
|
|
// there, so taking the bootstrap forge over does not move the registry back to the catalogue's
|
|
// port.
|
|
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: the controller will hold a setting
|
|
// against it. 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, and so does the address its consumers are told.
|
|
//
|
|
// 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
|
|
}
|
|
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.
|
|
if out, _ := run(asking, "docker", "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
|
|
}
|