dns and ip were declared, validated, handed to the runtime — and part of no comparison, so their first deployment compared every container equal and changed nothing, silently. The same shape as 04-ISSUES/045: a field that is not in the spec is a field that can never reach a container that already runs.
454 lines
16 KiB
Go
454 lines
16 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A container may name its resolvers and its own address — the shape a module shipping its own
|
|
// validating DNS needs: the resolver pinned where its siblings can find it, the siblings pointed
|
|
// at it. Both flags take addresses, so both reach the runtime verbatim.
|
|
func TestAContainerIsGivenItsResolverAndItsAddress(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":"imap","type":"container","name":"mailu-imap",`+
|
|
`"image":"dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
|
|
`"network":"mailu","dns":["192.168.203.254"],"ip":"192.168.203.7"}`)
|
|
|
|
_, _, _ = 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{"--dns 192.168.203.254", "--ip 192.168.203.7"} {
|
|
if !strings.Contains(started, want) {
|
|
t.Errorf("the container was started without %q:\n%s", want, started)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The resolver and address are part of the spec — a container whose dns or ip moved is a
|
|
// different container, or the fields can never reach one that already runs. That is not
|
|
// hypothetical: their first deployment compared equal and changed nothing.
|
|
func TestAChangedResolverOrAddressIsAChangedContainer(t *testing.T) {
|
|
base := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00"}
|
|
withDns := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", Dns: []string{"192.168.203.254"}}
|
|
withIP := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", IP: "192.168.203.254"}
|
|
plain := containerSpecReading(base, nil, nil)
|
|
if containerSpecReading(withDns, nil, nil) == plain {
|
|
t.Error("adding a resolver did not change the spec, so it can never reach a running container")
|
|
}
|
|
if containerSpecReading(withIP, nil, nil) == plain {
|
|
t.Error("adding an address did not change the spec, so it can never reach a running container")
|
|
}
|
|
}
|