Genesis raises the package registry before it builds the base
The base (mesh-tools) resolves the SDK by version from the mesh's package registry rather than cloning it from a git URL (hq ADR 0076, issue 053), so the registry has to answer and the SDK has to be in it before the base build runs. New steps, before base: seed gitea's database in the substrate store, raise gitea's server on it, create the admin/org/team and the builder's account, seal the builder its registry credential, and publish the SDK on a public base. gitea is adopted as an ordinary module after the base, so its provisioner image can be built. A minimal Go gitea admin client stands in until that module exists. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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 substrate'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 (
|
||||
// substrateStore is the substrate'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).
|
||||
substrateStore = "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 substrate store.
|
||||
giteaDBRole = "mesh_gitea"
|
||||
giteaDBName = "mesh_gitea"
|
||||
// giteaPort is where the raised server answers on the machine.
|
||||
giteaPort = 3000
|
||||
)
|
||||
|
||||
// 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 the mesh mints for this pivot. gitea's database password and its admin password
|
||||
// are the mesh's, generated here; the builder's is generated and also becomes its own-secret.
|
||||
dbPassword := newPassword()
|
||||
adminPassword := newPassword()
|
||||
builderPassword := newPassword()
|
||||
|
||||
say(" seeding gitea's database in the substrate store")
|
||||
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, say); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
say(" waiting for gitea to answer")
|
||||
base := fmt.Sprintf("http://127.0.0.1:%d", giteaPort)
|
||||
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(" delivering the builder its registry credential")
|
||||
if err := deliverBuilderNpm(ctx, o, control, builderPassword, say); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedGiteaDatabase creates gitea's role and database inside the substrate postgres, the same way
|
||||
// the substrate 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()
|
||||
|
||||
// A single transaction-free script: CREATE ROLE/DATABASE cannot run inside one, and DO blocks
|
||||
// let "already there" be silent rather than an error the caller must parse.
|
||||
script := fmt.Sprintf(`
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%[1]s') THEN
|
||||
CREATE ROLE %[1]s LOGIN PASSWORD '%[2]s';
|
||||
ELSE
|
||||
ALTER ROLE %[1]s LOGIN PASSWORD '%[2]s';
|
||||
END IF;
|
||||
END $$;
|
||||
SELECT 'CREATE DATABASE %[3]s OWNER %[1]s'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '%[3]s')\gexec
|
||||
`, giteaDBRole, password, giteaDBName)
|
||||
|
||||
if _, err := run(asking, "docker", "exec", "-i", substrateStore,
|
||||
"psql", "-U", "postgres", "-v", "ON_ERROR_STOP=1", "-c", script); err != nil {
|
||||
return fmt.Errorf("could not seed gitea's database in %s: %w", substrateStore, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// raiseGiteaServer starts the gitea server container against the substrate store. It joins the
|
||||
// store's network namespace so `127.0.0.1:5432` reaches postgres, and publishes its own port on the
|
||||
// machine so the builder and this installer can reach it. Started if absent, left alone if present.
|
||||
func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, dbPassword string,
|
||||
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", "GITEA__database__HOST=127.0.0.1:5432",
|
||||
"-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",
|
||||
"-e", "USER_UID=1000", "-e", "USER_GID=1000",
|
||||
}
|
||||
args := append([]string{
|
||||
"run", "-d", "--name", giteaBootstrap,
|
||||
"--network", "container:" + substrateStore,
|
||||
"--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, tolerating "already exists"; then set the password unconditionally so a re-run
|
||||
// converges. Run as the gitea user, which owns the data the CLI reads.
|
||||
create := fmt.Sprintf(
|
||||
"gitea admin user create --admin --username %s --email %s@localhost --password %q --must-change-password=false || true; "+
|
||||
"gitea admin user change-password --username %s --password %q || true",
|
||||
giteaAdminUser, giteaAdminUser, password, giteaAdminUser, password)
|
||||
if _, err := run(asking, "docker", "exec", "-u", "git", giteaBootstrap,
|
||||
"sh", "-c", create); err != nil {
|
||||
return fmt.Errorf("could not create the gitea admin: %w", err)
|
||||
}
|
||||
return 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.carrying(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
|
||||
}
|
||||
Reference in New Issue
Block a user