docker inspect <name> resolves across every object kind, not just
containers. A module regularly names a network the same as the
container that joins it (keycloak does this today, ordinarily) — so
when the container does not exist yet but the same-named network
already does, the bare form answers with the network's JSON instead
of reporting the container absent, and the template these callers use
(.State.Running) fails to execute against it entirely.
Live on novox tonight: minio's LB container, named the same as its
network ("minio"), could never be created — every apply crashed on
"the container runtime could not say whether minio is here", stuck
since first push, because the check itself never got a clean answer.
Fixed at every call site asking a container's state by name
(containerState, inspectFound, NamesFree, raiseGiteaServer,
containerRunning) by scoping to `docker container inspect`, matching
the type-scoped form this codebase already uses correctly for
networks, volumes and images elsewhere. Also scoped the one image
inspect that was still bare (publish.go), for the same reason.
mesh-host runs as a host-level service (nox-mesh-host.service), not a
Docker module — merging this does not redeploy it. The live novox
failure persists until the service itself is rebuilt and updated.
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package apply
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// The shapes added so that most of what a person installs is expressible.
|
|
//
|
|
// A shell, a chat client, a desktop are a package plus configuration in somebody's home, and a
|
|
// mesh with no user can manage /etc and nothing anybody looks at.
|
|
|
|
func declare(t *testing.T, resources string) *declaration.Declaration {
|
|
t.Helper()
|
|
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + resources + `]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func TestAFileMayBeBytesRatherThanText(t *testing.T) {
|
|
// A wallpaper, a font, an icon. Stored as its own encoding it would be a wallpaper nothing
|
|
// can open.
|
|
dir := t.TempDir()
|
|
original := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff}
|
|
d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/wall.png","bytes":"`+
|
|
base64.StdEncoding.EncodeToString(original)+`"}`)
|
|
|
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
on, err := os.ReadFile(dir + "/wall.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(on, original) {
|
|
t.Fatalf("the bytes did not survive: %x", on)
|
|
}
|
|
}
|
|
|
|
func TestAFileSaysWhatIsInItExactlyOnce(t *testing.T) {
|
|
// Three ways of saying it and no precedence between them, so "what is in this file" is
|
|
// answerable by looking rather than by knowing which field wins.
|
|
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"/etc/x","content":"a","bytes":"YQ=="}]}`))
|
|
if err == nil {
|
|
t.Fatal("a file that was both text and bytes was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "exactly once") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBytesThatAreNotBase64AreRefused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"w","type":"file","path":"`+dir+`/x","bytes":"not base64!!"}`)
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err == nil {
|
|
t.Fatal("a file carrying nonsense was written")
|
|
}
|
|
if _, statErr := os.Stat(dir + "/x"); statErr == nil {
|
|
t.Fatal("something was written before the failure")
|
|
}
|
|
}
|
|
|
|
// A gzipped tar, and its digest, built here so the test does not depend on a fixture nobody can
|
|
// regenerate.
|
|
func anArchive(t *testing.T, files map[string]string) ([]byte, string) {
|
|
t.Helper()
|
|
var raw bytes.Buffer
|
|
zipped := gzip.NewWriter(&raw)
|
|
writer := tar.NewWriter(zipped)
|
|
for name, body := range files {
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := writer.Write([]byte(body)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := zipped.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(raw.Bytes())
|
|
return raw.Bytes(), "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func serving(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write(body)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server.URL + "/theme.tar.gz"
|
|
}
|
|
|
|
func TestAnArchiveIsUnpacked(t *testing.T) {
|
|
body, digest := anArchive(t, map[string]string{
|
|
"config/theme.conf": "dark", "config/icons/one.svg": "<svg/>",
|
|
})
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
|
|
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
|
|
|
|
report, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !report.Changed() {
|
|
t.Fatal("nothing changed")
|
|
}
|
|
on, err := os.ReadFile(dir + "/theme/config/theme.conf")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(on) != "dark" {
|
|
t.Fatalf("got %q", on)
|
|
}
|
|
}
|
|
|
|
func TestAnArchiveThatIsNotWhatWasDeclaredIsRefusedBeforeAnythingIsWritten(t *testing.T) {
|
|
// The only thing making bytes from a network the mesh does not control safe to unpack is
|
|
// that they hash to what was declared.
|
|
body, _ := anArchive(t, map[string]string{"a": "b"})
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
|
|
`","digest":"sha256:`+strings.Repeat("ab", 32)+`","path":"`+dir+`/theme"}`)
|
|
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err == nil {
|
|
t.Fatal("an archive that was not what was declared was unpacked")
|
|
}
|
|
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
|
t.Fatal("something was written before the digest was checked")
|
|
}
|
|
}
|
|
|
|
func TestAnArchiveCannotWriteOutsideWhereItWasUnpacked(t *testing.T) {
|
|
// The oldest bug in unpacking. Checked against the resolved root rather than by looking for
|
|
// "..", because there is more than one way to name a path that escapes.
|
|
body, digest := anArchive(t, map[string]string{"../../escaped": "no"})
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
|
|
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
|
|
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err != nil && !strings.Contains(err.Error(), "outside") {
|
|
t.Fatalf("refused for the wrong reason: %v", err)
|
|
}
|
|
if _, statErr := os.Stat(dir + "/escaped"); statErr == nil {
|
|
t.Fatal("a file landed outside the directory it was unpacked into")
|
|
}
|
|
if err == nil {
|
|
t.Fatal("an escaping entry was accepted")
|
|
}
|
|
}
|
|
|
|
func TestAnUnpackedArchiveIsNotFetchedAgainForNothing(t *testing.T) {
|
|
// The digest is the whole identity of an archive, so a matching record means the tree came
|
|
// from these exact bytes. Applying twice must not report work.
|
|
body, digest := anArchive(t, map[string]string{"a": "b"})
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, body)+
|
|
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
|
|
|
|
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, _, err := Apply(context.Background(), archHost(t), d, state,
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again.Changed() {
|
|
said, _ := json.Marshal(again)
|
|
t.Fatalf("the second apply did work: %s", said)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq ADR 0012: the mesh creates no symlinks — a derived file is a copy.
|
|
//
|
|
// The archive is the one path where a symlink could arrive without anybody declaring it, which is
|
|
// why the refusal lives here. ADR 0012 was earned by production data loss through a symlink
|
|
// resolved inside a container volume path.
|
|
func TestAnArchiveWithSomethingThatIsNotAFileIsRefused(t *testing.T) {
|
|
// A theme needing a symlink would otherwise arrive silently incomplete, and a device node in
|
|
// an archive is not something to unpack quietly onto a machine.
|
|
var raw bytes.Buffer
|
|
zipped := gzip.NewWriter(&raw)
|
|
writer := tar.NewWriter(zipped)
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd", Mode: 0o777,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer.Close()
|
|
zipped.Close()
|
|
sum := sha256.Sum256(raw.Bytes())
|
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
|
|
|
dir := t.TempDir()
|
|
d := declare(t, `{"id":"theme","type":"archive","source":"`+serving(t, raw.Bytes())+
|
|
`","digest":"`+digest+`","path":"`+dir+`/theme"}`)
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginCarried, noServices, nil, nil)
|
|
if err == nil {
|
|
t.Fatal("a symlink was unpacked")
|
|
}
|
|
if !strings.Contains(err.Error(), "files and directories") {
|
|
t.Fatalf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnArchiveMustBePinned(t *testing.T) {
|
|
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
|
{"id":"t","type":"archive","source":"https://example.invalid/a.tgz","path":"/opt/t"}]}`))
|
|
if err == nil {
|
|
t.Fatal("an unpinned archive was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "digest") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq ADR 0029: a network is a shape so that it can be removed.
|
|
//
|
|
// The whole argument for widening the vocabulary is lifecycle — an action could create one and
|
|
// nothing could ever take it away — so removal is the assertion that matters, not creation.
|
|
func TestANetworkIsCreatedAndThenRemovedWhenNoLongerDeclared(t *testing.T) {
|
|
var calls []string
|
|
there := map[string]bool{}
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
calls = append(calls, name+" "+strings.Join(args, " "))
|
|
if name != "docker" || len(args) < 2 || args[0] != "network" {
|
|
return "", nil // the runtime probe
|
|
}
|
|
switch args[1] {
|
|
case "inspect":
|
|
if !there[args[2]] {
|
|
return "", fmt.Errorf("no such network")
|
|
}
|
|
case "create":
|
|
there[args[2]] = true
|
|
case "rm":
|
|
delete(there, args[2])
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
d := declare(t, `{"id":"private","type":"network","name":"mail"}`)
|
|
_, state, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginDeclared, run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !there["mail"] {
|
|
t.Fatal("the network was not created")
|
|
}
|
|
|
|
// The module is unassigned: the mesh now declares nothing.
|
|
empty := declare(t, `{"id":"unrelated","type":"directory","path":"`+t.TempDir()+`"}`)
|
|
if _, _, err := Apply(context.Background(), archHost(t), empty, state,
|
|
store.OriginDeclared, run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if there["mail"] {
|
|
t.Fatal("the network outlived the module that declared it, which is the entire reason " +
|
|
"this is a shape rather than an action")
|
|
}
|
|
}
|
|
|
|
// A network is created once and left alone when it is already there.
|
|
func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) {
|
|
var created int
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
if name == "docker" && len(args) > 1 && args[0] == "network" && args[1] == "create" {
|
|
created++
|
|
}
|
|
return "", nil // inspect succeeds: it is already there
|
|
}
|
|
|
|
d := declare(t, `{"id":"private","type":"network","name":"mail"}`)
|
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginDeclared, run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created != 0 {
|
|
t.Fatalf("a network that was already there was created %d time(s); the mesh owns the "+
|
|
"name and not the thing, so it does not tear one down and rebuild it", created)
|
|
}
|
|
}
|
|
|
|
// A secret inside a configuration file, substituted on the machine.
|
|
//
|
|
// **The one place a credential and a configuration meet.** A program wanting its token inside a
|
|
// JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the
|
|
// document because it discarded the value. So the module supplies the document with a hole, the
|
|
// mesh delivers the value sealed, and the host is the only thing that ever holds both.
|
|
func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "settings.json")
|
|
d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+
|
|
`"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+
|
|
`"secrets":{"atlassian":"SEALED"}}`)
|
|
|
|
open := func(blob string) ([]byte, error) {
|
|
if blob != "SEALED" {
|
|
return nil, fmt.Errorf("asked to open %q", blob)
|
|
}
|
|
return []byte("the-real-token"), nil
|
|
}
|
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginDeclared, nil, nil, open); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
written, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(written), `"token":"the-real-token"`) {
|
|
t.Fatalf("the secret was not put in: %s", written)
|
|
}
|
|
if strings.Contains(string(written), "secret:") {
|
|
t.Fatalf("a placeholder survived into the file: %s", written)
|
|
}
|
|
// The rest of the document is untouched — this is substitution, not replacement.
|
|
if !strings.Contains(string(written), `"tracking":"on"`) {
|
|
t.Fatalf("the content around the secret was lost: %s", written)
|
|
}
|
|
// And it carries a credential, so it is not world-readable.
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("a file holding a credential is %v", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// Defends the reason env-file exists: a credential may not travel in `env`.
|
|
//
|
|
// A declaration reaches a node over the broker and `env` is plain text in it, so a password there
|
|
// is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the
|
|
// runtime reads it.
|
|
func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
|
|
var ran []string
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
ran = append(ran, name+" "+strings.Join(args, " "))
|
|
if len(args) > 0 && args[0] == "container" {
|
|
return "", fmt.Errorf("no such container")
|
|
}
|
|
return "", nil
|
|
}
|
|
d := declare(t, `{"id":"app","type":"container","name":"umami",`+
|
|
`"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
|
|
`"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`)
|
|
|
|
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{},
|
|
store.OriginDeclared, run, nil, nil)
|
|
|
|
var started string
|
|
for _, line := range ran {
|
|
if strings.Contains(line, "run ") {
|
|
started = line
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"--env-file /var/lib/umami/database.env",
|
|
"--env-file /var/lib/umami/app.env",
|
|
} {
|
|
if !strings.Contains(started, want) {
|
|
t.Errorf("the container was started without %q:\n%s", want, started)
|
|
}
|
|
}
|
|
}
|