package main import ( "cmp" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "time" "github.com/johnandersen777/socialweb-computer-kcp/internal/app/marketbidder" "github.com/johnandersen777/socialweb-computer-kcp/internal/assets" "github.com/johnandersen777/socialweb-computer-kcp/internal/clicfg" "github.com/johnandersen777/socialweb-computer-kcp/internal/devinstance" "github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc" "github.com/johnandersen777/socialweb-computer-kcp/internal/progress" "github.com/johnandersen777/socialweb-computer-kcp/internal/versions" "github.com/johnandersen777/socialweb-computer-kcp/internal/vmruntime" ) var vmOptions = append([]string{backendOpt, "instance", "state-dir", "asset-cache", "handle", "vm-runtime-dir", "guest-image-source", "vm-stop-grace"}, devOnly("userns-stage-path", "pasta-path", "vmexec-path", "firecracker-path")...) func (a *app) vmExe(vals clicfg.Values, executable, option string) (string, error) { if !assets.Release() { p := vals.String(option) if p == "" || !filepath.IsAbs(p) { return "", fmt.Errorf("%s must be an absolute path in this dev build (never PATH), got %q", option, p) } return p, nil } cache, err := a.assetCache(vals) if err != nil { return "", err } e, err := assets.Resolve(assets.Options{Cache: cache, Log: a.log}, executable) if err != nil { return "", err } return e.Path, nil } func (a *app) vmRuntime(ctx context.Context, vals clicfg.Values) (vmruntime.Runtime, hostsvc.Manager, error) { state := vals.String("state-dir") if state == "" || !filepath.IsAbs(state) { return nil, nil, fmt.Errorf("state-dir must be an absolute path: the VM work dirs, images and the slot lock live under it") } if vals.String("handle") == "" { return nil, nil, fmt.Errorf("handle is required: VM units are named by the handle of the bidder instance that owns them") } var exe vmruntime.Executables for _, x := range []struct { dst *string name, option string }{ {&exe.Stage, "swc-userns-stage", "userns-stage-path"}, {&exe.Pasta, "pasta", "pasta-path"}, {&exe.VMExec, "socialweb-vmexec", "vmexec-path"}, {&exe.Firecracker, "firecracker", "firecracker-path"}, } { p, err := a.vmExe(vals, x.name, x.option) if err != nil { return nil, nil, err } *x.dst = p } p, err := a.placement(ctx, vals) if err != nil { return nil, nil, err } grace, err := vmStopGrace(vals) if err != nil { p.manager.Close() return nil, nil, err } rt, err := vmruntime.ForHost(vmruntime.Config{ Manager: p.manager, StateDir: state, RuntimeDir: vals.String("vm-runtime-dir"), Exec: exe, ImageSource: vals.String("guest-image-source"), StopGrace: grace, ImageOut: a.stderr, ImageTTY: progress.IsTTYWriter(a.stderr), }) if err != nil { p.manager.Close() return nil, nil, err } return rt, p.manager, nil } var vmExecutables = []struct{ name, option string }{ {"swc-userns-stage", "userns-stage-path"}, {"pasta", "pasta-path"}, {"socialweb-vmexec", "vmexec-path"}, {"firecracker", "firecracker-path"}, } var devVMOptions = append([]string{"handle", "vm-host-backend", "vm-runtime-dir", "guest-image-source", "vm-stop-grace"}, devOnly("userns-stage-path", "pasta-path", "vmexec-path", "firecracker-path")...) func (a *app) devVMExecutables(vals clicfg.Values) (map[string]devinstance.Executable, error) { out := map[string]devinstance.Executable{} for _, x := range vmExecutables { if !assets.Release() && vals.String(x.option) == "" { continue } p, err := a.vmExe(vals, x.name, x.option) if err != nil { if assets.Release() { if a.log != nil { a.log.Warn("the dev VM executable does not resolve; the network will record it missing", "executable", x.name, "err", err.Error()) } continue } return nil, err } out[x.name] = devinstance.Executable{Path: p} } downloads, err := a.guestDownloads(vals) switch { case err != nil && a.log != nil: a.log.Warn("the guest downloads asset does not resolve; the GuestControlPlane serves no /v1/downloads", "asset", marketbidder.GuestDownloadsExecutable, "err", err.Error()) case downloads.Path != "": out[marketbidder.GuestDownloadsExecutable] = downloads } return out, nil } func (a *app) guestDownloads(vals clicfg.Values) (devinstance.Executable, error) { if !assets.Release() { return devinstance.Executable{}, nil } cache, err := a.assetCache(vals) if err != nil { return devinstance.Executable{}, err } e, err := assets.Resolve(assets.Options{Cache: cache, Log: a.log}, marketbidder.GuestDownloadsExecutable) if err != nil { return devinstance.Executable{}, err } return devinstance.Executable{Path: e.Path, Version: e.Version}, nil } func (a *app) devVM(vals clicfg.Values, l devinstance.Layout) (*devinstance.VMRuntime, error) { handle := vals.String("handle") if handle == "" { return nil, nil } backend := hostsvc.BackendProc if b := vals.String("vm-host-backend"); b != "" { var err error if backend, err = hostsvc.ParseBackend(b); err != nil { return nil, err } } rt := vals.String("vm-runtime-dir") rt = cmp.Or(rt, l.RuntimeDir) if !filepath.IsAbs(rt) { return nil, fmt.Errorf("vm-runtime-dir %q must be absolute: each VM's API socket lives under it", rt) } grace, err := vmStopGrace(vals) if err != nil { return nil, err } vm := &devinstance.VMRuntime{Handle: handle, Backend: backend, ImageSource: vals.String("guest-image-source"), StopGrace: grace.String(), RuntimeDir: rt, Executables: map[string]string{}} for _, x := range vmExecutables { p, err := a.vmExe(vals, x.name, x.option) if err != nil { return nil, err } vm.Executables[x.name] = p } return vm, nil } func vmStopGrace(vals clicfg.Values) (time.Duration, error) { if s := vals.String("vm-stop-grace"); s != "" { d, err := time.ParseDuration(s) if err != nil { return 0, fmt.Errorf("vm-stop-grace: %w", err) } return d, nil } return versions.VMStopGrace() } func (a *app) instanceVMRuntime(ctx context.Context, rec devinstance.Record) (vmruntime.Runtime, hostsvc.Manager, error) { v := rec.VM cfg := hostsvc.Config{Backend: v.Backend} if v.Backend == hostsvc.BackendProc { cfg.ProcSocket = rec.Layout.Socket if rec.Parent != nil { cfg.ProcSocket = rec.Parent.Socket } } m, err := hostsvc.Open(ctx, cfg) if err != nil { return nil, nil, fmt.Errorf("the VM placement backend %s: %w", v.Backend, err) } grace, err := time.ParseDuration(v.StopGrace) if err != nil { m.Close() return nil, nil, err } rt, err := vmruntime.ForHost(vmruntime.Config{Manager: m, StateDir: rec.Layout.StateDir, RuntimeDir: v.RuntimeDir, Exec: vmruntime.Executables{Stage: v.Executables["swc-userns-stage"], Pasta: v.Executables["pasta"], VMExec: v.Executables["socialweb-vmexec"], Firecracker: v.Executables["firecracker"]}, ImageSource: v.ImageSource, StopGrace: grace, ImageOut: a.stderr, ImageTTY: progress.IsTTYWriter(a.stderr)}) if err != nil { m.Close() return nil, nil, err } return rt, m, nil } func (a *app) downVMs(ctx context.Context, rec devinstance.Record) ([]string, []string) { if rec.VM == nil { return nil, nil } rt, m, err := a.instanceVMRuntime(ctx, rec) if err != nil { a.log.Warn("the VM runtime of the instance; its VMs are not stopped here", "err", err.Error()) return nil, []string{err.Error()} } defer m.Close() listed, err := rt.List(ctx, rec.VM.Handle) if err != nil { return nil, []string{err.Error()} } var stopped, left []string for _, l := range listed { ref, err := rt.Load(rec.VM.Handle, l.Name) if err != nil { ref = vmruntime.RuntimeRef{Handle: rec.VM.Handle, Name: l.Name, Unit: l.Unit} } res, err := rt.Stop(ctx, ref) if err != nil { left = append(left, l.Unit+": "+err.Error()) continue } stopped = append(stopped, l.Unit) a.log.Info("VM stopped with its instance", "unit", l.Unit, "graceful", res.Graceful, "result", res.Result) } if err := m.StopSlice(ctx, vmruntime.SliceName(rec.VM.Handle)); err != nil { left = append(left, err.Error()) } return stopped, left } func runVM(verb string) verbFunc { return func(ctx context.Context, a *app, vals clicfg.Values, rest []string) int { rt, m, err := a.vmRuntime(ctx, vals) if err != nil { return a.fail(exitRefused, "vm %s: %v", verb, err) } defer m.Close() handle := vals.String("handle") name := "" if verb != "start" && verb != "list" { if len(rest) != 1 { return a.fail(exitRefused, "vm %s: name exactly one VM", verb) } name = rest[0] } var out any switch verb { case "start": raw, err := io.ReadAll(os.Stdin) if err != nil { return a.fail(exitFailed, "vm start: reading the spec from stdin: %v", err) } var spec vmruntime.VMSpec if err := json.Unmarshal(raw, &spec); err != nil { return a.fail(exitRefused, "vm start: the spec on stdin: %v", err) } spec.Handle = handle p, err := rt.Prepare(ctx, spec) if err != nil { return a.fail(exitFailed, "vm start: %v", err) } ref, err := rt.Start(ctx, p) if err != nil { return a.fail(exitFailed, "vm start: %v", err) } out = ref case "inspect": ref, err := rt.Load(handle, name) if os.IsNotExist(err) { out = vmruntime.Observation{Verdict: hostsvc.VerdictLost.String(), Reason: hostsvc.LostUnitGone, Detail: "no runtime record for " + name} break } if err != nil { return a.fail(exitFailed, "vm inspect: %v", err) } obs, err := rt.Inspect(ctx, ref) if err != nil { return a.fail(exitFailed, "vm inspect: %v", err) } out = obs case "stop": ref, err := rt.Load(handle, name) if os.IsNotExist(err) { ref = vmruntime.RuntimeRef{Handle: handle, Name: name, Unit: vmruntime.UnitName(handle, name)} } else if err != nil { return a.fail(exitFailed, "vm stop: %v", err) } res, err := rt.Stop(ctx, ref) if err != nil { return a.fail(exitFailed, "vm stop: %v", err) } out = res case "list": l, err := rt.List(ctx, handle) if err != nil { return a.fail(exitFailed, "vm list: %v", err) } out = l } a.printJSON(out) return exitOK } } func devOnly(names ...string) []string { if clicfg.ReleaseBuild() { return nil } return names }