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
187 lines
5.8 KiB
Go
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)
|
|
}
|