f-50: lab: image vérifiée, disques et seeds des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
99ecb59aad
commit
d1cd943d86
7 changed files with 999 additions and 20 deletions
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.g3e.fr/syonad/two/internal/lab/provision"
|
||||||
"git.g3e.fr/syonad/two/internal/lab/render"
|
"git.g3e.fr/syonad/two/internal/lab/render"
|
||||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||||
)
|
)
|
||||||
|
|
@ -111,7 +112,7 @@ func renderCmd(args []string, stdout, stderr io.Writer) int {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if err := writeNode(n); err != nil {
|
if err := provision.WriteFiles(n); err != nil {
|
||||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
@ -137,21 +138,3 @@ func readKeys(files []string) ([]string, error) {
|
||||||
}
|
}
|
||||||
return keys, nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
185
internal/lab/provision/image.go
Normal file
185
internal/lab/provision/image.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
334
internal/lab/provision/image_test.go
Normal file
334
internal/lab/provision/image_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
internal/lab/provision/provision.go
Normal file
151
internal/lab/provision/provision.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
317
internal/lab/provision/provision_test.go
Normal file
317
internal/lab/provision/provision_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ const (
|
||||||
|
|
||||||
type Plan struct {
|
type Plan struct {
|
||||||
Name string
|
Name string
|
||||||
|
Images []Image
|
||||||
Segments []SegmentPlan
|
Segments []SegmentPlan
|
||||||
Nodes []NodePlan
|
Nodes []NodePlan
|
||||||
Cables []Cable
|
Cables []Cable
|
||||||
|
|
@ -62,7 +63,7 @@ func Compute(t *Topology) (*Plan, error) {
|
||||||
return nil, err
|
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 {
|
for i, n := range t.Nodes {
|
||||||
p.Nodes = append(p.Nodes, NodePlan{
|
p.Nodes = append(p.Nodes, NodePlan{
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCompute_IsStableAcrossRuns(t *testing.T) {
|
||||||
first := compute(t, twoHypervisors)
|
first := compute(t, twoHypervisors)
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue