From 01c7730fb3a19ca22fcd03e54d3651e905fe8f85 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 14:11:36 +0200 Subject: [PATCH] Genesis raises gitea correctly: host network, honest SQL, matched ROOT_URL Fixes found raising the package registry end-to-end in the lab: seed gitea's DB with plain psql statements (no \gexec, no $$ DO-blocks that clash with the shell); run gitea on the host network so it reaches the substrate store and answers where the builder looks; set gitea ROOT_URL to the machine's loopback so npm's stored credential matches the tarball host; keep the pivot's passwords so a re-run is the same run; create the admin without re-enabling must-change-password. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/bootstrap/phase_packages.go | 100 +++++++++++++++------ internal/bootstrap/phase_packages_gitea.go | 19 +++- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/internal/bootstrap/phase_packages.go b/internal/bootstrap/phase_packages.go index 80d5292..10be2b7 100644 --- a/internal/bootstrap/phase_packages.go +++ b/internal/bootstrap/phase_packages.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "os" "strings" "time" ) @@ -51,11 +52,12 @@ func RaisePackageRegistry(ctx context.Context, o Options, d Deps, control contro 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() + // 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 substrate store") if err := seedGiteaDatabase(ctx, run, o.Timeout, dbPassword, say); err != nil { @@ -113,23 +115,33 @@ func seedGiteaDatabase(ctx context.Context, run Runner, timeout time.Duration, p 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) + // 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", substrateStore, "psql", "-U", "postgres", "-tAc", sql) + } - 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) + // 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", substrateStore, 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", substrateStore, 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", substrateStore, err) + } } return nil } @@ -158,6 +170,11 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db "-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/", giteaPort), "-e", "USER_UID=1000", "-e", "USER_GID=1000", } args := append([]string{ @@ -205,19 +222,48 @@ func createGiteaAdmin(ctx context.Context, run Runner, timeout time.Duration, pa 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 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 || true; "+ - "gitea admin user change-password --username %s --password %q || true", - giteaAdminUser, giteaAdminUser, password, giteaAdminUser, password) + "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); err != nil { + "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. diff --git a/internal/bootstrap/phase_packages_gitea.go b/internal/bootstrap/phase_packages_gitea.go index 304bd15..f44a618 100644 --- a/internal/bootstrap/phase_packages_gitea.go +++ b/internal/bootstrap/phase_packages_gitea.go @@ -124,9 +124,12 @@ func (g *giteaAdmin) findTeam(ctx context.Context, org, team string) (int, error // ensureUser creates a gitea user with the mesh's minted password, or resets that user's password // when it already exists, so a rotation takes. func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) error { + // A dotted domain: gitea's API validates the address, and an @localhost with no dot is refused + // as malformed — which comes back as the same 422 an "already exists" does, so the email is + // chosen to not provoke it and existence is checked directly rather than inferred from a status. status, body, err := g.do(ctx, http.MethodPost, "/admin/users", map[string]any{ "username": name, - "email": name + "@localhost", + "email": name + "@packages.mesh.local", "password": password, "must_change_password": false, }) @@ -136,7 +139,11 @@ func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) erro if status/100 == 2 { return nil } - if status == http.StatusUnprocessableEntity || status == http.StatusConflict { + exists, err := g.userExists(ctx, name) + if err != nil { + return err + } + if exists { // Already there: reset the password so this run's credential is the one that works. reset, rbody, err := g.do(ctx, http.MethodPatch, "/admin/users/"+name, map[string]any{"login_name": name, "password": password, "must_change_password": false}) @@ -151,6 +158,14 @@ func (g *giteaAdmin) ensureUser(ctx context.Context, name, password string) erro return fmt.Errorf("could not create the gitea user %q: %d %s", name, status, body) } +func (g *giteaAdmin) userExists(ctx context.Context, name string) (bool, error) { + status, _, err := g.do(ctx, http.MethodGet, "/users/"+name, nil) + if err != nil { + return false, err + } + return status == http.StatusOK, nil +} + func (g *giteaAdmin) addToTeam(ctx context.Context, teamID int, user string) error { status, body, err := g.do(ctx, http.MethodPut, fmt.Sprintf("/teams/%d/members/%s", teamID, user), nil) if err != nil {