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:
GnomeZworc 2026-10-04 14:49:34 +02:00
commit 1146d13d45
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
10 changed files with 1309 additions and 4 deletions

View file

@ -0,0 +1,269 @@
package machine
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"text/tabwriter"
"time"
"git.g3e.fr/syonad/two/internal/lab/provision"
"git.g3e.fr/syonad/two/internal/lab/render"
"git.g3e.fr/syonad/two/internal/lab/topology"
)
const (
User = "debian"
sshUnreachable = 255
cloudInitRecoverable = 2
)
type Lab struct {
Plan *topology.Plan
RunDir string
ProcDir string
Runner provision.Runner
Poll time.Duration
Stop time.Duration
Out io.Writer
signal func(pid int, sig syscall.Signal) error
}
func (l Lab) node(name string) (topology.NodePlan, error) {
for _, n := range l.Plan.Nodes {
if n.Name == name {
return n, nil
}
}
return topology.NodePlan{}, fmt.Errorf("node %q is not in lab %s", name, l.Plan.Name)
}
func (l Lab) pidFile(name string) string {
return filepath.Join(l.RunDir, name, render.PIDFile)
}
func (l Lab) PID(name string) (int, error) {
data, err := os.ReadFile(l.pidFile(name))
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 0 {
return 0, fmt.Errorf("%s: not a pid: %q", l.pidFile(name), data)
}
if !l.runs(pid, name) {
return 0, nil
}
return pid, nil
}
func (l Lab) runs(pid int, name string) bool {
if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) {
return false
}
cmdline, err := os.ReadFile(filepath.Join(l.ProcDir, strconv.Itoa(pid), "cmdline"))
if err != nil {
return false
}
return bytes.Contains(cmdline, []byte("\x00-name\x00"+name+"\x00"))
}
func (l Lab) Running() ([]string, error) {
var running []string
for _, n := range l.Plan.Nodes {
pid, err := l.PID(n.Name)
if err != nil {
return nil, err
}
if pid != 0 {
running = append(running, n.Name)
}
}
return running, nil
}
func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Duration) error {
running, err := l.Running()
if err != nil {
return err
}
if len(running) > 0 {
return fmt.Errorf("lab %s is already running (%s): 'lab down' first", l.Plan.Name, strings.Join(running, ", "))
}
nodes, err := provision.Prepare(ctx, l.Plan, provision.Options{RunDir: l.RunDir, Fetcher: fetcher, Runner: l.Runner})
if err != nil {
return err
}
for _, n := range switchesFirst(l.Plan, nodes) {
if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := l.Runner.Run(ctx, render.QEMUBinary, append(n.QEMU, "-daemonize")...); err != nil {
return fmt.Errorf("node %s: %w", n.Name, err)
}
fmt.Fprintf(l.Out, "%s: started\n", n.Name)
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var errs []error
for _, n := range l.Plan.Nodes {
if err := l.waitReady(ctx, n); err != nil {
errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
continue
}
fmt.Fprintf(l.Out, "%s: ready\n", n.Name)
}
return errors.Join(errs...)
}
func switchesFirst(p *topology.Plan, nodes []render.Node) []render.Node {
var first, rest []render.Node
for i, n := range nodes {
if p.Nodes[i].Role == topology.RoleSwitch {
first = append(first, n)
} else {
rest = append(rest, n)
}
}
return append(first, rest...)
}
func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error {
args := l.sshArgs(n, true, []string{"cloud-init", "status", "--wait"})
for {
err := l.Runner.Run(ctx, "ssh", args...)
switch code := exitCode(err); {
case err == nil:
return nil
case code == cloudInitRecoverable:
fmt.Fprintf(l.Out, "%s: cloud-init finished with recoverable errors: %v\n", n.Name, err)
return nil
case code != sshUnreachable:
return fmt.Errorf("cloud-init failed: %w", err)
}
select {
case <-ctx.Done():
return fmt.Errorf("not reachable over ssh: %w", ctx.Err())
case <-time.After(l.Poll):
}
}
}
func exitCode(err error) int {
var coded interface{ ExitCode() int }
if errors.As(err, &coded) {
return coded.ExitCode()
}
return -1
}
func (l Lab) sshArgs(n topology.NodePlan, batch bool, command []string) []string {
args := []string{
"-i", filepath.Join(l.RunDir, provision.KeyFile),
"-o", "IdentitiesOnly=yes",
"-o", "IdentityAgent=none",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-p", strconv.Itoa(n.SSHPort),
}
if batch {
args = append(args, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5")
}
args = append(args, User+"@127.0.0.1")
return append(args, command...)
}
func (l Lab) SSH(name string, terminal bool, command []string) ([]string, error) {
n, err := l.node(name)
if err != nil {
return nil, err
}
args := []string{"ssh"}
if terminal && len(command) > 0 {
args = append(args, "-t")
}
return append(args, l.sshArgs(n, false, command)...), nil
}
func (l Lab) Status(w io.Writer) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "node\trole\tstate\tpid\tssh\n")
for _, n := range l.Plan.Nodes {
pid, err := l.PID(n.Name)
if err != nil {
return err
}
state, shown := "stopped", "-"
if pid != 0 {
state, shown = "running", strconv.Itoa(pid)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t127.0.0.1:%d\n", n.Name, n.Role, state, shown, n.SSHPort)
}
return tw.Flush()
}
func (l Lab) Down(ctx context.Context) error {
var errs []error
for _, n := range l.Plan.Nodes {
pid, err := l.PID(n.Name)
if err != nil {
errs = append(errs, err)
continue
}
if pid == 0 {
continue
}
if err := l.stop(ctx, n.Name, pid); err != nil {
errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
continue
}
if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, err)
continue
}
fmt.Fprintf(l.Out, "%s: stopped\n", n.Name)
}
return errors.Join(errs...)
}
func (l Lab) stop(ctx context.Context, name string, pid int) error {
if pid <= 0 {
return fmt.Errorf("refusing to signal pid %d", pid)
}
send := l.signal
if send == nil {
send = syscall.Kill
}
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL} {
if err := send(pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
deadline := time.Now().Add(l.Stop)
for l.runs(pid, name) && time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(l.Poll):
}
}
if !l.runs(pid, name) {
return nil
}
}
return fmt.Errorf("pid %d still running after SIGKILL", pid)
}