mesh/merge-gate head 98ca97fc5b3f merged into trunk dcf6e86d0236 — pass: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) fo…
mesh/repo-check head 98ca97fc5b3f merged into trunk dcf6e86d0236 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: ace: novox/mesh-catalog#442: checks/particulars/go.mod cannot be read to check its mesh-sdk pin: Gitea API /repos/novox/mesh-catalo…
The upgrade and restore steps told a person to docker exec the snapshot program; no mesh tool did it. nats_snapshot_verify reads the newest bus.tar of the snapshots directory only.
313 lines
12 KiB
Go
313 lines
12 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
// The declared dump and the mounted entrypoint, exactly as the manifest declares them, against the upstream image
|
|
// the manifest pins: the mesh's snapshot program and entrypoint mounted at /opt (novox/hq ADR 0480 clauses 1, 2, 5).
|
|
//
|
|
// go test -tags integration -run 'TestTheDeclaredDump|TestTheEntrypoint|TestTheStoreSurvives' . (through mesh-controller.try)
|
|
//
|
|
// The image is pulled by the manifest's reference, so the server these run is the one the bus runs.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"nats-tools/internal/busarchive"
|
|
)
|
|
|
|
// manifestOf is the nats module's own definition, beside this program.
|
|
func manifestOf(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
raw, err := os.ReadFile("../../module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func declaredDump(t *testing.T, m map[string]any) string {
|
|
t.Helper()
|
|
for _, item := range m["data"].(map[string]any)["own"].([]any) {
|
|
it := item.(map[string]any)
|
|
if it["id"] != "jetstream" {
|
|
continue
|
|
}
|
|
backup, _ := it["backup"].(map[string]any)
|
|
if backup == nil || backup["into"] != "snapshots" {
|
|
t.Fatalf("the bus's streams are not protected by a dump into its snapshots: %v", it["backup"])
|
|
}
|
|
return backup["dump"].(string)
|
|
}
|
|
t.Fatal("the nats module declares no jetstream data item")
|
|
return ""
|
|
}
|
|
|
|
// serverConf is the module's own server settings, from the template its resource names.
|
|
func serverConf(t *testing.T, m map[string]any) string {
|
|
t.Helper()
|
|
for _, r := range m["resources"].([]any) {
|
|
res := r.(map[string]any)
|
|
if res["id"] == "server-conf" {
|
|
return templateText(t, res)
|
|
}
|
|
}
|
|
t.Fatal("the nats module declares no server configuration")
|
|
return ""
|
|
}
|
|
|
|
// busImage is the upstream image the manifest pins, as a reference docker pulls (name:tag@digest).
|
|
func busImage(t *testing.T, m map[string]any) string {
|
|
t.Helper()
|
|
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
|
|
art := a.(map[string]any)
|
|
if art["name"] == "server" {
|
|
if art["kind"] != "upstream" {
|
|
t.Fatalf("the server artifact is a %v, not an upstream image", art["kind"])
|
|
}
|
|
return art["from"].(string)
|
|
}
|
|
}
|
|
t.Fatal("the nats module declares no server artifact")
|
|
return ""
|
|
}
|
|
|
|
// programs builds the snapshot program for alpine (static, as the mesh's build does) and lays out the two
|
|
// directories the manifest mounts: the program at /opt/mesh and the entrypoint at /opt/mesh-entrypoint.
|
|
func programs(t *testing.T) (program, entry string) {
|
|
t.Helper()
|
|
program, entry = t.TempDir(), t.TempDir()
|
|
build := exec.Command("go", "build", "-trimpath", "-o", filepath.Join(program, "mesh-nats-snapshot"), ".")
|
|
build.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux")
|
|
if out, err := build.CombinedOutput(); err != nil {
|
|
t.Fatalf("building the program: %v\n%s", err, out)
|
|
}
|
|
script, err := os.ReadFile(filepath.Join(moduleDir, "program", "entrypoint.sh"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Mode 0644: the manifest runs the script through sh, so no execute bit is relied on.
|
|
if err := os.WriteFile(filepath.Join(entry, "entrypoint.sh"), script, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return program, entry
|
|
}
|
|
|
|
// runBus starts the upstream image the way the manifest does: its volumes, its args, the module's configuration
|
|
// and the given user list. The poll is a second so a test need not wait out the production five.
|
|
func runBus(t *testing.T, m map[string]any, name, accounts string) (port, pin, conf string) {
|
|
t.Helper()
|
|
if out, _ := exec.Command("docker", "ps", "-a", "-q", "--filter", "name=^"+name+"$").Output(); len(strings.TrimSpace(string(out))) > 0 {
|
|
t.Skipf("a container called %s already runs here; this test will not touch it", name)
|
|
}
|
|
conf, tlsDir, data := t.TempDir(), t.TempDir(), t.TempDir()
|
|
pin = selfSigned(t, tlsDir)
|
|
_ = os.WriteFile(filepath.Join(conf, "nats.conf"), []byte(serverConf(t, m)), 0o644)
|
|
_ = os.WriteFile(filepath.Join(conf, "accounts.conf"), []byte(accounts), 0o644)
|
|
program, entry := programs(t)
|
|
docker(t, "run", "-d", "--rm", "--name", name, "--user", owner(), "-p", "127.0.0.1::4222",
|
|
"-e", "MESH_NATS_CONF_POLL_SECONDS=1",
|
|
"-v", data+":/data", "-v", conf+":/etc/nats:ro", "-v", tlsDir+":/tls:ro",
|
|
"-v", program+":/opt/mesh:ro", "-v", entry+":/opt/mesh-entrypoint:ro",
|
|
busImage(t, m), "sh", "/opt/mesh-entrypoint/entrypoint.sh")
|
|
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name).Run() })
|
|
_, port, _ = strings.Cut(docker(t, "port", name, "4222/tcp"), ":")
|
|
return port, pin, conf
|
|
}
|
|
|
|
// The dump is the one the manifest declares, run by `sh -c` as the holder runs it, with the module's
|
|
// directories filled in; the server is the module's image with the module's own configuration and a
|
|
// user list holding the bus's own module with the controller's grants for it.
|
|
func TestTheDeclaredDumpRunsInTheBussImage(t *testing.T) {
|
|
needDocker(t)
|
|
const name = "mesh-broker-nats" // the container the dump names
|
|
m := manifestOf(t)
|
|
dump := declaredDump(t, m)
|
|
|
|
state, snapshots := t.TempDir(), t.TempDir()
|
|
accounts := liveBusConf()
|
|
accounts = accounts[strings.Index(accounts, "accounts {"):]
|
|
port, pin, _ := runBus(t, m, name, accounts)
|
|
|
|
admin := dial(t, "tls://127.0.0.1:"+port, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
|
|
js, _ := admin.JetStream()
|
|
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_hand-acts"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"a", "b", "c"} {
|
|
if _, err := kv.Put(k, []byte("act "+k)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
run := func(cred Credential) (string, error) {
|
|
raw, _ := json.Marshal(cred)
|
|
if err := os.WriteFile(filepath.Join(state, "broker"), raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := strings.NewReplacer("${dir:mesh-state}", state, "${dir:snapshots}", snapshots).Replace(dump)
|
|
out, err := exec.Command("sh", "-c", command).CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
began := time.Now()
|
|
out, err := run(Credential{URL: "nats://bus.example:4222", Fingerprint: pin, User: snapshotUser, Password: "snap"})
|
|
if err != nil {
|
|
t.Fatalf("the declared dump failed: %v\n%s", err, out)
|
|
}
|
|
t.Logf("the declared dump took %s:\n%s", time.Since(began).Round(time.Millisecond), out)
|
|
tarPath := filepath.Join(snapshots, "bus.tar")
|
|
f, err := os.Open(tarPath)
|
|
if err != nil {
|
|
t.Fatalf("the dump left no bus.tar: %v", err)
|
|
}
|
|
a, err := busarchive.ReadArchive(f, "")
|
|
f.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
if err := a.Verify(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(a.Manifest.Streams) != 1 || a.Manifest.Streams[0].Name != "KV_mesh-controller_hand-acts" || a.Manifest.Streams[0].Messages != 3 {
|
|
t.Fatalf("the snapshot holds %+v", a.Manifest.Streams)
|
|
}
|
|
before, _ := os.ReadFile(tarPath)
|
|
|
|
// A night that cannot snapshot fails, says why, and leaves last night's snapshot as it was.
|
|
out, err = run(Credential{Fingerprint: pin, User: snapshotUser, Password: "wrong"})
|
|
if err == nil {
|
|
t.Fatalf("a dump the bus refused succeeded:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Authorization Violation") && !strings.Contains(strings.ToLower(out), "authorization") {
|
|
t.Errorf("the failure does not say the bus refused the credential: %s", out)
|
|
}
|
|
after, _ := os.ReadFile(tarPath)
|
|
if string(after) != string(before) {
|
|
t.Error("a failed night replaced the last good snapshot")
|
|
}
|
|
if _, err := os.Stat(tarPath + ".partial"); !os.IsNotExist(err) {
|
|
t.Error("a failed night left its partial file behind")
|
|
}
|
|
}
|
|
|
|
// **A change of the user list reloads the server and keeps its connections** (ADR 0480 clause 5, ADR 0236 §37).
|
|
// The mounted entrypoint watches accounts.conf and signals the server; a client connected meanwhile is not
|
|
// dropped, and a user added by the change can then connect.
|
|
func TestTheEntrypointReloadsTheServerWithoutDroppingAConnection(t *testing.T) {
|
|
needDocker(t)
|
|
m := manifestOf(t)
|
|
accounts := plainConf()
|
|
accounts = accounts[strings.Index(accounts, "accounts {"):]
|
|
port, pin, conf := runBus(t, m, fmt.Sprintf("mesh-reload-test-%d", os.Getpid()), accounts)
|
|
|
|
admin := dial(t, "tls://127.0.0.1:"+port, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
|
|
if err := admin.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
changed := strings.Replace(accounts, `{ user: "admin", password: "admin" }`,
|
|
`{ user: "admin", password: "admin" }, { user: "late", password: "late" }`, 1)
|
|
if changed == accounts {
|
|
t.Fatal("the test's user list did not change")
|
|
}
|
|
// Written as the mesh writes it: replaced, not edited in place, so the watcher sees whole content.
|
|
tmp := filepath.Join(conf, "accounts.conf.new")
|
|
if err := os.WriteFile(tmp, []byte(changed), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(tmp, filepath.Join(conf, "accounts.conf")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// dial retries until the server takes the user: the reload happens within the poll interval.
|
|
late := dial(t, "tls://127.0.0.1:"+port, Credential{Fingerprint: pin, User: "late", Password: "late"}, nil)
|
|
_ = late
|
|
if !admin.IsConnected() {
|
|
t.Fatal("the connection open before the reload was dropped by it")
|
|
}
|
|
if err := admin.Flush(); err != nil {
|
|
t.Fatalf("the connection open before the reload no longer works: %v", err)
|
|
}
|
|
if n := admin.Stats().Reconnects; n != 0 {
|
|
t.Fatalf("the connection open before the reload reconnected %d times", n)
|
|
}
|
|
}
|
|
|
|
// **The bus can go back** (ADR 0480 clause 6): the new declaration and then the old one run on the same store, both
|
|
// well, which is what makes `reversible` true for the bus upgrade. The server binary (the upstream image's digest),
|
|
// the data directory and the configuration are the same in both; only where the entrypoint and the program come
|
|
// from differs. The old declaration is run as the image it replaced ran it: the same script as the image's own
|
|
// entrypoint, the program on the image's path. Then the new one again, so a way there, back and there again is
|
|
// shown, each time with the stream's messages as the one before left them.
|
|
func TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain(t *testing.T) {
|
|
needDocker(t)
|
|
m := manifestOf(t)
|
|
accounts := liveBusConf()
|
|
accounts = accounts[strings.Index(accounts, "accounts {"):]
|
|
conf, tlsDir, data := t.TempDir(), t.TempDir(), t.TempDir()
|
|
pin := selfSigned(t, tlsDir)
|
|
_ = os.WriteFile(filepath.Join(conf, "nats.conf"), []byte(serverConf(t, m)), 0o644)
|
|
_ = os.WriteFile(filepath.Join(conf, "accounts.conf"), []byte(accounts), 0o644)
|
|
program, entry := programs(t)
|
|
script := filepath.Join(entry, "entrypoint.sh")
|
|
if err := os.Chmod(script, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
common := []string{"-d", "--rm", "--user", owner(), "-p", "127.0.0.1::4222", "-e", "MESH_NATS_CONF_POLL_SECONDS=1",
|
|
"-v", data + ":/data", "-v", conf + ":/etc/nats:ro", "-v", tlsDir + ":/tls:ro"}
|
|
declarations := map[string][]string{
|
|
"new": {"-v", program + ":/opt/mesh:ro", "-v", entry + ":/opt/mesh-entrypoint:ro", busImage(t, m), "sh", "/opt/mesh-entrypoint/entrypoint.sh"},
|
|
"old": {"-v", script + ":/usr/local/bin/mesh-nats-entrypoint:ro",
|
|
"-v", filepath.Join(program, "mesh-nats-snapshot") + ":/usr/local/bin/mesh-nats-snapshot:ro",
|
|
"--entrypoint", "/usr/local/bin/mesh-nats-entrypoint", busImage(t, m)},
|
|
}
|
|
var kv nats.KeyValue
|
|
puts := 0
|
|
step := func(which string) {
|
|
name := fmt.Sprintf("mesh-rehearsal-%s-%d", which, os.Getpid())
|
|
docker(t, append(append([]string{"run", "--name", name}, common...), declarations[which]...)...)
|
|
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name).Run() })
|
|
_, port, _ := strings.Cut(docker(t, "port", name, "4222/tcp"), ":")
|
|
c := dial(t, "tls://127.0.0.1:"+port, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
|
|
js, _ := c.JetStream()
|
|
var err error
|
|
if puts == 0 {
|
|
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-rehearsal"})
|
|
} else {
|
|
kv, err = js.KeyValue("mesh-rehearsal")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("%s declaration: the store's bucket: %v", which, err)
|
|
}
|
|
for i := 1; i <= puts; i++ {
|
|
e, err := kv.Get(fmt.Sprintf("k%d", i))
|
|
if err != nil || string(e.Value()) != fmt.Sprintf("v%d", i) {
|
|
t.Fatalf("%s declaration: key k%d reads %v, %v", which, i, e, err)
|
|
}
|
|
}
|
|
puts++
|
|
if _, err := kv.Put(fmt.Sprintf("k%d", puts), []byte(fmt.Sprintf("v%d", puts))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Close()
|
|
docker(t, "stop", "-t", "10", name)
|
|
}
|
|
step("new")
|
|
step("old")
|
|
step("new")
|
|
}
|