340 lines
10 KiB
Go
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
|
|
}
|