Make the builders team and the read-only nox-builder keys in the forge account library, and watch the build modules' keys (issues 1481, 1482), part 4 of 17
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
// For each machine whose ssh-client binds the provision and has reported its public key (ssh-client's state `keys`),
|
||||
// it makes the machine's agent the one forge user nox-agent (in the agents team; ADR 0410), registers the key for
|
||||
// it, and serves where and as whom to reach the forge over ssh: the user, the forge machine's mesh name and port, its
|
||||
// host key and the two url roots. A public key is no secret, so the provision has no credential.
|
||||
// host key and the two url roots. A public key is no secret, so the provision has no credential. For each build
|
||||
// module that binds it and has reported its builder key (its own state `keys`), it does the same as the forge user
|
||||
// nox-builder, in the builders team that reads the code of every repository and nothing else, with the key read-only
|
||||
// (ADR 0466 §3).
|
||||
//
|
||||
// stdout is the MCP channel; it says what it says on stderr, and never the admin's password.
|
||||
package main
|
||||
@@ -22,6 +25,7 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
|
||||
"gitea/internal/forgeaccount"
|
||||
"gitea/internal/forgehttp"
|
||||
)
|
||||
|
||||
@@ -42,11 +46,12 @@ func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stateKeys is ssh-client's state `keys` over the runtime (the manifest's `reads` grants it).
|
||||
type stateKeys struct{}
|
||||
// stateKeys is the state `keys` of one module over the runtime (the manifest's `reads` grants it): ssh-client's for
|
||||
// the machines' keys, a build module's for the builders' (ADR 0466 §3).
|
||||
type stateKeys struct{ module string }
|
||||
|
||||
func (stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||
return stdio.State("ssh-client.keys").Watch("", func(c stdio.StateChange) error {
|
||||
func (k stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||
return stdio.State(k.module+".keys").Watch("", func(c stdio.StateChange) error {
|
||||
handle(c.Key, c.Op, c.Value, c.Current)
|
||||
return nil
|
||||
})
|
||||
@@ -106,14 +111,18 @@ func main() {
|
||||
}
|
||||
}
|
||||
svc.OnKeys = func(string) { go pass() }
|
||||
go func() {
|
||||
if err := svc.WatchKeys(ctx, stateKeys{}); err != nil {
|
||||
say("the machines' keys are not watched: %v", err)
|
||||
return
|
||||
}
|
||||
say("watching the machines' keys")
|
||||
pass()
|
||||
}()
|
||||
// Each module's keys are watched on their own, and a module whose state never answers (a build module that is not
|
||||
// installed) holds back its own entries only.
|
||||
for _, module := range append([]string{SSHClient}, forgeaccount.BuildModules...) {
|
||||
go func() {
|
||||
if err := svc.WatchSource(ctx, module, stateKeys{module}); err != nil {
|
||||
say("the keys of %s are not watched: %v", module, err)
|
||||
return
|
||||
}
|
||||
say("watching the keys of %s", module)
|
||||
pass()
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
svc.KeepRunning(ctx, p.Run)
|
||||
}()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR
|
||||
// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write
|
||||
// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says
|
||||
// what an agent's account is.
|
||||
// what an agent's account is. It also makes the one account of the build modules' keys, nox-builder, in the builders
|
||||
// team that reads the code of every repository and nothing else (ADR 0466 §3; issue 1481).
|
||||
package forgeaccount
|
||||
|
||||
import (
|
||||
@@ -26,8 +27,23 @@ const (
|
||||
TeamName = "agents"
|
||||
)
|
||||
|
||||
// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the
|
||||
// BuildersTeamName is the team of the build modules' account (ADR 0466 §3).
|
||||
const BuildersTeamName = "builders"
|
||||
|
||||
// Team is a team of the organisation as the mesh keeps it: its name and the shape it is put back to.
|
||||
type Team struct {
|
||||
Name string
|
||||
Shape func() map[string]any
|
||||
}
|
||||
|
||||
// AgentsTeam is the team of the machines' agents: write on code, issues and pull requests of every repository of the
|
||||
// organisation and no more, the shape of the forge tools' team (tools-account.ts).
|
||||
var AgentsTeam = Team{Name: TeamName, Shape: teamShape}
|
||||
|
||||
// BuildersTeam is the team of the build modules' account: it may read the code of every repository of the organisation
|
||||
// and has no other right (ADR 0466 §3).
|
||||
var BuildersTeam = Team{Name: BuildersTeamName, Shape: buildersTeamShape}
|
||||
|
||||
func teamShape() map[string]any {
|
||||
return map[string]any{
|
||||
"name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||
@@ -35,6 +51,13 @@ func teamShape() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func buildersTeamShape() map[string]any {
|
||||
return map[string]any{
|
||||
"name": BuildersTeamName, "permission": "read", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||
"units_map": map[string]string{"repo.code": "read"},
|
||||
}
|
||||
}
|
||||
|
||||
// User is gitea's account as the admin reads it.
|
||||
type User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
@@ -87,35 +110,41 @@ func (m *Accounts) EnsureAccount(ctx context.Context, account string) error {
|
||||
// EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the
|
||||
// account to it; an account already in it stays (ADR 0398 §1, issue 948).
|
||||
func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error {
|
||||
id, err := m.teamID(ctx)
|
||||
return m.EnsureInTeamOf(ctx, AgentsTeam, account)
|
||||
}
|
||||
|
||||
// EnsureInTeamOf is EnsureInTeam for any team of the mesh's: it makes the team when there is none, puts a team that is
|
||||
// there back to its shape, and adds the account to it.
|
||||
func (m *Accounts) EnsureInTeamOf(ctx context.Context, team Team, account string) error {
|
||||
id, err := m.teamID(ctx, team.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id != 0 {
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil {
|
||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err)
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), team.Shape(), nil); err != nil {
|
||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", team.Name, err)
|
||||
}
|
||||
} else {
|
||||
var made struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil {
|
||||
return fmt.Errorf("the team %s could not be made: %w", TeamName, err)
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", team.Shape(), &made); err != nil {
|
||||
return fmt.Errorf("the team %s could not be made: %w", team.Name, err)
|
||||
}
|
||||
id = made.ID
|
||||
m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg)
|
||||
m.say("made the forge team %s in %s", team.Name, TeamOrg)
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("the forge answered no id for the team %s", TeamName)
|
||||
return fmt.Errorf("the forge answered no id for the team %s", team.Name)
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil {
|
||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err)
|
||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, team.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// teamID is the agents team's id, or 0 when there is none.
|
||||
func (m *Accounts) teamID(ctx context.Context) (int64, error) {
|
||||
// teamID is the id of the team of that name, or 0 when there is none.
|
||||
func (m *Accounts) teamID(ctx context.Context, name string) (int64, error) {
|
||||
var id int64
|
||||
for page := 1; page <= 20 && id == 0; page++ {
|
||||
var teams []struct {
|
||||
@@ -126,7 +155,7 @@ func (m *Accounts) teamID(ctx context.Context) (int64, error) {
|
||||
return 0, fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.Name == TeamName {
|
||||
if t.Name == name {
|
||||
id = t.ID
|
||||
}
|
||||
}
|
||||
@@ -160,16 +189,49 @@ var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
// AgentUser is the one forge user of every machine's agents (ADR 0410 §1), in the agents write team.
|
||||
const AgentUser = "nox-agent"
|
||||
|
||||
// ForgeUser is the forge user of a machine's key of the given kind (ADR 0410 §1): `nox-agent` for every machine
|
||||
// and account (kind machine). A kind not built yet has none, and the answer is empty. The machine's name must still
|
||||
// be one an account could be named for.
|
||||
// BuilderUser is the one forge user of every build module's key (ADR 0466 §3), in the builders team: it reads and
|
||||
// pushes nothing.
|
||||
const BuilderUser = "nox-builder"
|
||||
|
||||
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
|
||||
// the account each runs as (ADR 0466 §1).
|
||||
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
|
||||
|
||||
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
|
||||
// other (ssh-client's).
|
||||
func KindOfModule(module string) string {
|
||||
for _, m := range BuildModules {
|
||||
if m == module {
|
||||
return "builder"
|
||||
}
|
||||
}
|
||||
return "machine"
|
||||
}
|
||||
|
||||
// ForgeUser is the forge user of a key of the given kind (ADR 0410 §1, ADR 0466 §3): `nox-agent` for every machine
|
||||
// and account (kind machine), `nox-builder` for every build module (kind builder). A kind not built yet has none, and
|
||||
// the answer is empty. The machine's name must still be one an account could be named for.
|
||||
func ForgeUser(node, kind string) string {
|
||||
if kind == "machine" && NodeName.MatchString(node) {
|
||||
if !NodeName.MatchString(node) {
|
||||
return ""
|
||||
}
|
||||
switch kind {
|
||||
case "machine":
|
||||
return AgentUser
|
||||
case "builder":
|
||||
return BuilderUser
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TeamOfUser is the team a forge user of the mesh is in.
|
||||
func TeamOfUser(user string) Team {
|
||||
if user == BuilderUser {
|
||||
return BuildersTeam
|
||||
}
|
||||
return AgentsTeam
|
||||
}
|
||||
|
||||
// NodeAccount is the forge account the token tool (cmd/agent-token) keeps for a node's agent: `agent-<node>` (ADR
|
||||
// 0365 §1). It is not the forge user of the machine's ssh key (ForgeUser), and its behaviour is unchanged.
|
||||
func NodeAccount(node string) string { return AccountPrefix + node }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package forgeaccount
|
||||
|
||||
import "testing"
|
||||
|
||||
// Issue 1481 (novox/hq ADR 0466 §3): the build modules' forge user, team and key scope.
|
||||
func TestTheBuildModulesHaveOneForgeUserAndTheMachinesKeepTheirs(t *testing.T) {
|
||||
for kind, want := range map[string]string{"machine": "nox-agent", "builder": "nox-builder", "session": ""} {
|
||||
if got := ForgeUser("ace", kind); got != want {
|
||||
t.Errorf("ForgeUser(ace, %s) = %q, want %q", kind, got, want)
|
||||
}
|
||||
}
|
||||
if ForgeUser("Not A Node", "builder") != "" {
|
||||
t.Errorf("a name no account could be named for got a user")
|
||||
}
|
||||
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "ssh-client": "machine", "": "machine"} {
|
||||
if got := KindOfModule(module); got != want {
|
||||
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
|
||||
}
|
||||
}
|
||||
if TeamOfUser(BuilderUser).Name != "builders" || TeamOfUser(AgentUser).Name != "agents" {
|
||||
t.Errorf("teams: %s and %s", TeamOfUser(BuilderUser).Name, TeamOfUser(AgentUser).Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A build module's scope names the module, so one module's pass does not remove the other's key on the same machine;
|
||||
// the machine's scope is every key of the machine.
|
||||
func TestABuilderScopeNamesTheModule(t *testing.T) {
|
||||
if got := BuilderScope("ace", "mesh-build-go"); got != "mesh:ace.mesh-build-go" {
|
||||
t.Errorf("scope = %q", got)
|
||||
}
|
||||
if BuilderScope("ace", "mesh-build-go") == BuilderScope("ace", "mesh-build-node") {
|
||||
t.Errorf("two build modules share a scope")
|
||||
}
|
||||
if got := KeyTitle("ace", "mesh-build-go"); got != BuilderScope("ace", "mesh-build-go") {
|
||||
t.Errorf("the key's title %q is not inside the scope", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The builders team reads the code of every repository of the organisation and has no other right.
|
||||
func TestTheBuildersTeamReadsCodeOnly(t *testing.T) {
|
||||
s := BuildersTeam.Shape()
|
||||
units, _ := s["units_map"].(map[string]string)
|
||||
if s["permission"] != "read" || s["includes_all_repositories"] != true || s["can_create_org_repo"] != false || len(units) != 1 || units["repo.code"] != "read" {
|
||||
t.Errorf("shape = %v", s)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type WantedKey struct {
|
||||
Title string
|
||||
// Key is the public key line: type, base64 and a comment.
|
||||
Key string
|
||||
// ReadOnly registers the key as one that may not push (a builder's, ADR 0466 §3).
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// Key is a key the forge holds for an account.
|
||||
@@ -56,10 +58,10 @@ func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) {
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// AddKey registers a key for the account, one it may write with.
|
||||
// AddKey registers a key for the account, one it may write with unless the key is wanted read-only.
|
||||
func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error {
|
||||
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys",
|
||||
map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil)
|
||||
map[string]any{"title": k.Title, "key": k.Key, "read_only": k.ReadOnly}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err)
|
||||
}
|
||||
@@ -78,6 +80,11 @@ func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error {
|
||||
// MachineScope is the title prefix of one machine's keys, `mesh:<node>.`: what a pass for that machine may remove.
|
||||
func MachineScope(node string) string { return TitlePrefix + node + "." }
|
||||
|
||||
// BuilderScope is the title prefix of one build module's key on one machine, `mesh:<node>.<module>`: what a pass for
|
||||
// that module's entry may remove. It names the module, since nox-builder holds the keys of both build modules of a
|
||||
// machine and one's pass must not remove the other's.
|
||||
func BuilderScope(node, module string) string { return TitlePrefix + node + "." + module }
|
||||
|
||||
// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered
|
||||
// for one machine (title starting scope, `mesh:<node>.`) that are no longer wanted. One forge user holds every
|
||||
// machine's keys (ADR 0410 §2), so a key under another machine's title is never removed here. A key whose title does not start `mesh:` is the operator's and
|
||||
|
||||
@@ -540,6 +540,8 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"ssh-client.keys"
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user