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-` 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:\", \"group\": \"env:\"} 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:\"", 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:\" 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:". 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:"; "" 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: 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 }