Files
mesh-host/internal/apply/groups.go
T
jochen ab4ca44f98
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/module-groups delivering: 0 of 2 delivered
mesh/delivery delivered
Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
A module puts the operator's account in a group by declaring the account with that group alone.
The node-engine now records each group it added, takes back only those when nothing declared still
asks for them, refuses a group the machine lacks before usermod runs, and states each such account
as its module's resource of kind account: relogin needed while the running session lacks the group.
2026-10-08 12:04:08 +02:00

222 lines
7.2 KiB
Go

package apply
import (
"context"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// An account's groups, from any module (novox/hq ADR 0252, issue 247).
//
// **A module that needs the account in a group declares the account with that group, and nothing else
// of it.** The shell's module sets the account's shell; the lighting daemon's module puts the same
// account in `openrazer`; the container runtime's in `docker`. A shell is one value and stays one
// module's (the controller refuses two); a group is added, so several modules' groups never contradict.
//
// **The mesh takes back only what it gave.** A group the account was in before is the machine's or the
// operator's, and stays when every resource that named it goes. A group the mesh put the account in is
// recorded on the resource that put it there, and given back when that resource stops asking for it —
// unless another declared resource still asks for the same group, which keeps it.
//
// **A group takes effect at the next login.** The machine's group database changes at once; every
// process already running, the account's own service manager and everything it started included, keeps
// the groups it started with. So the outcome says a new login is needed, and the node-engine's account
// judge (internal/accounts) says so on every look until the account's running manager has the group.
// Wanted is every group a declaration asks an account to be in, and which resources ask: account →
// group → resource ids.
type Wanted map[string]map[string][]string
// groupsWanted reads every user resource's groups from a declaration.
func groupsWanted(resources []declaration.Resource) Wanted {
w := Wanted{}
for _, r := range resources {
u, ok := r.(*declaration.User)
if !ok || u.Name == "" {
continue
}
for _, g := range u.Groups {
if w[u.Name] == nil {
w[u.Name] = map[string][]string{}
}
w[u.Name][g] = append(w[u.Name][g], u.ID)
}
}
return w
}
// othersAsk is every resource other than one that asks for an account to be in a group.
func (w Wanted) othersAsk(account, group, except string) []string {
var others []string
for _, id := range w[account][group] {
if id != except {
others = append(others, id)
}
}
return others
}
// applyGroups makes the account be in every group the resource declares, and takes it out of every group
// this resource put it in and no longer asks for. What the mesh put the account in is recorded on out.
func applyGroups(ctx context.Context, sys system.System, r *declaration.User, run Runner,
previous store.Applied, wanted Wanted, out *Outcome) error {
// What this resource put the account in before, for this account only: a declaration that renamed its
// user says nothing about the new one's groups.
var added []string
if previous.Target == r.Name {
added = append(added, previous.Groups...)
}
if len(r.Groups) == 0 && len(added) == 0 {
return nil
}
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
if err != nil {
return err
}
already := setOf(in)
declared := setOf(r.Groups)
var put []string
for _, want := range r.Groups {
if already[want] {
continue
}
exists, err := system.GroupExists(ctx, system.Runner(run), want)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("%q was not put in the group %q: this machine has no such group yet. The package "+
"that makes it is not installed, or is declared after the account", r.Name, want)
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil {
return err
}
already[want] = true
put = append(put, want)
if !contains(added, want) {
added = append(added, want)
}
}
// What this resource put the account in and no longer asks for.
var kept, said []string
for _, g := range added {
switch {
case declared[g]:
kept = append(kept, g)
case !already[g]:
// Taken out since, by a person: nothing to give back.
case len(wanted.othersAsk(r.Name, g, r.ID)) > 0:
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g,
strings.Join(wanted.othersAsk(r.Name, g, r.ID), ", ")))
default:
gone, why := leaveGroup(ctx, sys, r.Name, g, run)
if !gone {
kept = append(kept, g)
}
said = append(said, why)
}
}
out.groups = kept
if len(put) > 0 {
said = append([]string{fmt.Sprintf("put in %s; a session that began before has %s only after a new "+
"login", strings.Join(put, ", "), them(len(put)))}, said...)
}
if len(put) > 0 || len(said) > 0 {
if out.Action == "unchanged" {
out.Action = "updated"
}
out.Detail = joinDetail(out.Detail, strings.Join(said, "; "))
}
return nil
}
// giveGroupsBack is removeUser's groups: every group the mesh put the account in, taken back unless
// another declared resource still asks for it. Never fatal, for the shell's reason: a removal that failed
// would stay recorded and fail the same way on every apply after. Empty when there was nothing to say.
func giveGroupsBack(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted) (bool, string) {
if len(a.Groups) == 0 {
return false, ""
}
in, err := system.GroupsOf(ctx, system.Runner(run), a.Target)
if err != nil {
return false, fmt.Sprintf("the groups the mesh put it in (%s) were not given back: %v",
strings.Join(a.Groups, ", "), err)
}
already := setOf(in)
gave := false
var said []string
for _, g := range a.Groups {
if !already[g] {
continue
}
if others := wanted.othersAsk(a.Target, g, a.ID); len(others) > 0 {
said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(others, ", ")))
continue
}
gone, why := leaveGroup(ctx, sys, a.Target, g, run)
gave = gave || gone
said = append(said, why)
}
return gave, strings.Join(said, "; ")
}
// leaveGroup takes an account out of one group the mesh put it in, read back from the machine, and says
// what came of it.
func leaveGroup(ctx context.Context, sys system.System, account, group string, run Runner) (bool, string) {
l, ok := sys.(system.GroupLeaver)
if !ok {
return false, fmt.Sprintf("left in %s: this machine's system cannot take an account out of one group", group)
}
if err := l.RemoveUserFromGroup(ctx, system.Runner(run), account, group); err != nil {
return false, fmt.Sprintf("left in %s: %v", group, err)
}
in, err := system.GroupsOf(ctx, system.Runner(run), account)
if err != nil {
return false, fmt.Sprintf("taken out of %s, and the group database could not be read back: %v", group, err)
}
if setOf(in)[group] {
return false, fmt.Sprintf("taken out of %s, and the group database still lists it there", group)
}
return true, fmt.Sprintf("taken out of %s, which the mesh had put it in", group)
}
func setOf(items []string) map[string]bool {
s := map[string]bool{}
for _, i := range items {
s[i] = true
}
return s
}
func contains(items []string, want string) bool {
for _, i := range items {
if i == want {
return true
}
}
return false
}
func them(n int) string {
if n == 1 {
return "it"
}
return "them"
}
func joinDetail(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}