A take is a comparison: the host's facts, former targets, and strays (hq ADR 0163)
Every held thing carries what a take compares: for a found container its image and the image's date, the networks it is on and the other containers on each, its mounts and published ports, beside the declared image (and its date once pulled), ports and volumes, with the downgrade decided when both dates are known; for a found file whether the declared content differs and how, as lines lost and lines new. A resource whose target moved keeps the former target on record as an orphan, so the next apply removes the container or file the host wrote under the old name (issue 097). Every apply reports the strays: containers the mesh neither wrote nor holds.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A take is a comparison (novox/hq ADR 0163): while a module's container is held, the host reports
|
||||
// the found image and its age beside the declared one, the networks and who else is on them, the
|
||||
// mounts and the ports — and says when the declared image is the older.
|
||||
func TestAHeldContainerCarriesTheFactsATakeCompares(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
m.containers["hello-web"].image = "web:1.27"
|
||||
m.containers["hello-web"].imageID = "sha256:found"
|
||||
m.containers["hello-web"].networks = []string{"predecessor_default"}
|
||||
m.containers["hello-web"].mounts = []string{"/srv/web:/data"}
|
||||
m.containers["hello-web"].ports = []string{"80/tcp>0.0.0.0:8080"}
|
||||
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z", pinned: "2026-08-20T10:00:00Z"}
|
||||
m.members = map[string][]string{"predecessor_default": {"hello-web", "office", "db"}}
|
||||
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
h, ok := state.HeldAt("hello-web.server")
|
||||
if !ok || h.Facts == nil {
|
||||
t.Fatalf("a held container carries no facts: %+v", h)
|
||||
}
|
||||
f := h.Facts
|
||||
if f.Image != "web:1.27" || f.ImageCreated != "2026-09-17T10:00:00Z" {
|
||||
t.Errorf("the found image and its age: %+v", f)
|
||||
}
|
||||
if f.DeclaredImage != pinned || f.DeclaredImageCreated != "2026-08-20T10:00:00Z" || !f.Downgrade {
|
||||
t.Errorf("the declared image, its age, and that it is a downgrade: %+v", f)
|
||||
}
|
||||
if got := f.Networks["predecessor_default"]; len(got) != 2 || got[0] != "db" || got[1] != "office" {
|
||||
t.Errorf("the neighbours on the found network, without the container itself: %v", f.Networks)
|
||||
}
|
||||
if len(f.Mounts) != 1 || f.Mounts[0] != "/srv/web:/data" || len(f.Ports) != 1 || f.Ports[0] != "80/tcp>0.0.0.0:8080" {
|
||||
t.Errorf("mounts and ports as found: %+v", f)
|
||||
}
|
||||
// And the held file carries how the declared content differs from what was found.
|
||||
p, ok := state.HeldAt("hello-web.page")
|
||||
if !ok || p.Facts == nil || !p.Facts.Differs {
|
||||
t.Fatalf("a held file that differs from the declared content does not say so: %+v", p)
|
||||
}
|
||||
joined := strings.Join(p.Facts.Difference, "\n")
|
||||
if !strings.Contains(joined, "- the predecessor's page") || !strings.Contains(joined, "+ the mesh's page") {
|
||||
t.Errorf("the difference does not show what is lost and what is new: %q", joined)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, "unused"))
|
||||
}
|
||||
|
||||
// A declared image not yet on the machine leaves its age unknown and the comparison undecided.
|
||||
func TestAnImageNotYetPulledLeavesTheDowngradeUndecided(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
m.containers["hello-web"].image = "web:1.27"
|
||||
m.containers["hello-web"].imageID = "sha256:found"
|
||||
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z"}
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
h, _ := state.HeldAt("hello-web.server")
|
||||
if h.Facts == nil || h.Facts.DeclaredImageCreated != "" || h.Facts.Downgrade {
|
||||
t.Fatalf("an unknown declared age decided a downgrade: %+v", h.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDifferenceIsWhatIsLostAndWhatIsNew(t *testing.T) {
|
||||
differs, lines := differenceOf("a\nprivate scope: local\nb\n", "a\nb\nupstream: public\n")
|
||||
if !differs || len(lines) != 2 || lines[0] != "- private scope: local" || lines[1] != "+ upstream: public" {
|
||||
t.Fatalf("got %v %v", differs, lines)
|
||||
}
|
||||
if differs, lines := differenceOf("same\n", "same\n"); differs || lines != nil {
|
||||
t.Fatalf("identical content differs: %v %v", differs, lines)
|
||||
}
|
||||
}
|
||||
|
||||
// What runs on the machine that the mesh neither wrote nor holds is reported (ADR 0163).
|
||||
func TestStraysAreWhatRunsHereThatNobodyAsked(t *testing.T) {
|
||||
m := &machine{containers: map[string]*fakeContainer{
|
||||
"hello-web": {id: "ours", running: true, image: "web:1"},
|
||||
"gitea-old": {id: "left-behind", running: true, image: "gitea:1.22"},
|
||||
"held-thing": {id: "found", running: true, image: "x:1"},
|
||||
}}
|
||||
known := store.State{
|
||||
Resources: []store.Applied{{ID: "hello-web.server", Type: "container", Target: "hello-web"}},
|
||||
Held: []store.Held{{ID: "other.server", Kind: "container", Target: "held-thing"}},
|
||||
}
|
||||
strays, err := Strays(context.Background(), m.run, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(strays) != 1 || strays[0].Name != "gitea-old" || !strings.Contains(strays[0].Detail, "gitea:1.22") {
|
||||
t.Fatalf("strays: %+v", strays)
|
||||
}
|
||||
}
|
||||
+125
-4
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -433,6 +434,32 @@ type foundContainer struct {
|
||||
id string
|
||||
running bool
|
||||
spec string
|
||||
// What a take compares (novox/hq ADR 0163): the image and its id, the networks the container
|
||||
// is on, its mounts and its published ports — empty from a runtime (or a test's fake) that
|
||||
// answers the short form.
|
||||
image string
|
||||
imageID string
|
||||
networks []string
|
||||
mounts []string
|
||||
ports []string
|
||||
}
|
||||
|
||||
// foundFormat is what inspectFound asks the runtime for, tab-separated: the three a hold has
|
||||
// always needed, then the facts a take compares.
|
||||
const foundFormat = "{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \"" + specLabel + "\"}}" +
|
||||
"\t{{.Config.Image}}\t{{.Image}}" +
|
||||
"\t{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}" +
|
||||
"\t{{range .Mounts}}{{.Source}}:{{.Destination}},{{end}}" +
|
||||
"\t{{range $p, $b := .NetworkSettings.Ports}}{{$p}}{{range $b}}>{{.HostIp}}:{{.HostPort}}{{end}},{{end}}"
|
||||
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inspectFound reads a container by name the way a hold needs it: its id, whether it runs, and
|
||||
@@ -451,8 +478,7 @@ func inspectFound(ctx context.Context, name string, run Runner) (foundContainer,
|
||||
if err != nil {
|
||||
return foundContainer{}, false, fmt.Errorf("%w, so nothing can be said about %q", err, name)
|
||||
}
|
||||
out, err := run(ctx, cri, "container", "inspect", "--format",
|
||||
"{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}}", name)
|
||||
out, err := run(ctx, cri, "container", "inspect", "--format", foundFormat, name)
|
||||
if err != nil {
|
||||
if absent(err) {
|
||||
return foundContainer{}, false, nil
|
||||
@@ -466,14 +492,100 @@ func inspectFound(ctx context.Context, name string, run Runner) (foundContainer,
|
||||
name, err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(out), "\t")
|
||||
for len(parts) < 3 {
|
||||
for len(parts) < 8 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
spec := strings.TrimSpace(parts[2])
|
||||
if spec == "<no value>" {
|
||||
spec = ""
|
||||
}
|
||||
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec}, true, nil
|
||||
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec,
|
||||
image: strings.TrimSpace(parts[3]), imageID: strings.TrimSpace(parts[4]),
|
||||
networks: splitList(parts[5]), mounts: splitList(parts[6]), ports: splitList(parts[7])}, true, nil
|
||||
}
|
||||
|
||||
// factsOf is what a take would compare for a found container (novox/hq ADR 0163): the found
|
||||
// image and when it was made, the networks and who else is on them, mounts and ports — beside
|
||||
// what the module declares, and the declared image's date when that image is on the machine.
|
||||
// Every question the runtime cannot answer leaves its fact empty; a preview says so rather than
|
||||
// guesses.
|
||||
func factsOf(ctx context.Context, seen foundContainer, res *declaration.Container, run Runner) *Facts {
|
||||
cri, err := containerRuntime(ctx, run)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f := &store.Facts{Image: seen.image, Mounts: seen.mounts, Ports: seen.ports,
|
||||
DeclaredImage: res.Image, DeclaredPorts: res.Ports, DeclaredVolumes: res.Volumes}
|
||||
if seen.imageID != "" {
|
||||
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", seen.imageID); err == nil {
|
||||
f.ImageCreated = strings.TrimSpace(out)
|
||||
}
|
||||
}
|
||||
if res.Image != "" {
|
||||
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", res.Image); err == nil {
|
||||
f.DeclaredImageCreated = strings.TrimSpace(out)
|
||||
}
|
||||
}
|
||||
if found, err := time.Parse(time.RFC3339Nano, f.ImageCreated); err == nil {
|
||||
if declared, err := time.Parse(time.RFC3339Nano, f.DeclaredImageCreated); err == nil {
|
||||
f.Downgrade = declared.Before(found)
|
||||
}
|
||||
}
|
||||
for _, network := range seen.networks {
|
||||
if f.Networks == nil {
|
||||
f.Networks = map[string][]string{}
|
||||
}
|
||||
var members []string
|
||||
if out, err := run(ctx, cri, "network", "inspect", "--format",
|
||||
"{{range .Containers}}{{.Name}},{{end}}", network); err == nil {
|
||||
for _, m := range splitList(out) {
|
||||
if m != res.Name {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(members)
|
||||
f.Networks[network] = members
|
||||
}
|
||||
return (*Facts)(f)
|
||||
}
|
||||
|
||||
// Facts is store.Facts, named here so hold's callers read as one vocabulary.
|
||||
type Facts = store.Facts
|
||||
|
||||
// differenceOf is how a found file differs from the declared content: the lines only the found
|
||||
// file has, marked -, then the lines only the declared content has, marked +, in their own order,
|
||||
// bounded so a report stays a report. Not a diff tool's output: the question a take answers is
|
||||
// "what would be lost and what would be new", and that is these two lists.
|
||||
func differenceOf(found, declared string) (bool, []string) {
|
||||
if found == declared {
|
||||
return false, nil
|
||||
}
|
||||
const bound = 40
|
||||
count := func(s string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
out[line]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
inFound, inDeclared := count(found), count(declared)
|
||||
var out []string
|
||||
add := func(mark, s string, other map[string]int) {
|
||||
seen := map[string]int{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
seen[line]++
|
||||
if seen[line] > other[line] && len(out) < bound {
|
||||
out = append(out, mark+" "+line)
|
||||
}
|
||||
}
|
||||
}
|
||||
add("-", found, inDeclared)
|
||||
add("+", declared, inFound)
|
||||
if len(out) >= bound {
|
||||
out = append(out, "… and more")
|
||||
}
|
||||
return true, out
|
||||
}
|
||||
|
||||
// absent is whether a runtime said the thing is not there, rather than failing to answer. Its own
|
||||
@@ -540,6 +652,12 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
|
||||
} else if digestOf(string(content)) != h.Digest {
|
||||
changed = "rewritten"
|
||||
}
|
||||
// What a take would replace it with, and how that differs (novox/hq ADR 0163): a
|
||||
// file declared whole is compared whole; one written into is not replaced at all.
|
||||
if res.Into == "" {
|
||||
differs, lines := differenceOf(string(content), res.Content)
|
||||
h.Facts = &Facts{Differs: differs, Difference: lines}
|
||||
}
|
||||
}
|
||||
case *declaration.Directory:
|
||||
info, err := os.Lstat(res.Path)
|
||||
@@ -628,6 +746,9 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
|
||||
case h.Running && !seen.running:
|
||||
changed = "stopped"
|
||||
}
|
||||
if exists {
|
||||
h.Facts = factsOf(ctx, seen, res, run)
|
||||
}
|
||||
default:
|
||||
return out, h, fmt.Errorf("a %s cannot be held", r.Kind())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,7 +19,11 @@ import (
|
||||
// label when a host made it. Every command it is asked is written down.
|
||||
type machine struct {
|
||||
containers map[string]*fakeContainer
|
||||
asked []string
|
||||
// images is what `image inspect --format {{.Created}}` answers per image or id; members is
|
||||
// what `network inspect` lists per network (ADR 0163).
|
||||
images map[string]string
|
||||
members map[string][]string
|
||||
asked []string
|
||||
// wgUp is what `wg show interfaces` answers: the tunnels up on the machine.
|
||||
wgUp string
|
||||
// handshakes is what `wg show <interface> latest-handshakes` answers, and handshakesFail the
|
||||
@@ -97,6 +102,9 @@ type fakeContainer struct {
|
||||
id string
|
||||
running bool
|
||||
spec string
|
||||
// What a take compares (ADR 0163), answered in the long inspect form when set.
|
||||
image, imageID string
|
||||
networks, mounts, ports []string
|
||||
}
|
||||
|
||||
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -140,9 +148,38 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
|
||||
running = "true"
|
||||
}
|
||||
if strings.HasPrefix(args[3], "{{.Id}}") {
|
||||
return c.id + "\t" + running + "\t" + c.spec + "\n", nil
|
||||
line := c.id + "\t" + running + "\t" + c.spec
|
||||
if c.image != "" {
|
||||
line += "\t" + c.image + "\t" + c.imageID + "\t" + strings.Join(c.networks, ",") + "," +
|
||||
"\t" + strings.Join(c.mounts, ",") + "," + "\t" + strings.Join(c.ports, ",") + ","
|
||||
}
|
||||
return line + "\n", nil
|
||||
}
|
||||
return running + "\t" + c.spec + "\n", nil
|
||||
case "image":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
if created, ok := m.images[args[len(args)-1]]; ok {
|
||||
return created + "\n", nil
|
||||
}
|
||||
return "", errors.New("no such image")
|
||||
}
|
||||
return "", nil
|
||||
case "network":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
return strings.Join(m.members[args[len(args)-1]], ",") + ",\n", nil
|
||||
}
|
||||
return "", nil
|
||||
case "ps":
|
||||
var lines []string
|
||||
for name, c := range m.containers {
|
||||
state := "exited"
|
||||
if c.running {
|
||||
state = "running"
|
||||
}
|
||||
lines = append(lines, name+"\t"+c.image+"\t"+state)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
case "rm":
|
||||
delete(m.containers, args[len(args)-1])
|
||||
return "", nil
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR 0163):
|
||||
// every container the runtime has that no record names and no hold names. The question nothing
|
||||
// answered on 2026-09-23, when a renamed resource left its old container running for a day; asked
|
||||
// on every apply now, and reported, so a thing left behind is seen the day it is left.
|
||||
//
|
||||
// Containers only, today. A listener nobody declared is harder to attribute to a thing, and the
|
||||
// machine's own services are not strays; that account is issue 160's.
|
||||
func Strays(ctx context.Context, run Runner, known store.State) ([]store.Stray, error) {
|
||||
cri, err := containerRuntime(ctx, run)
|
||||
if err != nil {
|
||||
return nil, nil // a machine with no runtime has no containers to stray
|
||||
}
|
||||
out, err := run(ctx, cri, "ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ours := map[string]bool{}
|
||||
for _, r := range known.Resources {
|
||||
if declaration.Type(r.Type) == declaration.TypeContainer {
|
||||
ours[r.Target] = true
|
||||
}
|
||||
}
|
||||
for _, h := range known.Held {
|
||||
if h.Kind == string(declaration.TypeContainer) {
|
||||
ours[h.Target] = true
|
||||
}
|
||||
}
|
||||
var strays []store.Stray
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
parts := strings.Split(line, "\t")
|
||||
name := strings.TrimSpace(parts[0])
|
||||
if name == "" || ours[name] {
|
||||
continue
|
||||
}
|
||||
detail := ""
|
||||
if len(parts) > 2 {
|
||||
detail = strings.TrimSpace(parts[1]) + ", " + strings.TrimSpace(parts[2])
|
||||
}
|
||||
strays = append(strays, store.Stray{Kind: string(declaration.TypeContainer), Name: name, Detail: detail})
|
||||
}
|
||||
sort.Slice(strays, func(i, j int) bool { return strays[i].Name < strays[j].Name })
|
||||
return strays, nil
|
||||
}
|
||||
Reference in New Issue
Block a user