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.
515 lines
20 KiB
Go
515 lines
20 KiB
Go
package catalogue
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Every module is given an identity of its own, and a container that declares `identity` runs as it (novox/hq ADR
|
||
// 0325 §2, §3, §11).
|
||
//
|
||
// **The manifest says how its image is told who to run as, and nothing else.** A container resource may declare
|
||
// `identity`: `{"user": "env:PUID", "group": "env:PGID"}` for an image that reads its user and group from its
|
||
// environment, or `{"user": "run"}` for the container runtime's own user option. It is the image's interface, as its
|
||
// ports are. The mesh fills it in: the controller allocates the module one user and group number, mesh-wide, once,
|
||
// from a range of its own (IdentityLow–IdentityHigh), and the node-engine makes the account `mesh-<module>` on each
|
||
// machine the module is assigned to and tells the image those numbers.
|
||
//
|
||
// **The range is 50000–59999, and a module's user and group numbers are one number.** Chosen above the numbers
|
||
// distributions give people (1000 up) and below the 60000s Debian keeps for its own daemons, and far from root's 0
|
||
// and every root group a distribution ships (0, adm's 4, wheel's 10): no number in it is 0 or a root group's, so §11
|
||
// holds by the range alone, and the store refuses a row outside it. Both numbers come from one sequence, so a
|
||
// module's uid and gid are equal; a number once given is never given again, even after the module is forgotten
|
||
// (the store keeps the row, inventory.AllocateIdentity).
|
||
//
|
||
// **What the controller sends, and only to an engine that reads it** (Rendering.KeepsFolders: its statement's
|
||
// contract is link.IdentityContract or later):
|
||
//
|
||
// - the declaration's `identities`: [{module, name, uid, gid}] for each module of the machine that declares
|
||
// `identity` on a container;
|
||
// - a container's `identity` resolved: {"mode": "env", "user_env", "group_env", "uid", "gid"} or {"mode": "run",
|
||
// "uid", "gid"};
|
||
// - the owner of each of the module's directories a container declaring `identity` mounts: the identity's
|
||
// uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the
|
||
// identity, and owning it by the identity would lock its writer out;
|
||
// - an access's `users`: the module's identity where a container of it that declares `identity` mounts the
|
||
// access, and the tool runner's account where the module's tools run there and the account's number is known,
|
||
// each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access
|
||
// to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`,
|
||
// whether the operator let the mesh keep that folder on that machine (§8).
|
||
//
|
||
// To an older engine none of it is sent, and a container's `identity` is taken out: it runs as its image says, as
|
||
// before. A module that declares no `identity` is composed exactly as before, on any engine.
|
||
|
||
// The range module identities are allocated from (novox/hq ADR 0325 §3, §11). The store holds the same bounds.
|
||
const (
|
||
IdentityLow = 50000
|
||
IdentityHigh = 59999
|
||
)
|
||
|
||
// AllocatableID says whether n is a number the mesh may give a module's identity: inside the range, so never 0
|
||
// nor a root group's.
|
||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||
|
||
// IdentityField is a container resource's field saying how its image is told who to run as.
|
||
const IdentityField = "identity"
|
||
|
||
// The two ways an image is told who to run as.
|
||
const (
|
||
IdentityEnv = "env"
|
||
IdentityRun = "run"
|
||
)
|
||
|
||
// IdentitySpec is a container's `identity` as its manifest declares it.
|
||
type IdentitySpec struct {
|
||
// Mode is IdentityEnv or IdentityRun.
|
||
Mode string
|
||
// UserEnv and GroupEnv are the environment variables an env image reads, such as PUID and PGID.
|
||
UserEnv, GroupEnv string
|
||
}
|
||
|
||
// ModuleIdentity is one module's allocated identity: its numbers, the same on every machine.
|
||
type ModuleIdentity struct {
|
||
Module string
|
||
UID int
|
||
GID int
|
||
}
|
||
|
||
// IdentityName is the account a module's identity is on every machine it is assigned to.
|
||
func IdentityName(module string) string { return "mesh-" + module }
|
||
|
||
// Where an access's user comes from (novox/hq ADR 0325 §4, §5).
|
||
const (
|
||
FromContainer = "container"
|
||
FromToolRunner = "tool-runner"
|
||
)
|
||
|
||
// AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration
|
||
// AccessUser): the account, its user number, the use, and where it runs. The node-engine refuses a user with no
|
||
// number, so the tool runner's account is named only where its number is known (Rendering.AccountUIDs).
|
||
type AccessUser struct {
|
||
Identity string `json:"identity"`
|
||
UID int `json:"uid"`
|
||
Use string `json:"use"`
|
||
From string `json:"from"`
|
||
}
|
||
|
||
var envName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
|
||
|
||
// ParseIdentity reads a container's `identity`, or refuses it in words.
|
||
func ParseIdentity(v any) (IdentitySpec, error) {
|
||
fields, ok := v.(map[string]any)
|
||
if !ok {
|
||
return IdentitySpec{}, fmt.Errorf("identity is {\"user\": \"env:<NAME>\", \"group\": \"env:<NAME>\"} or " +
|
||
"{\"user\": \"run\"}")
|
||
}
|
||
for k := range fields {
|
||
if k != "user" && k != "group" {
|
||
return IdentitySpec{}, fmt.Errorf("identity says %q, and it says only how the image is told its user "+
|
||
"and group: \"user\" and \"group\", never an owner or a number (%s §1, §2)", k, ADR0325)
|
||
}
|
||
}
|
||
user, _ := fields["user"].(string)
|
||
group, hasGroup := fields["group"]
|
||
switch {
|
||
case user == IdentityRun:
|
||
if hasGroup {
|
||
return IdentitySpec{}, fmt.Errorf("identity {\"user\": \"run\"} takes no group: the runtime's user " +
|
||
"option carries both numbers")
|
||
}
|
||
return IdentitySpec{Mode: IdentityRun}, nil
|
||
case strings.HasPrefix(user, "env:"):
|
||
g, _ := group.(string)
|
||
if !strings.HasPrefix(g, "env:") {
|
||
return IdentitySpec{}, fmt.Errorf("identity names its user as %q and its group as %v; an image told "+
|
||
"its user by its environment is told its group the same way, \"env:<NAME>\"", user, group)
|
||
}
|
||
u, gr := strings.TrimPrefix(user, "env:"), strings.TrimPrefix(g, "env:")
|
||
if !envName.MatchString(u) || !envName.MatchString(gr) || u == gr {
|
||
return IdentitySpec{}, fmt.Errorf("identity names the variables %q and %q; each is an environment "+
|
||
"variable's name, upper-case letters, digits and underscores, and the two differ", u, gr)
|
||
}
|
||
return IdentitySpec{Mode: IdentityEnv, UserEnv: u, GroupEnv: gr}, nil
|
||
}
|
||
return IdentitySpec{}, fmt.Errorf("identity says its user is %q; it is \"env:<NAME>\" or \"run\"", user)
|
||
}
|
||
|
||
// DeclaresIdentity says a module has a container that declares `identity`: it is given one (§3).
|
||
func DeclaresIdentity(m Manifest) bool {
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) == "container" {
|
||
if _, has := r[IdentityField]; has {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// containerMounts is what a container of the manifest mounts, read from its volumes as written: the module's own
|
||
// directories by id, and its accesses by id — or, for a literal source, as "path:<path>".
|
||
func containerMounts(r map[string]any) (dirs, accesses []string) {
|
||
volumes, _ := r["volumes"].([]any)
|
||
for _, v := range volumes {
|
||
s, _ := v.(string)
|
||
source := volumeSource(s)
|
||
if strings.HasPrefix(source, "/") {
|
||
accesses = append(accesses, "path:"+filepath.Clean(source))
|
||
continue
|
||
}
|
||
for _, m := range dirRef.FindAllStringSubmatch(source, -1) {
|
||
dirs = append(dirs, m[1])
|
||
}
|
||
for _, m := range accessRef.FindAllStringSubmatch(source, -1) {
|
||
accesses = append(accesses, m[1])
|
||
}
|
||
}
|
||
return dirs, accesses
|
||
}
|
||
|
||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||
// "${dir:config}:/config" is "${dir:config}".
|
||
func volumeSource(v string) string {
|
||
depth := 0
|
||
for i, r := range v {
|
||
switch {
|
||
case r == '{' && i > 0 && v[i-1] == '$':
|
||
depth++
|
||
case r == '}' && depth > 0:
|
||
depth--
|
||
case r == ':' && depth == 0:
|
||
return v[:i]
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
// accessMode is the mode an access a container mounts was declared at, by id or by "path:<path>"; "" when the
|
||
// manifest declares no such access.
|
||
func accessMode(m Manifest, ref string) string {
|
||
for _, a := range m.Accesses {
|
||
if (a.ID != "" && a.ID == ref) || (a.Path != "" && ref == "path:"+filepath.Clean(a.Path)) {
|
||
return a.At()
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ADR0325 is how the module check names the decision its identity rules come from.
|
||
const ADR0325 = "novox/hq ADR 0325"
|
||
|
||
// identityProblems is what the module check refuses of a manifest's identities (novox/hq ADR 0325 §1, §2, §3):
|
||
//
|
||
// - an `identity` on anything but a container, or one that is neither env:<NAME> for both nor run;
|
||
// - **in a module that declares `identity` on a container** — it says it runs as its own identity, so it is held
|
||
// to the decision whole: a directory of it naming an owner (§1, §3), and a container of it that declares none
|
||
// and mounts one of the module's directories or a read-write access (§2).
|
||
//
|
||
// A module that declares no `identity` is warned of the last two (IdentityWarnings), never refused for them yet:
|
||
// every registered container module mounts its own directories without one, and refusing them would stop the
|
||
// catalogues composing before they can migrate. **This is the narrowest rule that breaks nothing registered**: no
|
||
// registered manifest declares `identity`, so no registered module is refused anything new.
|
||
func identityProblems(m Manifest) []string {
|
||
var problems []string
|
||
for _, r := range m.Resources {
|
||
v, has := r[IdentityField]
|
||
if !has {
|
||
continue
|
||
}
|
||
if fmt.Sprint(r["type"]) != "container" {
|
||
problems = append(problems, fmt.Sprintf("%s: %v is a %v and says identity; only a container says how its "+
|
||
"image is told who to run as (%s §2)", m.Module, r["id"], r["type"], ADR0325))
|
||
continue
|
||
}
|
||
if _, err := ParseIdentity(v); err != nil {
|
||
problems = append(problems, fmt.Sprintf("%s: the container %v: %v (%s §2)", m.Module, r["id"], err, ADR0325))
|
||
}
|
||
}
|
||
if !DeclaresIdentity(m) {
|
||
return problems
|
||
}
|
||
return append(problems, identityRules(m)...)
|
||
}
|
||
|
||
// IdentityWarnings is what the module check warns of a module that declares no `identity` (novox/hq ADR 0325): each
|
||
// thing the decision refuses once the module migrates, said now so the catalogues can. Refusing comes when no
|
||
// registered module still does any of it. Empty for a module that declares `identity`: it is refused these instead.
|
||
func IdentityWarnings(m Manifest) []string {
|
||
if DeclaresIdentity(m) {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, p := range identityRules(m) {
|
||
out = append(out, p+"; accepted while registered modules still do it, refused once they have moved")
|
||
}
|
||
return out
|
||
}
|
||
|
||
// identityRules is every place a manifest breaks what ADR 0325 §1–§3 refuse: a directory naming an owner, and a
|
||
// container declaring no identity that mounts the module's own directory or a read-write access.
|
||
func identityRules(m Manifest) []string {
|
||
var out []string
|
||
for _, r := range m.Resources {
|
||
switch fmt.Sprint(r["type"]) {
|
||
case "directory":
|
||
if owner, said := r["owner"]; said {
|
||
out = append(out, fmt.Sprintf("%s: the directory %v names its owner %v; a directory a module owns is "+
|
||
"owned by the module's identity, and no manifest names an owner (%s §1, §3)", m.Module, r["id"],
|
||
owner, ADR0325))
|
||
}
|
||
case "container":
|
||
if _, has := r[IdentityField]; has {
|
||
continue
|
||
}
|
||
dirs, accesses := containerMounts(r)
|
||
seen := map[string]bool{}
|
||
for _, d := range dirs {
|
||
if !seen[d] {
|
||
seen[d] = true
|
||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the module's "+
|
||
"own directory %s; only a container that declares identity mounts its module's directories, "+
|
||
"so what writes them is the module's identity (%s §2)", m.Module, r["id"], d, ADR0325))
|
||
}
|
||
}
|
||
for _, a := range accesses {
|
||
if accessMode(m, a) == AccessReadWrite && !seen["access:"+a] {
|
||
seen["access:"+a] = true
|
||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the read-write "+
|
||
"access %s; a container that runs as its image says is given no entry on a shared folder, so "+
|
||
"it is refused write (%s §2)", m.Module, r["id"], strings.TrimPrefix(a, "path:"), ADR0325))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that declares `identity` (novox/hq ADR
|
||
// 0325 §1, §3): its directories are owned by its identity. Nil for a module that declares none, whose placed owner
|
||
// is still applied, and warned of by PlacesOwnerWarnings.
|
||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||
if !DeclaresIdentity(m) {
|
||
return nil
|
||
}
|
||
if owned := placedOwners(m, layers); len(owned) > 0 {
|
||
return fmt.Errorf("%s places %s with an owner, and declares identity: its directories are owned by its "+
|
||
"identity, and no setting names an owner (%s §1, §3)", m.Module, strings.Join(owned, ", "), ADR0325)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PlacesOwnerWarnings is a warning for each directory a `places` setting gives an owner, for a module that declares
|
||
// no `identity` (novox/hq ADR 0325 §1): accepted while registered modules still do it, refused once they have moved.
|
||
func PlacesOwnerWarnings(m Manifest, layers []Layer) []string {
|
||
if DeclaresIdentity(m) {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, id := range placedOwners(m, layers) {
|
||
out = append(out, fmt.Sprintf("warning: %s's setting places.%s.owner names an owner; a directory a module owns "+
|
||
"is owned by the module's identity, and no setting names an owner (%s §1, §3). Accepted while "+
|
||
"registered modules still do it, refused once they have moved", m.Module, id, ADR0325))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func placedOwners(m Manifest, layers []Layer) []string {
|
||
placed, err := Places(m, layers)
|
||
if err != nil {
|
||
// empty-on-error: a placement Places refuses is refused by name where it is judged and composed; it names no owner here
|
||
return nil
|
||
}
|
||
var out []string
|
||
for id, p := range placed {
|
||
if p.Owner != "" {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// resolvedIdentity is a container's `identity` as the node-engine is told it: the mode, the variables an env image
|
||
// reads, and the module's numbers.
|
||
func resolvedIdentity(spec IdentitySpec, id ModuleIdentity) map[string]any {
|
||
out := map[string]any{"mode": spec.Mode, "uid": id.UID, "gid": id.GID}
|
||
if spec.Mode == IdentityEnv {
|
||
out["user_env"], out["group_env"] = spec.UserEnv, spec.GroupEnv
|
||
}
|
||
return out
|
||
}
|
||
|
||
// identityFor is the identity module m runs as here, and whether one is composed: a module that declares `identity`,
|
||
// to an engine that keeps folders, with an allocation the composition was given. A number outside the range is
|
||
// refused (§11), never sent.
|
||
func identityFor(m Manifest, with Rendering) (ModuleIdentity, bool, error) {
|
||
if !with.KeepsFolders || !DeclaresIdentity(m) {
|
||
return ModuleIdentity{}, false, nil
|
||
}
|
||
id, allocated := with.Identities[m.Module]
|
||
if !allocated {
|
||
return ModuleIdentity{}, false, nil
|
||
}
|
||
if !AllocatableID(id.UID) || !AllocatableID(id.GID) {
|
||
return ModuleIdentity{}, false, fmt.Errorf("%s's identity is %d:%d, outside %d–%d: no module identity is root "+
|
||
"or a root group's, and none is sent (%s §11)", m.Module, id.UID, id.GID, IdentityLow, IdentityHigh, ADR0325)
|
||
}
|
||
id.Module = m.Module
|
||
return id, true, nil
|
||
}
|
||
|
||
// identityInto composes one resource of module m for its identity: a container's `identity` resolved, or taken out
|
||
// where none is composed; and the owner of a directory one of its identity's containers mounts.
|
||
func identityInto(resource map[string]any, m Manifest, id ModuleIdentity, has bool, mountedByIdentity map[string]bool) error {
|
||
switch fmt.Sprint(resource["type"]) {
|
||
case "container":
|
||
v, declared := resource[IdentityField]
|
||
if !declared {
|
||
return nil
|
||
}
|
||
if !has {
|
||
delete(resource, IdentityField)
|
||
return nil
|
||
}
|
||
spec, err := ParseIdentity(v)
|
||
if err != nil {
|
||
return fmt.Errorf("%s: the container %v: %w", m.Module, resource["id"], err)
|
||
}
|
||
resource[IdentityField] = resolvedIdentity(spec, id)
|
||
case "directory":
|
||
if has && mountedByIdentity[fmt.Sprint(resource["id"])] {
|
||
resource["owner"] = fmt.Sprintf("%d:%d", id.UID, id.GID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// dirsMountedByIdentity is the ids of module m's directories one of its containers declaring `identity` mounts.
|
||
func dirsMountedByIdentity(m Manifest) map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) != "container" {
|
||
continue
|
||
}
|
||
if _, has := r[IdentityField]; !has {
|
||
continue
|
||
}
|
||
dirs, _ := containerMounts(r)
|
||
for _, d := range dirs {
|
||
out[d] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// identitiesOf is the declaration's `identities`, by module.
|
||
func identitiesOf(ids []ModuleIdentity) []map[string]any {
|
||
sort.Slice(ids, func(i, j int) bool { return ids[i].Module < ids[j].Module })
|
||
out := make([]map[string]any, 0, len(ids))
|
||
for _, id := range ids {
|
||
out = append(out, map[string]any{"module": id.Module, "name": IdentityName(id.Module), "uid": id.UID,
|
||
"gid": id.GID})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||
return ""
|
||
}
|
||
loads := false
|
||
for _, b := range m.Bundles {
|
||
if len(b.Loads) > 0 {
|
||
loads = true
|
||
}
|
||
}
|
||
if !loads {
|
||
return ""
|
||
}
|
||
if m.RunsAs != "" {
|
||
return m.RunsAs
|
||
}
|
||
return r.Account
|
||
}
|
||
|
||
// usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity
|
||
// where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the
|
||
// module's tools run as it and its number is known, each at the access's use (§4: the engine keeps the widest).
|
||
func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string,
|
||
with Rendering) {
|
||
var containers []map[string]any
|
||
for _, res := range out {
|
||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module {
|
||
if _, declares := res[IdentityField]; declares && has {
|
||
containers = append(containers, res)
|
||
}
|
||
}
|
||
}
|
||
runnerUID, runnerKnown := with.AccountUIDs[account]
|
||
for _, res := range out {
|
||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module {
|
||
continue
|
||
}
|
||
path, _ := res["path"].(string)
|
||
mode, _ := res["mode"].(string)
|
||
if mode == "" {
|
||
mode = AccessRead
|
||
}
|
||
users := []AccessUser{}
|
||
for _, c := range containers {
|
||
if mounts(c, path) {
|
||
users = append(users, AccessUser{Identity: IdentityName(m.Module), UID: id.UID, Use: mode, From: FromContainer})
|
||
break // one identity per module, whichever of its containers mounts it
|
||
}
|
||
}
|
||
if account != "" && runnerKnown && runnerUID > 0 {
|
||
users = append(users, AccessUser{Identity: account, UID: runnerUID, Use: mode, From: FromToolRunner})
|
||
}
|
||
if len(users) > 0 {
|
||
res["users"] = users
|
||
}
|
||
res["taken"] = with.KeptFolders[filepath.Clean(path)]
|
||
}
|
||
}
|
||
|
||
// mounts says whether a composed container mounts path, or something below it, from the machine. A path that merely
|
||
// begins with the same letters — /storage/media2 beside /storage/media — is another path.
|
||
func mounts(container map[string]any, path string) bool {
|
||
volumes, _ := container["volumes"].([]any)
|
||
path = filepath.Clean(path)
|
||
for _, v := range volumes {
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
source, _, _ := strings.Cut(s, ":")
|
||
if !strings.HasPrefix(source, "/") {
|
||
continue // a named volume: the runtime's, not the operator's path
|
||
}
|
||
source = filepath.Clean(source)
|
||
if source == path || strings.HasPrefix(source, path+"/") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||
// definition's default; false where neither says, or the module declares no such access.
|
||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||
_, byID, err := accessesFor(m, layers)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
path, has := byID[id]
|
||
if !has || path == "" {
|
||
return "", false
|
||
}
|
||
return filepath.Clean(path), true
|
||
}
|