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) }