`secret rotate <node> <module> <name>` makes the secret anew the way the first mint did, seals it
to the machine and the operator, and sends the machine, so the module starts again on the new value
— said in the log with who asked and when, never the value. Only for a secret whose definition says
`"taken": "at-start"`: an own secret is now a path, or {path, taken}, and a definition that says
nothing of how a secret is taken is refused with the word to write, because a credential rotated
under software that never reads it again is worse than one left alone (issue 179). `applied` is
refused by name until the staged form ADR 0114 decided is built; a value given to the mesh is
refused as ADR 0113 says. `rotate` is a verb on the controller's seat with two shapes — a pair
credential by provision, an own secret by machine, module and name — so the console can ask.
Registered manifests keep their bytes: a path alone is written back as a path.
374 lines
13 KiB
Go
374 lines
13 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// A directory the mesh places (novox/hq ADR 0112, to-be 27, issue 119).
|
|
//
|
|
// **A module definition names no host path.** A directory resource may omit `path`; the mesh
|
|
// resolves where it lands when the declaration is composed — `<root>/<module>/<id>`, the root a
|
|
// node's own setting with /var/lib as the default. From then on the module's own files, mounts
|
|
// and environment name the place as `${dir:<id>}`, the same shape as `${bound:…}` and
|
|
// `${secret:…}`: a fact the module asks for by name and never states.
|
|
//
|
|
// **A directory that states a path keeps it, and still answers `${dir:<id>}`.** That is the
|
|
// placement for an adopted machine: data that must sit where the predecessor already put it is
|
|
// declared with the path as the exception it is, and everything else in the module names it by
|
|
// id — so moving it later is one line, not a search.
|
|
//
|
|
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
|
|
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
|
|
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
|
|
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
|
|
// `${dir:<id>}` and states no path.
|
|
//
|
|
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
|
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
|
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
|
|
|
// defaultDataRoot is where module data lands when a node states no root of its own.
|
|
const defaultDataRoot = "/var/lib"
|
|
|
|
// The two places a pathless directory may name, beside its own id.
|
|
const (
|
|
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
|
|
// assignment, which every other placed thing of the module sits beneath.
|
|
placeOwn = "."
|
|
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
|
|
placeMesh = "mesh"
|
|
)
|
|
|
|
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
|
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
|
|
|
// dataRoot is the root this node keeps placed directories under.
|
|
func dataRoot(with Rendering) string {
|
|
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
|
|
return root
|
|
}
|
|
return defaultDataRoot
|
|
}
|
|
|
|
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
|
//
|
|
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
|
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
|
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
|
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
|
// immediately.
|
|
//
|
|
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
|
|
// filled after the directories it can name are resolved; one level, because a directory beneath
|
|
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
|
|
func dirsFor(m Manifest, with Rendering) map[string]string {
|
|
dirs := map[string]string{}
|
|
var beneath []map[string]any
|
|
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
|
|
// malformed; here an invalid setting simply places nothing.
|
|
placed, _ := Places(m, with.Settings[m.Module])
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) != "directory" {
|
|
continue
|
|
}
|
|
id := fmt.Sprint(r["id"])
|
|
if p, said := placed[id]; said {
|
|
dirs[id] = p.Path
|
|
continue
|
|
}
|
|
if path, stated := r["path"].(string); stated && path != "" {
|
|
if strings.HasPrefix(path, "${dir:") {
|
|
beneath = append(beneath, r)
|
|
continue
|
|
}
|
|
dirs[id] = strings.TrimRight(path, "/")
|
|
continue
|
|
}
|
|
switch place, _ := r["place"].(string); place {
|
|
case placeOwn:
|
|
dirs[id] = dataRoot(with) + "/" + m.Module
|
|
case placeMesh:
|
|
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
|
|
default:
|
|
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
|
}
|
|
}
|
|
for _, r := range beneath {
|
|
path := strings.TrimRight(r["path"].(string), "/")
|
|
// A reference to no directory is left as written and refused where the resource is
|
|
// placed (dirInto), with the message that names what exists.
|
|
filled, _ := dirFill(path, dirs, m.Module)
|
|
dirs[fmt.Sprint(r["id"])] = filled
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or
|
|
// beginning with a placed reference — resolution makes it absolute before anything reads it.
|
|
// (unknownDirRefs is what checks the reference names a real directory.)
|
|
func placedOrAbsolute(path string) bool {
|
|
return strings.HasPrefix(path, "/") ||
|
|
(strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path))
|
|
}
|
|
|
|
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
|
|
func dirFill(s string, dirs map[string]string, module string) (string, error) {
|
|
var missing error
|
|
out := dirRef.ReplaceAllStringFunc(s, func(ref string) string {
|
|
id := dirRef.FindStringSubmatch(ref)[1]
|
|
path, has := dirs[id]
|
|
if !has {
|
|
missing = fmt.Errorf(
|
|
"%s says ${dir:%s}, and %s declares no directory %q. It declares %s",
|
|
module, id, module, id, orNothing(namesOfDirs(dirs)))
|
|
return ref
|
|
}
|
|
return path
|
|
})
|
|
return out, missing
|
|
}
|
|
|
|
// placedManifest is the manifest with every path the mesh resolves already resolved: the maps
|
|
// naming where bindings, credentials and contributions land are filled against this node's
|
|
// placed directories, so everything downstream — the generated binding files, the sealed
|
|
// secrets, the grant directories — reads a concrete place and learns nothing new.
|
|
func placedManifest(m Manifest, with Rendering) (Manifest, error) {
|
|
dirs := dirsFor(m, with)
|
|
fillMap := func(in map[string]string) (map[string]string, error) {
|
|
if len(in) == 0 {
|
|
return in, nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for key, value := range in {
|
|
filled, err := dirFill(value, dirs, m.Module)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[key] = filled
|
|
}
|
|
return out, nil
|
|
}
|
|
var err error
|
|
if m.Receives, err = fillMap(m.Receives); err != nil {
|
|
return m, err
|
|
}
|
|
if m.Binds, err = fillMap(m.Binds); err != nil {
|
|
return m, err
|
|
}
|
|
if m.Secrets, err = fillMap(m.Secrets); err != nil {
|
|
return m, err
|
|
}
|
|
if len(m.OwnSecrets) > 0 {
|
|
own := make(OwnSecrets, len(m.OwnSecrets))
|
|
for name, s := range m.OwnSecrets {
|
|
filled, err := dirFill(s.Path, dirs, m.Module)
|
|
if err != nil {
|
|
return m, err
|
|
}
|
|
own[name] = OwnSecret{Path: filled, Taken: s.Taken}
|
|
}
|
|
m.OwnSecrets = own
|
|
}
|
|
if m.Grants, err = fillMap(m.Grants); err != nil {
|
|
return m, err
|
|
}
|
|
if len(m.SecretsMany) > 0 {
|
|
many := make(map[string]map[string]string, len(m.SecretsMany))
|
|
for to, locals := range m.SecretsMany {
|
|
if many[to], err = fillMap(locals); err != nil {
|
|
return m, err
|
|
}
|
|
}
|
|
m.SecretsMany = many
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// dirInto places a resource: a pathless directory is given the path the mesh resolved for it,
|
|
// and every ${dir:…} the resource carries — in its path, its content, its mounts, its
|
|
// environment and its env-files — becomes that path.
|
|
//
|
|
// A reference naming no directory of this module is refused. Left as written, the literal
|
|
// `${dir:x}` would reach the machine as a path, and the runtime would create and mount a
|
|
// directory called `${dir:x}` — real, wrong, and named after the mistake.
|
|
func dirInto(resource map[string]any, dirs map[string]string, module string) error {
|
|
fill := func(s string) (string, error) { return dirFill(s, dirs, module) }
|
|
|
|
if fmt.Sprint(resource["type"]) == "directory" {
|
|
id := fmt.Sprint(resource["id"])
|
|
if path, stated := resource["path"].(string); !stated || path == "" {
|
|
resource["path"] = dirs[id]
|
|
}
|
|
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
|
// such field — resolved, the place IS the path.
|
|
delete(resource, "place")
|
|
}
|
|
|
|
var err error
|
|
if path, ok := resource["path"].(string); ok {
|
|
if resource["path"], err = fill(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if content, ok := resource["content"].(string); ok {
|
|
if resource["content"], err = fill(content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
|
|
// manifest's own map, and the manifest is composed once per node — a fill written in place
|
|
// would leave the first node's paths inside every later composition.
|
|
if volumes, ok := resource["volumes"].([]any); ok {
|
|
filled := make([]any, len(volumes))
|
|
for i, v := range volumes {
|
|
filled[i] = v
|
|
if mount, ok := v.(string); ok {
|
|
if filled[i], err = fill(mount); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["volumes"] = filled
|
|
}
|
|
if env, ok := resource["env"].(map[string]any); ok {
|
|
filled := make(map[string]any, len(env))
|
|
for key, v := range env {
|
|
filled[key] = v
|
|
if value, ok := v.(string); ok {
|
|
if filled[key], err = fill(value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["env"] = filled
|
|
}
|
|
if files, ok := resource["env-file"].([]any); ok {
|
|
filled := make([]any, len(files))
|
|
for i, v := range files {
|
|
filled[i] = v
|
|
if path, ok := v.(string); ok {
|
|
if filled[i], err = fill(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["env-file"] = filled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
|
|
// declares — refused where the author is, not at composition on some later day (the same
|
|
// near-versus-far reasoning as the host's strict parse).
|
|
func (m Manifest) unknownDirRefs() []string {
|
|
declared := map[string]bool{}
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) == "directory" {
|
|
declared[fmt.Sprint(r["id"])] = true
|
|
}
|
|
}
|
|
referenced := func(s string) []string {
|
|
var ids []string
|
|
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
|
|
ids = append(ids, match[1])
|
|
}
|
|
return ids
|
|
}
|
|
var problems []string
|
|
for _, r := range m.Resources {
|
|
place, said := r["place"].(string)
|
|
if !said {
|
|
continue
|
|
}
|
|
if fmt.Sprint(r["type"]) != "directory" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s says place on %v, which is not a directory — only a directory is placed",
|
|
m.Module, r["id"]))
|
|
continue
|
|
}
|
|
if path, stated := r["path"].(string); stated && path != "" {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s states both path and place on %v — a stated path IS the placement",
|
|
m.Module, r["id"]))
|
|
}
|
|
if place != placeOwn && place != placeMesh {
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
|
|
"%q — where the mesh keeps what it writes for the module",
|
|
m.Module, place, r["id"], placeOwn, placeMesh))
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
refuse := func(id string, where any) {
|
|
if declared[id] || seen[id] {
|
|
return
|
|
}
|
|
seen[id] = true
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
|
|
"cannot place would reach the machine as a literal path",
|
|
m.Module, id, where, id))
|
|
}
|
|
for _, r := range m.Resources {
|
|
for _, field := range []string{"path", "content"} {
|
|
if s, ok := r[field].(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
for _, field := range []string{"volumes", "env-file"} {
|
|
if list, ok := r[field].([]any); ok {
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if env, ok := r["env"].(map[string]any); ok {
|
|
for _, v := range env {
|
|
if s, ok := v.(string); ok {
|
|
for _, id := range referenced(s) {
|
|
refuse(id, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
maps := map[string]map[string]string{
|
|
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
|
|
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
|
|
}
|
|
for field, entries := range maps {
|
|
for _, value := range entries {
|
|
for _, id := range referenced(value) {
|
|
refuse(id, field)
|
|
}
|
|
}
|
|
}
|
|
for to, locals := range m.SecretsMany {
|
|
for _, value := range locals {
|
|
for _, id := range referenced(value) {
|
|
refuse(id, "secrets."+to)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(problems)
|
|
return problems
|
|
}
|
|
|
|
func namesOfDirs(dirs map[string]string) []string {
|
|
var names []string
|
|
for id := range dirs {
|
|
names = append(names, fmt.Sprintf("%q", id))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|