Files
mesh-catalog/modules/gitea/cmd/agent-teams/teams.go
T

340 lines
10 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
"gitea/internal/forgehttp"
)
const (
// ConfigKey is the module's config value this program applies.
ConfigKey = "agent-read-teams"
// TeamPrefix names every team this program owns; a team without it is never touched (ADR 0365 §3).
TeamPrefix = "agent-read-"
// AccountPrefix names an agent's account for its node (cmd/agent-token AccountPrefix, ADR 0365 §1).
AccountPrefix = "agent-"
page = 50
)
var (
teamName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$`)
nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
repoName = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)$`)
)
// readUnits are the units a read team has, each at read: the forge's rights of a team that reads and nothing more.
var readUnits = map[string]string{
"repo.code": "read", "repo.issues": "read", "repo.pulls": "read", "repo.releases": "read", "repo.wiki": "read",
}
// Team is one read team of the value: its repositories (owner/repo, read only) and its members (nodes whose agent
// account, agent-<node>, is in the team).
type Team struct {
Repos []string `json:"repos"`
Members []string `json:"members"`
}
// Value is the config value: each team by the name it is given without the prefix.
type Value map[string]Team
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
// which empties every read team.
func ReadValue(path string) (Value, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the teams file cannot be read (%s): %w", path, err)
}
var file map[string]json.RawMessage
if err := json.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("the teams file %s is not JSON: %w", path, err)
}
var v Value
if r, ok := file[ConfigKey]; ok {
dec := json.NewDecoder(strings.NewReader(string(r)))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("%s in %s is not {team: {repos, members}}: %w", ConfigKey, path, err)
}
}
return v, nil
}
// Syncer makes the forge's agent-read-* teams the value's.
type Syncer struct {
Forge forgehttp.Client
Log func(format string, a ...any)
WaitFor time.Duration // how long to wait for the forge to answer; none when zero
Poll time.Duration
}
type forgeTeam struct {
ID int64 `json:"id"`
Name string `json:"name"`
Permission string `json:"permission"`
}
type forgeRepo struct {
FullName string `json:"full_name"`
}
type forgeMember struct {
Login string `json:"login"`
}
func (s *Syncer) say(format string, a ...any) {
if s.Log != nil {
s.Log(format, a...)
}
}
// Sync applies the value. A team that cannot be applied is said and the others still are; the error names them.
func (s *Syncer) Sync(ctx context.Context, v Value) error {
if err := s.waitForForge(ctx); err != nil {
return err
}
orgs, err := s.orgs(ctx)
if err != nil {
return err
}
wanted := map[string]bool{}
var failed []string
names := make([]string, 0, len(v))
for n := range v {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
wanted[TeamPrefix+n] = true
if err := s.apply(ctx, n, v[n]); err != nil {
s.say("not applied: team %s%s: %v", TeamPrefix, n, err)
failed = append(failed, TeamPrefix+n)
}
}
for _, org := range orgs {
teams, err := s.teams(ctx, org)
if err != nil {
return err
}
for _, t := range teams {
if !strings.HasPrefix(t.Name, TeamPrefix) || wanted[t.Name] {
continue
}
if err := s.sync(ctx, org, t, nil, nil); err != nil {
s.say("not applied: team %s in %s: %v", t.Name, org, err)
failed = append(failed, t.Name)
}
}
}
if len(failed) > 0 {
return fmt.Errorf("not applied: %s", strings.Join(failed, ", "))
}
return nil
}
func (s *Syncer) waitForForge(ctx context.Context) error {
deadline := time.Now().Add(s.WaitFor)
for {
err := s.Forge.GetJSON(ctx, "/api/v1/version", nil)
if err == nil {
return nil
}
var se *forgehttp.StatusError
if errors.As(err, &se) || time.Now().After(deadline) || ctx.Err() != nil {
return fmt.Errorf("the forge is not reachable: %w", err)
}
time.Sleep(s.Poll)
}
}
// apply makes one team of the value.
func (s *Syncer) apply(ctx context.Context, name string, t Team) error {
if !teamName.MatchString(name) {
return fmt.Errorf("%q is not a team name", name)
}
if len(t.Repos) == 0 {
return errors.New("it lists no repository, so the organisation to make it in is not known")
}
org := ""
repos := map[string]bool{}
for _, r := range t.Repos {
m := repoName.FindStringSubmatch(r)
if m == nil {
return fmt.Errorf("%q is not owner/repo", r)
}
if org == "" {
org = m[1]
}
if m[1] != org {
return fmt.Errorf("%q is not in %s: a team's repositories are of one owner, the first's", r, org)
}
repos[r] = true
}
members := map[string]bool{}
for _, n := range t.Members {
if !nodeName.MatchString(n) {
return fmt.Errorf("%q is not a node's name", n)
}
members[AccountPrefix+n] = true
}
full := TeamPrefix + name
teams, err := s.teams(ctx, org)
if err != nil {
return err
}
var team *forgeTeam
for i := range teams {
if teams[i].Name == full {
team = &teams[i]
}
}
if team == nil {
var made forgeTeam
err := s.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+org+"/teams", map[string]any{
"name": full, "description": "Read-only team of agents' forge accounts (ADR 0365 §3); set by the gitea config value " + ConfigKey,
"permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits,
}, &made)
if err != nil {
return fmt.Errorf("the team could not be made: %w", err)
}
s.say("made the team %s in %s, reading only", full, org)
team = &made
} else if team.Permission != "read" {
if err := s.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", team.ID), map[string]any{
"name": full, "permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits,
}, nil); err != nil {
return fmt.Errorf("the team could not be made read-only: %w", err)
}
s.say("made the team %s in %s read-only (it was %s)", full, org, team.Permission)
}
return s.sync(ctx, org, *team, repos, members)
}
// sync makes the team's repositories and members exactly the sets (nil is none).
func (s *Syncer) sync(ctx context.Context, org string, t forgeTeam, repos, members map[string]bool) error {
var errs []string
var haveRepos []forgeRepo
if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/repos", t.ID), &haveRepos); err != nil {
return err
}
held := map[string]bool{}
for _, r := range haveRepos {
held[r.FullName] = true
if !repos[r.FullName] {
if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r.FullName), nil, nil); err != nil {
errs = append(errs, err.Error())
continue
}
s.say("took %s from the team %s", r.FullName, t.Name)
}
}
for _, r := range sortedKeys(repos) {
if held[r] {
continue
}
if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r), nil, nil); err != nil {
errs = append(errs, fmt.Sprintf("%s could not be given to the team: %v", r, err))
continue
}
s.say("gave %s to the team %s, to read", r, t.Name)
}
var haveMembers []forgeMember
if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/members", t.ID), &haveMembers); err != nil {
return err
}
in := map[string]bool{}
for _, m := range haveMembers {
in[m.Login] = true
if !members[m.Login] {
if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m.Login)), nil, nil); err != nil {
errs = append(errs, err.Error())
continue
}
s.say("took %s out of the team %s", m.Login, t.Name)
}
}
for _, m := range sortedKeys(members) {
if in[m] {
continue
}
if err := s.Forge.GetJSON(ctx, "/api/v1/users/"+url.PathEscape(m), nil); err != nil {
if errors.Is(err, forgehttp.ErrNotFound) {
s.say("skipped %s for the team %s: the account does not exist yet", m, t.Name)
continue
}
errs = append(errs, err.Error())
continue
}
if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m)), nil, nil); err != nil {
errs = append(errs, fmt.Sprintf("%s could not be put in the team: %v", m, err))
continue
}
s.say("put %s in the team %s", m, t.Name)
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// list reads every page of a list answer into into (a pointer to a slice).
func (s *Syncer) list(ctx context.Context, path string, into any) error {
var all []json.RawMessage
for p := 1; ; p++ {
var got []json.RawMessage
if err := s.Forge.GetJSON(ctx, fmt.Sprintf("%s?limit=%d&page=%d", path, page, p), &got); err != nil {
return err
}
all = append(all, got...)
if len(got) < page {
break
}
}
raw, _ := json.Marshal(all)
return json.Unmarshal(raw, into)
}
func (s *Syncer) teams(ctx context.Context, org string) ([]forgeTeam, error) {
var out []forgeTeam
if err := s.list(ctx, "/api/v1/orgs/"+org+"/teams", &out); err != nil {
if errors.Is(err, forgehttp.ErrNotFound) {
return nil, fmt.Errorf("the organisation %s does not exist on the forge", org)
}
return nil, err
}
return out, nil
}
// orgs are the organisations on the forge, where a read team no longer in the value is found.
func (s *Syncer) orgs(ctx context.Context) ([]string, error) {
var out []struct {
Name string `json:"name"`
}
if err := s.list(ctx, "/api/v1/admin/orgs", &out); err != nil {
return nil, fmt.Errorf("the forge's organisations could not be read: %w", err)
}
names := make([]string, len(out))
for i, o := range out {
names[i] = o.Name
}
return names, nil
}