387 lines
12 KiB
Go
387 lines
12 KiB
Go
package provision
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"git.g3e.fr/syonad/two/internal/lab/render"
|
|
"git.g3e.fr/syonad/two/internal/lab/topology"
|
|
)
|
|
|
|
const generatedKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab"
|
|
|
|
type fakeRunner struct {
|
|
calls [][]string
|
|
fail string
|
|
}
|
|
|
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
|
f.calls = append(f.calls, append([]string{name}, args...))
|
|
if name == f.fail {
|
|
return errors.New(name + " failed")
|
|
}
|
|
if name == "ssh-keygen" {
|
|
private := args[len(args)-1]
|
|
if err := os.WriteFile(private, []byte("private"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(private+".pub", []byte(generatedKey+"\n"), 0o644)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRunner) commands(name string) [][]string {
|
|
var out [][]string
|
|
for _, c := range f.calls {
|
|
if c[0] == name {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func labPlan(t *testing.T, m *mirror) *topology.Plan {
|
|
t.Helper()
|
|
img := m.image()
|
|
doc := `name: evpn-2hv
|
|
images:
|
|
debian12:
|
|
url: ` + img.URL + `
|
|
sums: ` + img.Sums + `
|
|
segments:
|
|
underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
|
|
nodes:
|
|
sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 }
|
|
rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] }
|
|
hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
|
|
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
|
|
`
|
|
topo, err := topology.Parse([]byte(doc))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
p, err := topology.Compute(topo)
|
|
if err != nil {
|
|
t.Fatalf("Compute: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func prepare(t *testing.T, runner *fakeRunner) (string, string, *mirror, []render.Node, error) {
|
|
t.Helper()
|
|
m := newMirror(t)
|
|
publish(m, []byte("qcow2 image"))
|
|
root := t.TempDir()
|
|
run := filepath.Join(root, "run")
|
|
cache := filepath.Join(root, "cache")
|
|
nodes, err := Prepare(context.Background(), labPlan(t, m), Options{
|
|
RunDir: run,
|
|
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: cache},
|
|
Runner: runner,
|
|
})
|
|
return run, cache, m, nodes, err
|
|
}
|
|
|
|
func TestPrepare_StagesEveryNode(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
run, cache, _, nodes, err := prepare(t, runner)
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
|
|
base := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2")
|
|
want := [][]string{
|
|
{"ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "two-lab", "-f", filepath.Join(run, "lab_ed25519")},
|
|
}
|
|
for _, n := range []string{"sw1", "rr1", "hv1", "hv2"} {
|
|
dir := filepath.Join(run, n)
|
|
want = append(want,
|
|
[]string{"qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, filepath.Join(dir, "disk.qcow2"), "20G"},
|
|
[]string{"genisoimage", "-quiet", "-output", filepath.Join(dir, "seed.iso"), "-volid", "cidata", "-joliet", "-rock",
|
|
filepath.Join(dir, "user-data"), filepath.Join(dir, "meta-data"), filepath.Join(dir, "network-config")},
|
|
)
|
|
}
|
|
if !reflect.DeepEqual(runner.calls, want) {
|
|
t.Errorf("commands:\n got %q\nwant %q", runner.calls, want)
|
|
}
|
|
|
|
var names []string
|
|
for _, n := range nodes {
|
|
names = append(names, n.Name)
|
|
}
|
|
if !reflect.DeepEqual(names, []string{"sw1", "rr1", "hv1", "hv2"}) {
|
|
t.Errorf("nodes = %v", names)
|
|
}
|
|
if info, err := os.Stat(run); err != nil || info.Mode().Perm() != 0o700 {
|
|
t.Errorf("run dir mode = %v, %v", info.Mode().Perm(), err)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_DownloadsASharedImageOnce(t *testing.T) {
|
|
_, _, m, _, err := prepare(t, &fakeRunner{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
if n := m.count("/bookworm/latest/" + imageName); n != 1 {
|
|
t.Errorf("image downloaded %d times for four nodes, want 1", n)
|
|
}
|
|
if n := m.count("/bookworm/latest/SHA512SUMS"); n != 1 {
|
|
t.Errorf("sums read %d times for four nodes, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_TheGeneratedKeyIsTheOnlyAuthorizedKey(t *testing.T) {
|
|
_, _, _, nodes, err := prepare(t, &fakeRunner{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
for _, n := range nodes {
|
|
data, err := os.ReadFile(filepath.Join(n.Dir, "user-data"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg struct {
|
|
Keys []string `yaml:"ssh_authorized_keys"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("%s user-data: %v", n.Name, err)
|
|
}
|
|
if !reflect.DeepEqual(cfg.Keys, []string{generatedKey}) {
|
|
t.Errorf("%s authorized keys = %q, want only the generated key", n.Name, cfg.Keys)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepare_WritesTheRenderedFilesPrivately(t *testing.T) {
|
|
run, _, _, nodes, err := prepare(t, &fakeRunner{})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
hv1 := filepath.Join(run, "hv1")
|
|
if nodes[2].Dir != hv1 {
|
|
t.Fatalf("hv1 dir = %s, want %s", nodes[2].Dir, hv1)
|
|
}
|
|
for _, f := range []string{"qemu.args", "user-data", "meta-data", "network-config"} {
|
|
info, err := os.Stat(filepath.Join(hv1, f))
|
|
if err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Errorf("%s mode = %v, %v", f, info.Mode().Perm(), err)
|
|
}
|
|
}
|
|
args, err := os.ReadFile(filepath.Join(hv1, "qemu.args"))
|
|
if err != nil || !strings.HasPrefix(string(args), "-name\nhv1\n") || !strings.HasSuffix(string(args), "\n") {
|
|
t.Errorf("qemu.args = %q, %v", args, err)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_StopsAtTheFirstFailedCommand(t *testing.T) {
|
|
runner := &fakeRunner{fail: "qemu-img"}
|
|
_, _, _, _, err := prepare(t, runner)
|
|
if err == nil || !strings.Contains(err.Error(), "node sw1: qemu-img failed") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
if n := len(runner.commands("genisoimage")); n != 0 {
|
|
t.Errorf("genisoimage ran %d times after qemu-img failed", n)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_StopsBeforeAnyCommandWhenTheImageIsWrong(t *testing.T) {
|
|
m := newMirror(t)
|
|
publish(m, []byte("qcow2 image"))
|
|
m.put("/bookworm/latest/"+imageName, []byte("tampered"))
|
|
runner := &fakeRunner{}
|
|
root := t.TempDir()
|
|
_, err := Prepare(context.Background(), labPlan(t, m), Options{
|
|
RunDir: filepath.Join(root, "run"),
|
|
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
|
|
Runner: runner,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "sha512") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
if len(runner.calls) != 0 {
|
|
t.Errorf("commands ran after a bad image: %q", runner.calls)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_RefusesARelativeRunDir(t *testing.T) {
|
|
m := newMirror(t)
|
|
publish(m, []byte("qcow2 image"))
|
|
runner := &fakeRunner{}
|
|
_, err := Prepare(context.Background(), labPlan(t, m), Options{
|
|
RunDir: "run",
|
|
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()},
|
|
Runner: runner,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), `run dir "run" must be an absolute path`) {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 {
|
|
t.Errorf("sums read %d times before the run dir was checked", n)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_RefusesAnUndeclaredImage(t *testing.T) {
|
|
m := newMirror(t)
|
|
p := labPlan(t, m)
|
|
p.Images = nil
|
|
_, err := Prepare(context.Background(), p, Options{RunDir: t.TempDir(), Runner: &fakeRunner{}})
|
|
if err == nil || !strings.Contains(err.Error(), `node sw1: image "debian12" is not declared`) {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureKey_GeneratesOnlyOnce(t *testing.T) {
|
|
dir := t.TempDir()
|
|
runner := &fakeRunner{}
|
|
|
|
for i := 0; i < 2; i++ {
|
|
key, err := EnsureKey(context.Background(), runner, dir)
|
|
if err != nil || key != generatedKey {
|
|
t.Fatalf("EnsureKey #%d = %q, %v", i+1, key, err)
|
|
}
|
|
}
|
|
if n := len(runner.commands("ssh-keygen")); n != 1 {
|
|
t.Errorf("ssh-keygen ran %d times, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestEnsureKey_Rejections(t *testing.T) {
|
|
cases := map[string]string{
|
|
"empty": "\n",
|
|
"two keys": generatedKey + "\n" + generatedKey + "\n",
|
|
"stray break": "ssh-ed25519\rAAAA",
|
|
}
|
|
for name, pub := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "lab_ed25519"), []byte("private"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "lab_ed25519.pub"), []byte(pub), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := EnsureKey(context.Background(), &fakeRunner{}, dir); err == nil || !strings.Contains(err.Error(), "not a single public key") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnsureKey_ReportsAFailedGeneration(t *testing.T) {
|
|
if _, err := EnsureKey(context.Background(), &fakeRunner{fail: "ssh-keygen"}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "ssh-keygen failed") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStage_RefusesARelativeBaseImage(t *testing.T) {
|
|
runner := &fakeRunner{}
|
|
err := Stage(context.Background(), runner, render.Node{Name: "hv1", Dir: t.TempDir()}, "debian.qcow2")
|
|
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
if len(runner.calls) != 0 {
|
|
t.Errorf("commands ran: %q", runner.calls)
|
|
}
|
|
}
|
|
|
|
func TestExecRunner_ReportsTheCommandOutput(t *testing.T) {
|
|
err := ExecRunner{}.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 3")
|
|
if err == nil || !strings.Contains(err.Error(), "sh: exit status 3: boom") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
if err := (ExecRunner{}).Run(context.Background(), "true"); err != nil {
|
|
t.Errorf("true: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) {
|
|
dir := t.TempDir()
|
|
key, err := EnsureKey(context.Background(), ExecRunner{}, dir)
|
|
if err != nil {
|
|
t.Fatalf("EnsureKey: %v", err)
|
|
}
|
|
if !strings.HasPrefix(key, "ssh-ed25519 ") || !strings.HasSuffix(key, " two-lab") {
|
|
t.Errorf("key = %q", key)
|
|
}
|
|
info, err := os.Stat(filepath.Join(dir, "lab_ed25519"))
|
|
if err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err)
|
|
}
|
|
}
|
|
|
|
func TestReadFRR_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) {
|
|
dir := t.TempDir()
|
|
conf := filepath.Join(dir, "rr1.conf")
|
|
if err := os.WriteFile(conf, []byte("hostname rr1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := newMirror(t)
|
|
p := labPlan(t, m)
|
|
p.Nodes[1].FRR = conf
|
|
|
|
got, err := ReadFRR(p)
|
|
|
|
if err != nil || !reflect.DeepEqual(got, map[string]string{"rr1": "hostname rr1\n"}) {
|
|
t.Errorf("ReadFRR = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestReadFRR_NamesTheNodeOfAMissingFile(t *testing.T) {
|
|
m := newMirror(t)
|
|
p := labPlan(t, m)
|
|
p.Nodes[2].FRR = filepath.Join(t.TempDir(), "absent.conf")
|
|
|
|
if _, err := ReadFRR(p); err == nil || !strings.Contains(err.Error(), "node hv1: ") || !strings.Contains(err.Error(), "absent.conf") {
|
|
t.Errorf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) {
|
|
m := newMirror(t)
|
|
publish(m, []byte("qcow2 image"))
|
|
root := t.TempDir()
|
|
conf := filepath.Join(root, "hv1.conf")
|
|
if err := os.WriteFile(conf, []byte("hostname hv1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := labPlan(t, m)
|
|
p.Nodes[2].FRR = conf
|
|
|
|
nodes, err := Prepare(context.Background(), p, Options{
|
|
RunDir: filepath.Join(root, "run"),
|
|
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
|
|
Runner: &fakeRunner{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(nodes[2].Dir, "user-data"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cfg struct {
|
|
Files []struct {
|
|
Path string `yaml:"path"`
|
|
Content string `yaml:"content"`
|
|
} `yaml:"write_files"`
|
|
}
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, f := range cfg.Files {
|
|
if f.Path == "/etc/lab/frr.conf" {
|
|
if f.Content != "hostname hv1\n" {
|
|
t.Errorf("frr.conf = %q", f.Content)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data)
|
|
}
|