Virtualization tab, phase 1: Incus per machine — detect, install, instances, consoles, snapshots, images
The tab reports one of four states for the selected machine (ready, repair, installable, unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the pool drivers the kernel has — and acts on that machine only. Install is one followed task: packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file, btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a restart. Instances list with live CPU and memory, create from the images: catalogue or a local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/ delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry, limits editing. Images: local store, catalogue search and download (exported into the library too), import a tarball or a VM disk from a path, publish an instance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
af53b45b67
commit
240129c83f
@@ -34,6 +34,7 @@ import (
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/hardware"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/network"
|
||||
"hamlab/internal/nodes"
|
||||
@@ -75,6 +76,7 @@ type Server struct {
|
||||
update updateState
|
||||
events eventLog
|
||||
kvms kvmProxies
|
||||
virt *incus.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
nodes *nodes.Manager
|
||||
@@ -104,6 +106,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
if s.user == "" {
|
||||
s.user = "?"
|
||||
}
|
||||
s.virt = incus.New(cfg.DataDir(), s.user, version)
|
||||
s.migrateUsers()
|
||||
// The speed test and the outside view. Its history outlives a restart, and its last result
|
||||
// is published as sensors, so the line is graphable and alertable like anything else.
|
||||
@@ -239,6 +242,20 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
|
||||
m.HandleFunc("GET /api/virt", s.auth(s.virtOverview))
|
||||
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
|
||||
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
|
||||
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
|
||||
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
|
||||
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
|
||||
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
|
||||
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
|
||||
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
|
||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
||||
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
||||
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
package api
|
||||
|
||||
// The Virtualization tab: LXC containers and KVM virtual machines through Incus, per
|
||||
// machine. Three page states — ready, repairable, installable, or unavailable with the
|
||||
// reason — and every action lands on the machine that was asked. Long work (install, create,
|
||||
// image downloads, publish) runs as a followed task; consoles are terminal sessions like
|
||||
// any other, so they reattach and work through the hub's proxy.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/incus"
|
||||
)
|
||||
|
||||
func readBody(r *http.Request, v any, max int64) error {
|
||||
return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v)
|
||||
}
|
||||
|
||||
// virtOverview is the page: the machine's state, and when Incus answers, everything on it.
|
||||
func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.virt.Detect(r.Context())
|
||||
out := map[string]any{"status": st}
|
||||
if st.State == "ready" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
inst, err := s.virt.Instances(ctx)
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
out["instances"] = inst
|
||||
imgs, _ := s.virt.Images(ctx)
|
||||
out["images"] = imgs
|
||||
out["pools"] = s.virt.Pools(ctx)
|
||||
out["networks"] = s.virt.Networks(ctx)
|
||||
}
|
||||
out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": s.virt.Library(s.virt.ISOsDir())}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) {
|
||||
var o incus.InstallOpts
|
||||
if err := readBody(r, &o, 16<<10); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
st := s.virt.Detect(r.Context())
|
||||
if st.State != "installable" && st.State != "repair" {
|
||||
jsonErr(w, 409, "nothing to install here: "+st.State)
|
||||
return
|
||||
}
|
||||
if err := s.virt.EnsureDirs(); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
script, lay, err := s.virt.InstallScript(st, o)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.virt.SaveLayout(lay); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// kept beside the layout, so what was run is on record
|
||||
_ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700)
|
||||
t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script))
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool {
|
||||
if st := s.virt.Detect(r.Context()); st.State != "ready" {
|
||||
jsonErr(w, 409, "Incus isn't ready on this machine")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- images ----
|
||||
|
||||
func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1")
|
||||
if err != nil {
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" {
|
||||
var f []incus.Image
|
||||
for _, im := range list {
|
||||
if im.Type == t {
|
||||
f = append(f, im)
|
||||
}
|
||||
}
|
||||
list = f
|
||||
}
|
||||
if list == nil {
|
||||
list = []incus.Image{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"images": list})
|
||||
}
|
||||
|
||||
func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
alias := strings.TrimSpace(body.Alias)
|
||||
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||
jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
local := strings.ReplaceAll(alias, "/", "-")
|
||||
// copied into the local store under a flat alias, then exported to the library so the
|
||||
// file is Ham Lab's whether or not Incus keeps its cache
|
||||
script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
|
||||
t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir()))
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Alias string `json:"alias"`
|
||||
Type string `json:"type"` // container | virtual-machine
|
||||
}
|
||||
_ = readBody(r, &body, 8192)
|
||||
alias := strings.TrimSpace(body.Alias)
|
||||
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||
jsonErr(w, 400, "alias: letters, digits, dots, dashes")
|
||||
return
|
||||
}
|
||||
p := filepath.Clean(body.Path)
|
||||
st, err := os.Stat(p)
|
||||
if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() {
|
||||
jsonErr(w, 400, "path: a file on this machine")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p))
|
||||
var script string
|
||||
if body.Type == "virtual-machine" {
|
||||
// a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes
|
||||
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"`
|
||||
} else {
|
||||
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"`
|
||||
}
|
||||
t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch()))
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Instance string `json:"instance"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
alias := strings.TrimSpace(body.Alias)
|
||||
if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||
jsonErr(w, 400, "an instance and an alias")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
|
||||
t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir()))
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
if !incus.ValidImage(body.Ref) {
|
||||
jsonErr(w, 400, "bad image")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- instances ----
|
||||
|
||||
func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var sp incus.CreateSpec
|
||||
if err := readBody(r, &sp, 512<<10); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
st := s.virt.Detect(r.Context())
|
||||
if st.State != "ready" {
|
||||
jsonErr(w, 409, "Incus isn't ready on this machine")
|
||||
return
|
||||
}
|
||||
args, err := incus.CreateArgs(sp, st.KVM)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if inst, err := s.virt.Instances(r.Context()); err == nil {
|
||||
for _, in := range inst {
|
||||
if in.Name == sp.Name {
|
||||
jsonErr(w, 409, "there is already an instance called "+sp.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
kind := "container"
|
||||
if sp.VM {
|
||||
kind = "VM"
|
||||
}
|
||||
t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...))
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if !incus.ValidName(name) {
|
||||
jsonErr(w, 400, "bad name")
|
||||
return
|
||||
}
|
||||
list, err := s.virt.Instances(r.Context())
|
||||
if err != nil {
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
for _, in := range list {
|
||||
if in.Name == name {
|
||||
jsonOK(w, 200, in)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonErr(w, 404, "no such instance")
|
||||
}
|
||||
|
||||
// virtAction is start / stop / restart / kill / delete, and the two consoles.
|
||||
func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) {
|
||||
name, action := r.PathValue("name"), r.PathValue("action")
|
||||
if !incus.ValidName(name) {
|
||||
jsonErr(w, 400, "bad name")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
var args []string
|
||||
switch action {
|
||||
case "start":
|
||||
args = []string{"start", name}
|
||||
case "stop":
|
||||
args = []string{"stop", name}
|
||||
case "restart":
|
||||
args = []string{"restart", name}
|
||||
case "kill":
|
||||
args = []string{"stop", "--force", name}
|
||||
case "delete":
|
||||
var body struct {
|
||||
Confirm string `json:"confirm"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
if body.Confirm != name {
|
||||
jsonErr(w, 400, "type the instance's name to delete it")
|
||||
return
|
||||
}
|
||||
args = []string{"delete", name}
|
||||
if body.Force {
|
||||
args = []string{"delete", "--force", name}
|
||||
}
|
||||
case "console", "exec":
|
||||
s.virtTerminal(w, r, name, action)
|
||||
return
|
||||
default:
|
||||
jsonErr(w, 400, "unknown action")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
log.Printf("virt: %s %s (from %s)", action, name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// virtTerminal opens the serial console or a shell as a terminal session.
|
||||
func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) {
|
||||
var argv []string
|
||||
title := ""
|
||||
if kind == "console" {
|
||||
argv = s.virt.Argv(r.Context(), "console", name)
|
||||
title = "console " + name
|
||||
} else {
|
||||
argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi")
|
||||
title = "sh " + name
|
||||
}
|
||||
sess, err := s.mgr.CreateCmd("", title, argv)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title})
|
||||
}
|
||||
|
||||
func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
if !incus.ValidName(name) || !incus.ValidName(body.Name) {
|
||||
jsonErr(w, 400, "bad name")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||
defer cancel()
|
||||
if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// virtConfig sets limits, the snapshot schedule, or a description — the config keys the page
|
||||
// edits, each validated for its shape. An empty value unsets the key.
|
||||
func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body map[string]string
|
||||
if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
var set, unset []string
|
||||
for k, v := range body {
|
||||
v = strings.TrimSpace(v)
|
||||
ok := false
|
||||
switch k {
|
||||
case "limits.cpu":
|
||||
ok = incus.ValidCPU(v)
|
||||
case "limits.memory":
|
||||
ok = incus.ValidSize(v)
|
||||
case "snapshots.schedule":
|
||||
ok = incus.ValidCron(v)
|
||||
case "snapshots.expiry":
|
||||
ok = incus.ValidExpiry(v)
|
||||
case "description":
|
||||
ok = len(v) <= 200 && !strings.ContainsAny(v, "\n\r")
|
||||
case "boot.autostart":
|
||||
ok = v == "" || v == "true" || v == "false"
|
||||
}
|
||||
if !ok {
|
||||
jsonErr(w, 400, "bad value for "+k)
|
||||
return
|
||||
}
|
||||
if v == "" {
|
||||
unset = append(unset, k)
|
||||
} else {
|
||||
set = append(set, k+"="+v)
|
||||
}
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||
defer cancel()
|
||||
if len(set) > 0 {
|
||||
if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, k := range unset {
|
||||
_, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine
|
||||
}
|
||||
log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = readBody(r, &body, 4096)
|
||||
snap := strings.TrimSpace(body.Name)
|
||||
if snap == "" {
|
||||
snap = time.Now().Format("2006-01-02_15.04.05")
|
||||
}
|
||||
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
|
||||
jsonErr(w, 400, "bad name")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"name": snap})
|
||||
}
|
||||
|
||||
func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) {
|
||||
name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action")
|
||||
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
|
||||
jsonErr(w, 400, "bad name")
|
||||
return
|
||||
}
|
||||
var args []string
|
||||
switch action {
|
||||
case "restore":
|
||||
args = []string{"snapshot", "restore", name, snap}
|
||||
case "delete":
|
||||
args = []string{"snapshot", "delete", name, snap}
|
||||
default:
|
||||
jsonErr(w, 400, "unknown action")
|
||||
return
|
||||
}
|
||||
if !s.virtReady(w, r) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
|
||||
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||
return
|
||||
}
|
||||
log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
Reference in New Issue
Block a user