Give every module an identity and ask once to keep a shared folder (hq ADR 0325, issue 575)

A module's containers that declare identity run as an identity the controller allocates once, mesh-wide,
from 50000-59999; each access names its users and whether its folder is taken; an access wait is excused
for a folder the mesh may not keep yet, and the operator is asked once per folder and machine, the folder
taken only on the warrant. All of it is sent only to an engine at contract 5. A placement for an access a
newer build brings is judged against that build (issue 575). mesh-host pinned to the ADR 0325 branch head.
This commit is contained in:
2026-10-12 19:05:13 +02:00
parent f10aedac58
commit 63602264ef
35 changed files with 2671 additions and 31 deletions
+206 -4
View File
@@ -456,14 +456,56 @@ const (
// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own —
// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A
// media library, a download spool is the operator's: it existed before the mesh, several modules
// read and write it at once, and the mesh must not create, chown, reconcile or remove it. The
// host confirms it is there and mounts it; nothing else.
// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it
// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it
// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the
// filesystem the data was placed on (§9).
type Access struct {
ID string `json:"id"`
Type Type `json:"type"`
Path string `json:"path"`
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"`
// Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each
// module identity whose container mounts it, and the tool runner's account standing for every module whose
// tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several
// accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a
// controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept.
Users []AccessUser `json:"users,omitempty"`
// Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is
// kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty.
Taken bool `json:"taken,omitempty"`
// Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted
// so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5).
Grant json.RawMessage `json:"grant,omitempty"`
}
// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4).
const (
FromContainer = "container"
FromToolRunner = "tool-runner"
)
// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its
// use, and where it runs.
type AccessUser struct {
Identity string `json:"identity"`
UID int `json:"uid"`
Use string `json:"use"`
From string `json:"from"`
}
// UnmarshalJSON reads a user as strictly as a resource is read.
func (u *AccessUser) UnmarshalJSON(raw []byte) error {
type plain AccessUser
var p plain
if err := strictDecode(raw, &p); err != nil {
return fmt.Errorf("an access's user: %w", err)
}
*u = AccessUser(p)
return nil
}
func (a *Access) Identity() string { return a.ID }
@@ -482,9 +524,41 @@ func (a *Access) validate(where string, _ bool) []string {
problems = append(problems, fmt.Sprintf(
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
}
for _, u := range a.Users {
switch {
case u.Identity == "" || !accountName.MatchString(u.Identity):
problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one",
where, u.Identity))
case u.UID <= 0:
// Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a
// number below it is no account.
problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+
"root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID))
}
if u.Use != AccessRead && u.Use != AccessReadWrite {
problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where,
u.Identity, u.Use, AccessRead, AccessReadWrite))
}
if u.Use == AccessReadWrite && a.Mode != AccessReadWrite {
problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where,
u.Identity, orRead(a.Mode)))
}
if u.From != FromContainer && u.From != FromToolRunner {
problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where,
u.Identity, u.From, FromContainer, FromToolRunner))
}
}
return problems
}
// orRead is an access's mode as said: absent narrows to read.
func orRead(mode string) string {
if mode == "" {
return AccessRead
}
return mode
}
func (u *User) Identity() string { return u.ID }
func (u *User) Kind() Type { return TypeUser }
func (u *User) Target() string { return u.Name }
@@ -1160,6 +1234,78 @@ type Container struct {
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
Health *Health `json:"health,omitempty"`
// RunAs is who the container runs as, resolved by the controller from the module's identity and the
// image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and
// PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
// a resource's Identity is its id.
RunAs *ContainerIdentity `json:"identity,omitempty"`
}
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
const (
IdentityByEnv = "env"
IdentityByRun = "run"
)
// ContainerIdentity is who a container runs as, and how its image is told.
type ContainerIdentity struct {
// Mode is IdentityByEnv or IdentityByRun.
Mode string `json:"mode"`
// UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for
// IdentityByEnv: "PUID", "PGID".
UserEnv string `json:"user_env,omitempty"`
GroupEnv string `json:"group_env,omitempty"`
UID int `json:"uid"`
GID int `json:"gid"`
}
// UnmarshalJSON reads an identity as strictly as a resource is read.
func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error {
type plain ContainerIdentity
var p plain
if err := strictDecode(raw, &p); err != nil {
return fmt.Errorf("a container's identity: %w", err)
}
*i = ContainerIdentity(p)
return nil
}
// envName is a variable's name as a runtime takes it.
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func (i *ContainerIdentity) problems(where string, env map[string]string) []string {
if i == nil {
return nil
}
var problems []string
switch i.Mode {
case IdentityByEnv:
if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) {
problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+
"it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv))
}
for _, name := range []string{i.UserEnv, i.GroupEnv} {
if _, set := env[name]; set && name != "" {
problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+
"for one variable", where, name))
}
}
case IdentityByRun:
if i.UserEnv != "" || i.GroupEnv != "" {
problems = append(problems, where+": an identity told by the runtime's user option names no variable")
}
default:
problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where,
IdentityByEnv, IdentityByRun, i.Mode))
}
// Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started.
if i.UID <= 0 || i.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+
"never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID))
}
return problems
}
func (c *Container) Identity() string { return c.ID }
@@ -1242,6 +1388,7 @@ func (c *Container) validate(where string, _ bool) []string {
problems = append(problems, where+": networks is for a container that keeps running; a step "+
"runs and exits, and joins nothing afterwards")
}
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
return append(problems, checkImage(where, c.Image)...)
}
@@ -1392,6 +1539,53 @@ type Declaration struct {
// the generation of what it puts back, which may be older than what the machine applied, and is
// not refused for it.
PutBack bool
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account
// and a group `mesh-<module>` with the numbers the controller allocated once for the module, the same on
// every machine. The engine makes each before any resource is applied, and removes what it made of one no
// longer declared. Absent is a controller older than ADR 0325.
Identities []Identity
}
// Identity is one module's identity, as the controller allocated it.
type Identity struct {
Module string `json:"module"`
Name string `json:"name"`
UID int `json:"uid"`
GID int `json:"gid"`
}
// IdentityPrefix starts every module identity's name.
const IdentityPrefix = "mesh-"
// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number
// twice — two modules with one account would write as each other.
func checkIdentities(ids []Identity) []string {
var problems []string
names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{}
for _, id := range ids {
where := fmt.Sprintf("identity %q", id.Name)
if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) {
problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s<module>, and this one is "+
"for the module %q", where, IdentityPrefix, id.Module))
}
if id.UID <= 0 || id.GID <= 0 {
problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+
"nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where,
id.UID, id.GID))
}
if names[id.Name] {
problems = append(problems, where+": declared twice")
}
if other, used := uids[id.UID]; used {
problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other))
}
if other, used := gids[id.GID]; used {
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other))
}
names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name
}
return problems
}
// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the
@@ -1561,6 +1755,9 @@ type envelope struct {
// sends them only to a host whose reports say `reads_generation`.
Generation int64 `json:"generation,omitempty"`
PutBack bool `json:"put_back,omitempty"`
// Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a
// controller sends it only to a host whose health says link.IdentityContract.
Identities []Identity `json:"identities,omitempty"`
}
func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -1578,8 +1775,9 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
}
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack}
var problems []string
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
Identities: env.Identities}
problems := checkIdentities(env.Identities)
if env.Generation < 0 {
// Below zero would read as "none claimed" and pass every refusal of an older generation.
problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+
@@ -1732,6 +1930,10 @@ func checkWhileStopped(resources []Resource) []string {
return problems
}
// accountName is what an identity's or an access user's name may be: the portable account name the user tools
// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument.
var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`)
func strictDecode(raw []byte, into any) error {
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
// field the host does not know is a thing the control plane believes it asked for.