f-50: lab: lancement, état, arrêt et accès SSH des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
d1cd943d86
commit
1146d13d45
10 changed files with 1309 additions and 4 deletions
193
cmd/lab/lifecycle.go
Normal file
193
cmd/lab/lifecycle.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/machine"
|
||||
"git.g3e.fr/syonad/two/internal/lab/provision"
|
||||
)
|
||||
|
||||
const (
|
||||
topologyFile = "topology.yml"
|
||||
pollInterval = 5 * time.Second
|
||||
stopTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
execve = syscall.Exec
|
||||
procDir = "/proc"
|
||||
)
|
||||
|
||||
func defaultDir(parts ...string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(append([]string{home}, parts...)...)
|
||||
}
|
||||
|
||||
func flags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() { fmt.Fprint(stderr, usage) }
|
||||
return fs, fs.String("run", defaultDir("lab-run"), "run directory of the lab")
|
||||
}
|
||||
|
||||
func lab(runDir string, plan string, stdout, stderr io.Writer) (machine.Lab, bool) {
|
||||
dir, err := filepath.Abs(runDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return machine.Lab{}, false
|
||||
}
|
||||
p, ok := load(plan, stderr)
|
||||
if !ok {
|
||||
return machine.Lab{}, false
|
||||
}
|
||||
return machine.Lab{
|
||||
Plan: p,
|
||||
RunDir: dir,
|
||||
ProcDir: procDir,
|
||||
Runner: provision.ExecRunner{},
|
||||
Poll: pollInterval,
|
||||
Stop: stopTimeout,
|
||||
Out: stdout,
|
||||
}, true
|
||||
}
|
||||
|
||||
func current(runDir string, stdout, stderr io.Writer) (machine.Lab, bool) {
|
||||
saved := filepath.Join(runDir, topologyFile)
|
||||
if _, err := os.Stat(saved); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: no lab in %s: %v\n", runDir, err)
|
||||
return machine.Lab{}, false
|
||||
}
|
||||
return lab(runDir, saved, stdout, stderr)
|
||||
}
|
||||
|
||||
func upCmd(args []string, stdout, stderr io.Writer) int {
|
||||
fs, runDir := flags("up", stderr)
|
||||
cacheDir := fs.String("cache", defaultDir(".cache", "two-lab"), "image cache directory")
|
||||
timeout := fs.Duration("timeout", 20*time.Minute, "how long to wait for the nodes to be ready")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 1 {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
cache, err := filepath.Abs(*cacheDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(*runDir, topologyFile)); err == nil {
|
||||
previous, ok := current(*runDir, stdout, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
running, err := previous.Running()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if len(running) > 0 {
|
||||
fmt.Fprintf(stderr, "lab: lab %s is still running in %s: 'lab down' first\n", previous.Plan.Name, previous.RunDir)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
l, ok := lab(*runDir, fs.Arg(0), stdout, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
source, err := os.ReadFile(fs.Arg(0))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := os.MkdirAll(l.RunDir, 0o700); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(l.RunDir, topologyFile), source, 0o600); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
fetcher := provision.Fetcher{Client: &http.Client{}, CacheDir: cache}
|
||||
if err := l.Up(ctx, fetcher, *timeout); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\nlab: started nodes keep running: 'lab status', 'lab down'\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func statusCmd(args []string, stdout, stderr io.Writer) int {
|
||||
fs, runDir := flags("status", stderr)
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
l, ok := current(*runDir, stdout, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
if err := l.Status(stdout); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func downCmd(args []string, stdout, stderr io.Writer) int {
|
||||
fs, runDir := flags("down", stderr)
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
l, ok := current(*runDir, stdout, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
if err := l.Down(context.Background()); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sshCmd(args []string, stdout, stderr io.Writer) int {
|
||||
fs, runDir := flags("ssh", stderr)
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() < 1 {
|
||||
fs.Usage()
|
||||
return 2
|
||||
}
|
||||
l, ok := current(*runDir, stdout, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
argv, err := l.SSH(fs.Arg(0), isTerminal(os.Stdin), fs.Args()[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
path, err := exec.LookPath(argv[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := execve(path, argv, os.Environ()); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -21,6 +21,22 @@ const usage = `usage: lab <command> [options] <topology.yml> [dir]
|
|||
render -key <public key file> <topology.yml> <dir>
|
||||
write, for each node, <dir>/<node>/qemu.args (one argument per line) and the
|
||||
cloud-init seed files meta-data, user-data and network-config
|
||||
|
||||
up [-run dir] [-cache dir] [-timeout duration] <topology.yml>
|
||||
download and verify the images, create fresh disks and seeds, start every node
|
||||
(switches first) and wait until cloud-init has finished on each of them;
|
||||
the topology is kept in <run>/topology.yml for the commands below
|
||||
|
||||
status [-run dir]
|
||||
list the nodes of the lab and whether their qemu process is running
|
||||
|
||||
down [-run dir]
|
||||
stop every qemu process of the lab (SIGTERM, then SIGKILL)
|
||||
|
||||
ssh [-run dir] <node> [command...]
|
||||
open a shell on a node, or run a command on it, with the key generated by up
|
||||
|
||||
defaults: -run $HOME/lab-run, -cache $HOME/.cache/two-lab, -timeout 20m
|
||||
`
|
||||
|
||||
type keyFiles []string
|
||||
|
|
@ -46,6 +62,14 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||
return plan(args[1], stdout, stderr)
|
||||
case "render":
|
||||
return renderCmd(args[1:], stdout, stderr)
|
||||
case "up":
|
||||
return upCmd(args[1:], stdout, stderr)
|
||||
case "status":
|
||||
return statusCmd(args[1:], stdout, stderr)
|
||||
case "down":
|
||||
return downCmd(args[1:], stdout, stderr)
|
||||
case "ssh":
|
||||
return sshCmd(args[1:], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ package main
|
|||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -119,3 +122,180 @@ func TestRun_RenderRefusesMissingKeyFile(t *testing.T) {
|
|||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func savedLab(t *testing.T) string {
|
||||
t.Helper()
|
||||
run := filepath.Join(t.TempDir(), "run")
|
||||
if err := os.MkdirAll(run, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
example, err := os.ReadFile(filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(run, "topology.yml"), example, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func TestRun_LifecycleUsage(t *testing.T) {
|
||||
for _, args := range [][]string{{"up"}, {"up", "a.yml", "b.yml"}, {"status", "x"}, {"down", "x"}, {"ssh"}, {"up", "-bogus", "a.yml"}} {
|
||||
code, _, stderr := runLab(args...)
|
||||
if code != 2 || !strings.Contains(stderr, "usage: lab") {
|
||||
t.Errorf("args %v: code %d, stderr %q", args, code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CommandsWithoutALabInTheRunDir(t *testing.T) {
|
||||
run := t.TempDir()
|
||||
for _, cmd := range []string{"status", "down", "ssh"} {
|
||||
args := []string{cmd, "-run", run}
|
||||
if cmd == "ssh" {
|
||||
args = append(args, "hv1")
|
||||
}
|
||||
code, _, stderr := runLab(args...)
|
||||
if code != 1 || !strings.Contains(stderr, "lab: no lab in "+run) {
|
||||
t.Errorf("%s: code %d, stderr %q", cmd, code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_StatusOfAStoppedLab(t *testing.T) {
|
||||
code, stdout, stderr := runLab("status", "-run", savedLab(t))
|
||||
want := `node role state pid ssh
|
||||
sw1 switch stopped - 127.0.0.1:2200
|
||||
rr1 rr stopped - 127.0.0.1:2201
|
||||
hv1 hypervisor stopped - 127.0.0.1:2202
|
||||
hv2 hypervisor stopped - 127.0.0.1:2203
|
||||
`
|
||||
if code != 0 || stdout != want {
|
||||
t.Errorf("code %d, stderr %q, stdout:\n%s", code, stderr, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_DownOfAStoppedLab(t *testing.T) {
|
||||
code, stdout, stderr := runLab("down", "-run", savedLab(t))
|
||||
if code != 0 || stdout != "" || stderr != "" {
|
||||
t.Errorf("code %d, stdout %q, stderr %q", code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_SSHExecsSSHWithTheNodePort(t *testing.T) {
|
||||
run := savedLab(t)
|
||||
var gotPath string
|
||||
var gotArgv []string
|
||||
execve = func(path string, argv []string, env []string) error {
|
||||
gotPath, gotArgv = path, argv
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { execve = syscall.Exec })
|
||||
|
||||
code, _, stderr := runLab("ssh", "-run", run, "hv2", "ip", "-br", "a")
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
if filepath.Base(gotPath) != "ssh" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
want := []string{"ssh",
|
||||
"-i", filepath.Join(run, "lab_ed25519"),
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "IdentityAgent=none",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-p", "2203",
|
||||
"debian@127.0.0.1",
|
||||
"ip", "-br", "a",
|
||||
}
|
||||
if strings.Join(gotArgv, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("\n got %q\nwant %q", gotArgv, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_SSHUnknownNode(t *testing.T) {
|
||||
code, _, stderr := runLab("ssh", "-run", savedLab(t), "hv9")
|
||||
if code != 1 || !strings.Contains(stderr, `node "hv9" is not in lab evpn-2hv`) {
|
||||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UpRefusesAnInvalidTopologyAndKeepsTheSavedOne(t *testing.T) {
|
||||
run := savedLab(t)
|
||||
before, _ := os.ReadFile(filepath.Join(run, "topology.yml"))
|
||||
bad := filepath.Join(t.TempDir(), "bad.yml")
|
||||
if err := os.WriteFile(bad, []byte("name: x\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, _, stderr := runLab("up", "-run", run, bad)
|
||||
|
||||
if code != 1 || !strings.Contains(stderr, "at least one node is required") {
|
||||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) {
|
||||
t.Error("the saved topology was replaced by an invalid one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminal_DevNullAndPipesAreNotTerminals(t *testing.T) {
|
||||
null, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer null.Close()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
for name, f := range map[string]*os.File{"/dev/null": null, "pipe": r} {
|
||||
if isTerminal(f) {
|
||||
t.Errorf("%s is detected as a terminal", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) {
|
||||
run := savedLab(t)
|
||||
before, _ := os.ReadFile(filepath.Join(run, "topology.yml"))
|
||||
proc := t.TempDir()
|
||||
procDir = proc
|
||||
t.Cleanup(func() { procDir = "/proc" })
|
||||
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
|
||||
pid := strconv.Itoa(cmd.Process.Pid)
|
||||
if err := os.MkdirAll(filepath.Join(proc, pid), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(proc, pid, "cmdline"), []byte("qemu-system-x86_64\x00-name\x00hv1\x00"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(run, "hv1"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(run, "hv1", "qemu.pid"), []byte(pid+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
other := filepath.Join(t.TempDir(), "other.yml")
|
||||
if err := os.WriteFile(other, bytes.ReplaceAll(before, []byte("evpn-2hv"), []byte("other")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _, stderr := runLab("up", "-run", run, other)
|
||||
|
||||
if code != 1 || !strings.Contains(stderr, "lab evpn-2hv is still running in "+run) {
|
||||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) {
|
||||
t.Error("the topology of a running lab was replaced")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
cmd/lab/terminal.go
Normal file
14
cmd/lab/terminal.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//go:build linux || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func isTerminal(f *os.File) bool {
|
||||
_, err := unix.IoctlGetTermios(int(f.Fd()), ioctlReadTermios)
|
||||
return err == nil
|
||||
}
|
||||
5
cmd/lab/terminal_darwin.go
Normal file
5
cmd/lab/terminal_darwin.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TIOCGETA
|
||||
5
cmd/lab/terminal_linux.go
Normal file
5
cmd/lab/terminal_linux.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TCGETS
|
||||
7
cmd/lab/terminal_other.go
Normal file
7
cmd/lab/terminal_other.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build !linux && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func isTerminal(*os.File) bool { return false }
|
||||
Loading…
Add table
Add a link
Reference in a new issue