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