Files
mesh-host/internal/bootstrap/phase_packages_gitea.go
T
jschoubben 01c7730fb3 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
2026-09-16 14:11:36 +02:00

187 lines
5.8 KiB
Go

package bootstrap
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
)
// A minimal gitea admin client, for the genesis pivot only. The gitea MODULE carries the real one
// (its TS provisioner); this exists because at genesis that module cannot be built yet — its image
// stands on the base, which is what this is helping to build. It does the few acts the pivot needs
// and nothing more: an org, a team, a user, a membership. Everything is idempotent, because genesis
// is safe to run again.
type giteaAdmin struct {
base string
user string
password string
client *http.Client
}
func (g *giteaAdmin) do(ctx context.Context, method, path string, body any) (int, []byte, error) {
var payload io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return 0, nil, err
}
payload = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, g.base+"/api/v1"+path, payload)
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(g.user+":"+g.password)))
res, err := g.client.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res.StatusCode, out, nil
}
// ok reports whether a status is one this pivot treats as success — the create succeeded, or the
// thing already exists (422/409), which for an idempotent step is the same outcome.
func ensured(status int) bool {
return status/100 == 2 || status == http.StatusUnprocessableEntity || status == http.StatusConflict
}
func (g *giteaAdmin) ensureOrg(ctx context.Context, name string) error {
status, body, err := g.do(ctx, http.MethodPost, "/orgs",
map[string]any{"username": name, "visibility": "private"})
if err != nil {
return err
}
if !ensured(status) {
return fmt.Errorf("could not create the gitea org %q: %d %s", name, status, body)
}
return nil
}
// ensureTeam creates the org's package team with write on packages and returns its id, finding the
// existing one when a create loses to a concurrent one.
func (g *giteaAdmin) ensureTeam(ctx context.Context, org, team string) (int, error) {
if id, err := g.findTeam(ctx, org, team); err != nil {
return 0, err
} else if id != 0 {
return id, nil
}
status, body, err := g.do(ctx, http.MethodPost, "/orgs/"+org+"/teams", map[string]any{
"name": team,
"permission": "read",
"units_map": map[string]string{"repo.packages": "write"},
"includes_all_repositories": true,
"can_create_org_repo": false,
})
if err != nil {
return 0, err
}
if status/100 == 2 {
var made struct {
ID int `json:"id"`
}
if err := json.Unmarshal(body, &made); err == nil && made.ID != 0 {
return made.ID, nil
}
}
// A lost race, or a body without an id: re-find.
if id, err := g.findTeam(ctx, org, team); err == nil && id != 0 {
return id, nil
}
return 0, fmt.Errorf("could not create the gitea team %q in %q: %d %s", team, org, status, body)
}
func (g *giteaAdmin) findTeam(ctx context.Context, org, team string) (int, error) {
status, body, err := g.do(ctx, http.MethodGet, "/orgs/"+org+"/teams?limit=50", nil)
if err != nil {
return 0, err
}
if status != http.StatusOK {
return 0, nil
}
var teams []struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &teams); err != nil {
return 0, err
}
for _, t := range teams {
if t.Name == team {
return t.ID, nil
}
}
return 0, nil
}
// 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 + "@packages.mesh.local",
"password": password,
"must_change_password": false,
})
if err != nil {
return err
}
if status/100 == 2 {
return nil
}
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})
if err != nil {
return err
}
if reset/100 == 2 {
return nil
}
return fmt.Errorf("could not reset the gitea user %q: %d %s", name, reset, rbody)
}
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 {
return err
}
if !ensured(status) {
return fmt.Errorf("could not add %q to team %d: %d %s", user, teamID, status, body)
}
return nil
}
// newPassword is a mesh-minted secret: 32 bytes of randomness, URL-safe so it survives a connection
// string and an .npmrc without escaping.
func newPassword() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}