The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
333 lines
11 KiB
Go
333 lines
11 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// What a move does to a module's containers (novox/hq ADR 0245).
|
|
//
|
|
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
|
|
// variable, a health check adopted. A module whose containers are recreated in one send is down while
|
|
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
|
|
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
|
|
// send says, per module, how many of its containers it recreates and whether any image changed, before
|
|
// it is sent and in the plan that sent it.
|
|
|
|
// Recreation is what moving a module from one build's manifest to another's does to its containers.
|
|
type Recreation struct {
|
|
// Containers is how many containers the new build declares.
|
|
Containers int
|
|
// Recreated are the ids of the containers whose declaration differs — changed, added or gone —
|
|
// sorted.
|
|
Recreated []string
|
|
// Images are the ids among them whose image changed (or that are added or gone).
|
|
Images []string
|
|
}
|
|
|
|
// Recreates compares two builds of a module, container by container, by resource id.
|
|
func Recreates(from, to Manifest) Recreation {
|
|
before := containersByID(from)
|
|
after := containersByID(to)
|
|
var r Recreation
|
|
r.Containers = len(after)
|
|
for id, now := range after {
|
|
was, held := before[id]
|
|
switch {
|
|
case !held:
|
|
r.Recreated = append(r.Recreated, id)
|
|
r.Images = append(r.Images, id)
|
|
case !sameDeclaration(was, now):
|
|
r.Recreated = append(r.Recreated, id)
|
|
if !sameDeclaration(was["image"], now["image"]) {
|
|
r.Images = append(r.Images, id)
|
|
}
|
|
}
|
|
}
|
|
for id := range before {
|
|
if _, kept := after[id]; !kept {
|
|
r.Recreated = append(r.Recreated, id)
|
|
r.Images = append(r.Images, id)
|
|
}
|
|
}
|
|
sort.Strings(r.Recreated)
|
|
sort.Strings(r.Images)
|
|
return r
|
|
}
|
|
|
|
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
|
|
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
|
|
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
|
|
|
|
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
|
|
func (r Recreation) Say(module string) string {
|
|
if len(r.Recreated) == 0 {
|
|
return ""
|
|
}
|
|
what := "with a new image"
|
|
switch {
|
|
case r.SpecOnly():
|
|
what = "no new image, only their declaration"
|
|
case len(r.Images) < len(r.Recreated):
|
|
what = fmt.Sprintf("%d with a new image", len(r.Images))
|
|
}
|
|
whole := ""
|
|
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
|
|
whole = ", every one at once: its service is interrupted until they are up again"
|
|
} else if len(r.Recreated) > 1 {
|
|
whole = ", at once: what they serve is interrupted until they are up again"
|
|
}
|
|
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
|
|
what, strings.Join(r.Recreated, ", "), whole)
|
|
}
|
|
|
|
func containersByID(m Manifest) map[string]map[string]any {
|
|
out := map[string]map[string]any{}
|
|
for _, r := range m.Resources {
|
|
if t, _ := r["type"].(string); t != "container" {
|
|
continue
|
|
}
|
|
id, _ := r["id"].(string)
|
|
out[id] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
|
|
// float are one value.
|
|
func sameDeclaration(a, b any) bool {
|
|
ra, errA := json.Marshal(a)
|
|
rb, errB := json.Marshal(b)
|
|
if errA != nil || errB != nil {
|
|
return reflect.DeepEqual(a, b)
|
|
}
|
|
var na, nb any
|
|
_ = json.Unmarshal(ra, &na)
|
|
_ = json.Unmarshal(rb, &nb)
|
|
return reflect.DeepEqual(na, nb)
|
|
}
|
|
|
|
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
|
|
//
|
|
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
|
|
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
|
|
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
|
|
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
|
|
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
|
|
// data still waits.
|
|
|
|
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
|
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
|
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
|
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
|
|
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
|
|
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
|
|
// nothing — its tool list — may differ.
|
|
func Holds(from, to Manifest) string {
|
|
module := to.Module
|
|
if module == "" {
|
|
module = from.Module
|
|
}
|
|
if said := Recreates(from, to).Say(module); said != "" {
|
|
return said
|
|
}
|
|
if !sameDeclaration(from.Data, to.Data) {
|
|
return "it changes " + module + "'s data declaration"
|
|
}
|
|
for _, f := range []struct {
|
|
what string
|
|
was, s any
|
|
}{
|
|
{"what it provides", from.Provides, to.Provides},
|
|
{"what it requires", from.Requires, to.Requires},
|
|
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
|
|
{"what it contributes to a provider", from.Contributes, to.Contributes},
|
|
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
|
|
// A setting is filled into the files the module writes: a changed default changes what a container may
|
|
// mount without the file's own declaration changing.
|
|
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
|
|
} {
|
|
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
|
|
return "it changes " + f.what
|
|
}
|
|
}
|
|
mounted := append(mountSources(from), mountSources(to)...)
|
|
data := append(dataPaths(from), dataPaths(to)...)
|
|
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
|
|
before, after := resourcesByID(from), resourcesByID(to)
|
|
var changed []string
|
|
for id, r := range after {
|
|
if was, held := before[id]; !held || !sameDeclaration(was, r) {
|
|
changed = append(changed, id)
|
|
}
|
|
}
|
|
for id := range before {
|
|
if _, kept := after[id]; !kept {
|
|
changed = append(changed, id)
|
|
}
|
|
}
|
|
sort.Strings(changed)
|
|
dirs := absoluteDirectories(from, to)
|
|
for _, key := range changed {
|
|
for _, r := range []map[string]any{before[key], after[key]} {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
|
|
switch kind {
|
|
case "file", "directory":
|
|
for _, p := range pathsOf(r, dirs) {
|
|
for _, m := range mounted {
|
|
if pathsMeet(p, expandDir(m.source, dirs)) {
|
|
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
|
|
}
|
|
}
|
|
for _, d := range data {
|
|
if pathsMeet(p, expandDir(d.path, dirs)) {
|
|
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
|
|
}
|
|
}
|
|
}
|
|
case "process", "service":
|
|
if keepsData {
|
|
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
|
|
}
|
|
default:
|
|
// Anything that is not a file or directory (judged above by whether a container mounts it
|
|
// or it holds data) always holds: an archive, a package, a user, a network, or any type
|
|
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
|
|
// fields that recreate nothing).
|
|
return fmt.Sprintf("it changes the %s %s", kind, id)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type mountSource struct{ container, source string }
|
|
|
|
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
|
|
// environment files.
|
|
func mountSources(m Manifest) []mountSource {
|
|
var out []mountSource
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) != "container" {
|
|
continue
|
|
}
|
|
id := fmt.Sprint(r["id"])
|
|
vols, _ := r["volumes"].([]any)
|
|
for _, v := range vols {
|
|
s, _ := v.(string)
|
|
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
|
|
out = append(out, mountSource{id, mount[1]})
|
|
}
|
|
}
|
|
switch files := r["env-file"].(type) {
|
|
case string:
|
|
out = append(out, mountSource{id, files})
|
|
case []any:
|
|
for _, f := range files {
|
|
if s, ok := f.(string); ok {
|
|
out = append(out, mountSource{id, s})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type dataPath struct{ id, path string }
|
|
|
|
func dataPaths(m Manifest) []dataPath {
|
|
if m.Data == nil {
|
|
return nil
|
|
}
|
|
var out []dataPath
|
|
for _, it := range m.Data.Own {
|
|
out = append(out, dataPath{it.ID, it.Path})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resourcesByID(m Manifest) map[string]map[string]any {
|
|
out := map[string]map[string]any{}
|
|
for _, r := range m.Resources {
|
|
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
|
|
func absoluteDirectories(ms ...Manifest) map[string]string {
|
|
out := map[string]string{}
|
|
for _, m := range ms {
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) != "directory" {
|
|
continue
|
|
}
|
|
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
|
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
|
|
func pathsOf(r map[string]any, dirs map[string]string) []string {
|
|
var out []string
|
|
if p, ok := r["path"].(string); ok && p != "" {
|
|
out = append(out, expandDir(p, dirs))
|
|
}
|
|
if fmt.Sprint(r["type"]) == "directory" {
|
|
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
|
|
// either way is one path.
|
|
func expandDir(p string, dirs map[string]string) string {
|
|
p = strings.TrimRight(p, "/")
|
|
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
|
|
if abs, ok := dirs[ref[1]]; ok {
|
|
return abs
|
|
}
|
|
return p
|
|
}
|
|
if strings.HasPrefix(p, "${dir:") {
|
|
end := strings.Index(p, "}")
|
|
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
|
|
return abs + p[end+1:]
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// pathsMeet is whether one path is the other or lies within it.
|
|
func pathsMeet(a, b string) bool {
|
|
if a == "" || b == "" {
|
|
return false
|
|
}
|
|
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
|
}
|
|
|
|
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
|
|
func emptyAsNil(v any) any {
|
|
rv := reflect.ValueOf(v)
|
|
switch rv.Kind() {
|
|
case reflect.Slice, reflect.Map:
|
|
if rv.Len() == 0 {
|
|
return nil
|
|
}
|
|
case reflect.Ptr:
|
|
if rv.IsNil() {
|
|
return nil
|
|
}
|
|
}
|
|
return v
|
|
}
|