diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 936e09a..13db735 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "git.g3e.fr/syonad/two/internal/lab/provision" "git.g3e.fr/syonad/two/internal/lab/render" "git.g3e.fr/syonad/two/internal/lab/topology" ) @@ -111,7 +112,7 @@ func renderCmd(args []string, stdout, stderr io.Writer) int { return 1 } for _, n := range nodes { - if err := writeNode(n); err != nil { + if err := provision.WriteFiles(n); err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 } @@ -137,21 +138,3 @@ func readKeys(files []string) ([]string, error) { } return keys, nil } - -func writeNode(n render.Node) error { - if err := os.MkdirAll(n.Dir, 0o700); err != nil { - return err - } - files := map[string][]byte{ - "qemu.args": []byte(strings.Join(n.QEMU, "\n") + "\n"), - "meta-data": n.MetaData, - "user-data": n.UserData, - "network-config": n.NetworkConfig, - } - for name, content := range files { - if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { - return err - } - } - return nil -} diff --git a/internal/lab/provision/image.go b/internal/lab/provision/image.go new file mode 100644 index 0000000..c4e8347 --- /dev/null +++ b/internal/lab/provision/image.go @@ -0,0 +1,185 @@ +package provision + +import ( + "bufio" + "bytes" + "context" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const maxSumsSize = 1 << 20 + +var sha512Pattern = regexp.MustCompile(`^[0-9a-f]{128}$`) + +type Fetcher struct { + Client *http.Client + CacheDir string +} + +func (f Fetcher) Image(ctx context.Context, img topology.Image) (string, error) { + if !filepath.IsAbs(f.CacheDir) { + return "", fmt.Errorf("image cache dir %q must be an absolute path", f.CacheDir) + } + name, err := fileName(img.URL) + if err != nil { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + + sums, err := f.get(ctx, img.Sums, maxSumsSize) + if err != nil { + return "", fmt.Errorf("image %s: sums: %w", img.Name, err) + } + want, err := expectedSum(sums, name) + if err != nil { + return "", fmt.Errorf("image %s: %s: %w", img.Name, img.Sums, err) + } + + dir := filepath.Join(f.CacheDir, img.Name) + target := filepath.Join(dir, name) + if got, err := fileSum(target); err == nil && got == want { + return target, nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + if err := f.download(ctx, img.URL, dir, target, want); err != nil { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + return target, nil +} + +func (f Fetcher) client() *http.Client { + if f.Client != nil { + return f.Client + } + return http.DefaultClient +} + +func (f Fetcher) open(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := f.client().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status) + } + return resp, nil +} + +func (f Fetcher) get(ctx context.Context, rawURL string, limit int64) ([]byte, error) { + resp, err := f.open(ctx, rawURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("GET %s: larger than %d bytes", rawURL, limit) + } + return data, nil +} + +func (f Fetcher) download(ctx context.Context, rawURL, dir, target, want string) error { + resp, err := f.open(ctx, rawURL) + if err != nil { + return err + } + defer resp.Body.Close() + + tmp, err := os.CreateTemp(dir, filepath.Base(target)+".part-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + h := sha512.New() + if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { + tmp.Close() + return fmt.Errorf("GET %s: %w", rawURL, err) + } + if err := tmp.Close(); err != nil { + return err + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("GET %s: sha512 %s, want %s", rawURL, got, want) + } + if err := os.Chmod(tmp.Name(), 0o644); err != nil { + return err + } + return os.Rename(tmp.Name(), target) +} + +func fileName(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + name := path.Base(u.Path) + if name == "" || name == "." || name == "/" { + return "", fmt.Errorf("url %q does not name a file", rawURL) + } + return name, nil +} + +func expectedSum(sums []byte, name string) (string, error) { + var found []string + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { + continue + } + sum := strings.ToLower(fields[0]) + if !sha512Pattern.MatchString(sum) { + return "", fmt.Errorf("entry for %s is not a sha512 sum", name) + } + found = append(found, sum) + } + if err := sc.Err(); err != nil { + return "", err + } + switch { + case len(found) == 0: + return "", fmt.Errorf("no sum for %s", name) + case len(found) > 1: + return "", fmt.Errorf("%d sums for %s", len(found), name) + } + return found[0], nil +} + +func fileSum(p string) (string, error) { + file, err := os.Open(p) + if err != nil { + return "", err + } + defer file.Close() + h := sha512.New() + if _, err := io.Copy(h, file); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/lab/provision/image_test.go b/internal/lab/provision/image_test.go new file mode 100644 index 0000000..cd1ab20 --- /dev/null +++ b/internal/lab/provision/image_test.go @@ -0,0 +1,334 @@ +package provision + +import ( + "context" + "crypto/sha512" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const imageName = "debian-12-generic-amd64.qcow2" + +type mirror struct { + mu sync.Mutex + files map[string][]byte + status map[string]int + hits map[string]int + server *httptest.Server +} + +func newMirror(t *testing.T) *mirror { + t.Helper() + m := &mirror{files: map[string][]byte{}, status: map[string]int{}, hits: map[string]int{}} + m.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + m.hits[r.URL.Path]++ + if code := m.status[r.URL.Path]; code != 0 { + w.WriteHeader(code) + return + } + data, ok := m.files[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Write(data) + })) + t.Cleanup(m.server.Close) + return m +} + +func (m *mirror) put(path string, data []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.files[path] = data +} + +func (m *mirror) count(path string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.hits[path] +} + +func (m *mirror) image() topology.Image { + return topology.Image{ + Name: "debian12", + URL: m.server.URL + "/bookworm/latest/" + imageName, + Sums: m.server.URL + "/bookworm/latest/SHA512SUMS", + } +} + +func sum(data []byte) string { + h := sha512.Sum512(data) + return hex.EncodeToString(h[:]) +} + +func publish(m *mirror, content []byte) { + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte( + sum([]byte("other"))+" debian-12-genericcloud-amd64.qcow2\n"+ + sum(content)+" "+imageName+"\n"+ + sum([]byte("raw"))+" debian-12-generic-amd64.raw\n")) +} + +func fetch(t *testing.T, m *mirror, cache string) string { + t.Helper() + got, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) + if err != nil { + t.Fatalf("Image: %v", err) + } + return got +} + +func fetchError(t *testing.T, m *mirror, cache string) string { + t.Helper() + _, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) + if err == nil { + t.Fatal("Image: no error") + } + return err.Error() +} + +func entries(t *testing.T, dir string) []string { + t.Helper() + list, err := os.ReadDir(dir) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var names []string + for _, e := range list { + names = append(names, e.Name()) + } + return names +} + +func TestImage_DownloadsAndVerifies(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + + got := fetch(t, m, cache) + + if want := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2"); got != want { + t.Errorf("path = %s, want %s", got, want) + } + data, err := os.ReadFile(got) + if err != nil || string(data) != "qcow2 image" { + t.Errorf("content = %q, %v", data, err) + } + info, err := os.Stat(got) + if err != nil || info.Mode().Perm() != 0o644 { + t.Errorf("mode = %v, %v", info.Mode().Perm(), err) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { + t.Errorf("cache dir holds %v, want only the image", names) + } +} + +func TestImage_ReusesAVerifiedCacheButAlwaysRereadsTheSums(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + + fetch(t, m, cache) + fetch(t, m, cache) + + if n := m.count("/bookworm/latest/" + imageName); n != 1 { + t.Errorf("image downloaded %d times, want 1", n) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 2 { + t.Errorf("sums read %d times, want 2", n) + } +} + +func TestImage_ReplacesACorruptedCache(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + path := fetch(t, m, cache) + if err := os.WriteFile(path, []byte("bit rot"), 0o644); err != nil { + t.Fatal(err) + } + + fetch(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "qcow2 image" { + t.Errorf("content = %q, want the published image", data) + } + if n := m.count("/bookworm/latest/" + imageName); n != 2 { + t.Errorf("image downloaded %d times, want 2", n) + } +} + +func TestImage_FollowsANewReleaseOfTheSameFile(t *testing.T) { + m := newMirror(t) + publish(m, []byte("release 1")) + cache := t.TempDir() + path := fetch(t, m, cache) + + publish(m, []byte("release 2")) + fetch(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "release 2" { + t.Errorf("content = %q, want release 2", data) + } +} + +func TestImage_RejectsAMismatchAndLeavesNothing(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + m.put("/bookworm/latest/"+imageName, []byte("tampered")) + cache := t.TempDir() + + msg := fetchError(t, m, cache) + + if !strings.Contains(msg, "sha512") { + t.Errorf("error = %q, want a sha512 mismatch", msg) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v after a mismatch, want nothing", names) + } +} + +func TestImage_KeepsTheVerifiedCacheWhenANewDownloadFails(t *testing.T) { + m := newMirror(t) + publish(m, []byte("release 1")) + cache := t.TempDir() + path := fetch(t, m, cache) + + publish(m, []byte("release 2")) + m.put("/bookworm/latest/"+imageName, []byte("truncated")) + fetchError(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "release 1" { + t.Errorf("content = %q, want release 1 kept", data) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { + t.Errorf("cache dir holds %v, want only the image", names) + } +} + +func TestImage_AcceptsBinaryModeSumLines(t *testing.T) { + m := newMirror(t) + content := []byte("qcow2 image") + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(strings.ToUpper(sum(content))+" *"+imageName+"\n")) + + fetch(t, m, t.TempDir()) +} + +func TestImage_SumsRejections(t *testing.T) { + content := []byte("qcow2 image") + cases := map[string]struct { + sums string + want string + }{ + "no entry": {sum(content) + " debian-12-generic-arm64.qcow2\n", "no sum for " + imageName}, + "two entries": {sum(content) + " " + imageName + "\n" + sum(content) + " " + imageName + "\n", "2 sums for " + imageName}, + "not sha512": {"d41d8cd98f00b204e9800998ecf8427e " + imageName + "\n", "is not a sha512 sum"}, + "prefix of name": {sum(content) + " " + imageName + ".sig\n", "no sum for " + imageName}, + "name in directory": {sum(content) + " nested/" + imageName + "\n", "no sum for " + imageName}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + m := newMirror(t) + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(c.sums)) + if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, c.want) { + t.Errorf("error = %q, want %q", msg, c.want) + } + if n := m.count("/bookworm/latest/" + imageName); n != 0 { + t.Errorf("image downloaded %d times before the sums were trusted", n) + } + }) + } +} + +func TestImage_RefusesOversizedSums(t *testing.T) { + m := newMirror(t) + m.put("/bookworm/latest/SHA512SUMS", make([]byte, 1<<20+1)) + if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, "larger than 1048576 bytes") { + t.Errorf("error = %q", msg) + } +} + +func TestImage_AcceptsSumsOfExactlyTheLimit(t *testing.T) { + m := newMirror(t) + content := []byte("qcow2 image") + line := sum(content) + " " + imageName + "\n" + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(strings.Repeat("\n", 1<<20-len(line))+line)) + + fetch(t, m, t.TempDir()) +} + +func TestImage_HTTPErrors(t *testing.T) { + for _, path := range []string{"/bookworm/latest/SHA512SUMS", "/bookworm/latest/" + imageName} { + t.Run(path, func(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + m.status[path] = http.StatusServiceUnavailable + cache := t.TempDir() + if msg := fetchError(t, m, cache); !strings.Contains(msg, "503") { + t.Errorf("error = %q, want the http status", msg) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v, want nothing", names) + } + }) + } +} + +func TestImage_RefusesARelativeCacheDir(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + if msg := fetchError(t, m, "cache"); !strings.Contains(msg, "must be an absolute path") { + t.Errorf("error = %q", msg) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 { + t.Errorf("sums read %d times", n) + } +} + +func TestImage_RefusesAURLWithoutFileName(t *testing.T) { + m := newMirror(t) + img := m.image() + img.URL = m.server.URL + "/" + _, err := Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}.Image(context.Background(), img) + if err == nil || !strings.Contains(err.Error(), "does not name a file") { + t.Errorf("error = %v", err) + } +} + +func TestImage_RejectsAnInterruptedDownloadAndLeavesNothing(t *testing.T) { + content := []byte("qcow2 image") + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "SHA512SUMS") { + w.Write([]byte(sum(content) + " " + imageName + "\n")) + return + } + w.Header().Set("Content-Length", "1000") + w.Write(content[:4]) + })) + defer server.Close() + cache := t.TempDir() + img := topology.Image{Name: "debian12", URL: server.URL + "/" + imageName, Sums: server.URL + "/SHA512SUMS"} + + _, err := Fetcher{Client: server.Client(), CacheDir: cache}.Image(context.Background(), img) + + if err == nil || !strings.Contains(err.Error(), "unexpected EOF") { + t.Errorf("error = %v, want an interrupted transfer", err) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v, want nothing", names) + } +} diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go new file mode 100644 index 0000000..98c1b2f --- /dev/null +++ b/internal/lab/provision/provision.go @@ -0,0 +1,151 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/render" + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + KeyFile = "lab_ed25519" + KeyLabel = "two-lab" + DiskSize = "20G" + SeedLabel = "cidata" + + ArgsFile = "qemu.args" +) + +var SeedFiles = []string{"user-data", "meta-data", "network-config"} + +type Runner interface { + Run(ctx context.Context, name string, args ...string) error +} + +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) error { + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + return nil +} + +type Options struct { + RunDir string + Fetcher Fetcher + Runner Runner +} + +func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, error) { + if !filepath.IsAbs(o.RunDir) { + return nil, fmt.Errorf("run dir %q must be an absolute path", o.RunDir) + } + if err := os.MkdirAll(o.RunDir, 0o700); err != nil { + return nil, err + } + + images := map[string]string{} + for _, n := range p.Nodes { + if _, ok := images[n.Image]; ok { + continue + } + img, ok := imageNamed(p, n.Image) + if !ok { + return nil, fmt.Errorf("node %s: image %q is not declared", n.Name, n.Image) + } + base, err := o.Fetcher.Image(ctx, img) + if err != nil { + return nil, err + } + images[n.Image] = base + } + + key, err := EnsureKey(ctx, o.Runner, o.RunDir) + if err != nil { + return nil, err + } + nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}}) + if err != nil { + return nil, err + } + for i, n := range nodes { + if err := Stage(ctx, o.Runner, n, images[p.Nodes[i].Image]); err != nil { + return nil, fmt.Errorf("node %s: %w", n.Name, err) + } + } + return nodes, nil +} + +func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) { + private := filepath.Join(dir, KeyFile) + if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) { + if err := r.Run(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", KeyLabel, "-f", private); err != nil { + return "", err + } + } else if err != nil { + return "", err + } + data, err := os.ReadFile(private + ".pub") + if err != nil { + return "", err + } + key := strings.TrimSpace(string(data)) + if key == "" || strings.ContainsAny(key, "\r\n") { + return "", fmt.Errorf("%s.pub: not a single public key", private) + } + return key, nil +} + +func WriteFiles(n render.Node) error { + if err := os.MkdirAll(n.Dir, 0o700); err != nil { + return err + } + files := map[string][]byte{ + ArgsFile: []byte(strings.Join(n.QEMU, "\n") + "\n"), + "meta-data": n.MetaData, + "user-data": n.UserData, + "network-config": n.NetworkConfig, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { + return err + } + } + return nil +} + +func Stage(ctx context.Context, r Runner, n render.Node, base string) error { + if !filepath.IsAbs(base) { + return fmt.Errorf("base image %q must be an absolute path", base) + } + if err := WriteFiles(n); err != nil { + return err + } + disk := filepath.Join(n.Dir, render.DiskFile) + seed := filepath.Join(n.Dir, render.SeedFile) + if err := r.Run(ctx, "qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, disk, DiskSize); err != nil { + return err + } + args := []string{"-quiet", "-output", seed, "-volid", SeedLabel, "-joliet", "-rock"} + for _, f := range SeedFiles { + args = append(args, filepath.Join(n.Dir, f)) + } + return r.Run(ctx, "genisoimage", args...) +} + +func imageNamed(p *topology.Plan, name string) (topology.Image, bool) { + for _, i := range p.Images { + if i.Name == name { + return i, true + } + } + return topology.Image{}, false +} diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go new file mode 100644 index 0000000..b05d693 --- /dev/null +++ b/internal/lab/provision/provision_test.go @@ -0,0 +1,317 @@ +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] } + hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] } +` + 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) + } +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 5d91389..0fd0ee6 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -18,6 +18,7 @@ const ( type Plan struct { Name string + Images []Image Segments []SegmentPlan Nodes []NodePlan Cables []Cable @@ -62,7 +63,7 @@ func Compute(t *Topology) (*Plan, error) { return nil, err } - p := &Plan{Name: t.Name} + p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)} for i, n := range t.Nodes { p.Nodes = append(p.Nodes, NodePlan{ Name: n.Name, diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 9c65b6d..1621621 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -107,6 +107,14 @@ func TestCompute_TwoHypervisorsPlan(t *testing.T) { } } +func TestCompute_CarriesTheDeclaredImages(t *testing.T) { + p := compute(t, twoHypervisors) + want := []Image{{Name: "deb", URL: "https://example.invalid/deb.qcow2", Sums: "https://example.invalid/SHA512SUMS"}} + if !reflect.DeepEqual(p.Images, want) { + t.Errorf("images = %+v, want %+v", p.Images, want) + } +} + func TestCompute_IsStableAcrossRuns(t *testing.T) { first := compute(t, twoHypervisors) for i := 0; i < 20; i++ {