Self-upgrade installer: genesis pivot, rename, adopt store+broker #13
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user