The `image build` flow spun up a transient Firecracker VM, SSHed in, and ran a large bash provisioning script to derive a new managed image from an existing one. It overlapped heavily with the golden- image Dockerfile flow (same mise/docker/tmux/opencode install logic duplicated in Go as `imagemgr.BuildProvisionScript`) and had far more machinery: async op state, RPC begin/status/cancel, webui form + operation page, preflight checks, API types, tests. For custom images, writing a Dockerfile is simpler and more reproducible. Removed end-to-end: - CLI `image build` subcommand + `absolutizeImageBuildPaths`. - Daemon: BuildImage method, imagebuild.go (transient-VM orchestration), image_build_ops.go (async begin/status/cancel), imagemgr/build.go (the 247-line provisioning script generator and all its append* helpers), validateImageBuildPrereqs + addImageBuildPrereqs. - RPC dispatches for image.build / .begin / .status / .cancel. - opstate registry `imageBuildOps`, daemon seam `imageBuild`, background pruner call. - API types: ImageBuildParams, ImageBuildOperation, ImageBuildBeginResult, ImageBuildStatusParams, ImageBuildStatusResult; model type ImageBuildRequest. - Web UI: Backend interface methods, handlers, form, routes, template branches (images.html build form, operation.html build branch, dashboard.html Build button). - Tests that directly exercised BuildImage. Doctor polish (task C): - Drop the "image build" preflight section entirely (its raison d'être is gone). - Default-image check now accepts "not local but in imagecat" as OK: vm create auto-pulls on first use. Only flag when the image is neither locally registered nor in the catalog. Net: 24 files touched, 1,373 lines deleted, 25 added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1124 lines
31 KiB
Go
1124 lines
31 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
)
|
|
|
|
type Backend interface {
|
|
Config() model.DaemonConfig
|
|
Layout() paths.Layout
|
|
DashboardSummary(context.Context) (api.DashboardSummary, error)
|
|
ListVMs(context.Context) ([]model.VMRecord, error)
|
|
FindVM(context.Context, string) (model.VMRecord, error)
|
|
GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error)
|
|
BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error)
|
|
VMCreateStatus(context.Context, string) (api.VMCreateOperation, error)
|
|
StartVM(context.Context, string) (model.VMRecord, error)
|
|
StopVM(context.Context, string) (model.VMRecord, error)
|
|
RestartVM(context.Context, string) (model.VMRecord, error)
|
|
DeleteVM(context.Context, string) (model.VMRecord, error)
|
|
SetVM(context.Context, api.VMSetParams) (model.VMRecord, error)
|
|
PortsVM(context.Context, string) (api.VMPortsResult, error)
|
|
ListImages(context.Context) ([]model.Image, error)
|
|
FindImage(context.Context, string) (model.Image, error)
|
|
RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error)
|
|
PromoteImage(context.Context, string) (model.Image, error)
|
|
DeleteImage(context.Context, string) (model.Image, error)
|
|
}
|
|
|
|
type Server struct {
|
|
backend Backend
|
|
templates *template.Template
|
|
pickerFS fs.FS
|
|
}
|
|
|
|
type pickerRoot struct {
|
|
Label string
|
|
Path string
|
|
}
|
|
|
|
type flashMessage struct {
|
|
Kind string
|
|
Message string
|
|
}
|
|
|
|
type vmCreateForm struct {
|
|
Name string
|
|
ImageName string
|
|
VCPU string
|
|
Memory string
|
|
SystemOverlaySize string
|
|
WorkDiskSize string
|
|
NATEnabled bool
|
|
NoStart bool
|
|
}
|
|
|
|
type vmSetForm struct {
|
|
VCPU string
|
|
Memory string
|
|
WorkDiskSize string
|
|
NATEnabled bool
|
|
}
|
|
|
|
type imageRegisterForm struct {
|
|
Name string
|
|
RootfsPath string
|
|
WorkSeedPath string
|
|
KernelPath string
|
|
InitrdPath string
|
|
ModulesDir string
|
|
Docker bool
|
|
}
|
|
|
|
type pageData struct {
|
|
Title string
|
|
BodyTemplate string
|
|
BodyHTML template.HTML
|
|
Section string
|
|
Summary api.DashboardSummary
|
|
Flash *flashMessage
|
|
CSRFToken string
|
|
PickerRoots []pickerRoot
|
|
MutationAllowed bool
|
|
ErrorMessage string
|
|
VMs []model.VMRecord
|
|
VM model.VMRecord
|
|
VMImage model.Image
|
|
VMStats model.VMStats
|
|
VMPorts api.VMPortsResult
|
|
VMPortsError string
|
|
VMCreateForm vmCreateForm
|
|
VMSetForm vmSetForm
|
|
Images []model.Image
|
|
Image model.Image
|
|
ImageUsers int
|
|
ImageRegisterForm imageRegisterForm
|
|
LogText string
|
|
VMCreateOperation *api.VMCreateOperation
|
|
OperationStatusURL string
|
|
OperationSuccessURL string
|
|
OperationLogPath string
|
|
OperationKind string
|
|
}
|
|
|
|
type fsEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
type fsListingResponse struct {
|
|
Path string `json:"path"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Kind string `json:"kind"`
|
|
Entries []fsEntry `json:"entries"`
|
|
Roots []pickerRoot `json:"roots"`
|
|
}
|
|
|
|
//go:embed templates/*.html assets/*
|
|
var embeddedAssets embed.FS
|
|
|
|
func NewHandler(backend Backend) http.Handler {
|
|
tmpl := template.Must(template.New("page").Funcs(template.FuncMap{
|
|
"shortID": shortID,
|
|
"formatBytes": formatBytes,
|
|
"formatBytesCompact": formatBytesCompact,
|
|
"formatPercent": formatPercent,
|
|
"percentOf": percentOf,
|
|
"relativeTime": relativeTime,
|
|
"formatBool": formatBool,
|
|
"stateClass": stateClass,
|
|
"findImage": findImage,
|
|
"endpointHref": endpointHref,
|
|
"sumInt64": sumInt64,
|
|
"eq": func(a, b any) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
|
|
}).ParseFS(embeddedAssets, "templates/*.html"))
|
|
staticFS, err := fs.Sub(embeddedAssets, "assets")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
server := &Server{
|
|
backend: backend,
|
|
templates: tmpl,
|
|
pickerFS: staticFS,
|
|
}
|
|
mux := http.NewServeMux()
|
|
server.registerRoutes(mux)
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) registerRoutes(mux *http.ServeMux) {
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(s.pickerFS)))
|
|
mux.HandleFunc("GET /", s.wrap(s.handleDashboard))
|
|
mux.HandleFunc("GET /vms", s.wrap(s.handleVMList))
|
|
mux.HandleFunc("GET /vms/new", s.wrap(s.handleVMNew))
|
|
mux.HandleFunc("POST /vms", s.wrap(s.handleVMCreate))
|
|
mux.HandleFunc("GET /vms/{id}", s.wrap(s.handleVMShow))
|
|
mux.HandleFunc("GET /vms/{id}/logs", s.wrap(s.handleVMLogs))
|
|
mux.HandleFunc("POST /vms/{id}/start", s.wrap(s.handleVMStart))
|
|
mux.HandleFunc("POST /vms/{id}/stop", s.wrap(s.handleVMStop))
|
|
mux.HandleFunc("POST /vms/{id}/restart", s.wrap(s.handleVMRestart))
|
|
mux.HandleFunc("POST /vms/{id}/delete", s.wrap(s.handleVMDelete))
|
|
mux.HandleFunc("POST /vms/{id}/set", s.wrap(s.handleVMSet))
|
|
mux.HandleFunc("GET /images", s.wrap(s.handleImageList))
|
|
mux.HandleFunc("GET /images/register", s.wrap(s.handleImageRegisterForm))
|
|
mux.HandleFunc("POST /images/register", s.wrap(s.handleImageRegister))
|
|
mux.HandleFunc("GET /images/{id}", s.wrap(s.handleImageShow))
|
|
mux.HandleFunc("POST /images/{id}/promote", s.wrap(s.handleImagePromote))
|
|
mux.HandleFunc("POST /images/{id}/delete", s.wrap(s.handleImageDelete))
|
|
mux.HandleFunc("GET /operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationPage))
|
|
mux.HandleFunc("GET /api/operations/vm-create/{id}", s.wrap(s.handleVMCreateOperationAPI))
|
|
mux.HandleFunc("GET /api/fs", s.wrap(s.handleFSAPI))
|
|
}
|
|
|
|
func (s *Server) wrap(fn func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := fn(w, r); err != nil {
|
|
s.writeError(w, r, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
|
status := http.StatusInternalServerError
|
|
lower := strings.ToLower(err.Error())
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist), strings.Contains(lower, "not found"):
|
|
status = http.StatusNotFound
|
|
case strings.Contains(lower, "csrf"), strings.Contains(lower, "cross-origin"):
|
|
status = http.StatusForbidden
|
|
case strings.Contains(lower, "path must"), strings.Contains(lower, "not a directory"):
|
|
status = http.StatusBadRequest
|
|
}
|
|
if status == http.StatusInternalServerError {
|
|
http.Error(w, err.Error(), status)
|
|
return
|
|
}
|
|
if renderErr := s.renderPage(w, r, status, "Not Found", "error_content", func(data *pageData) error {
|
|
data.Section = "none"
|
|
data.ErrorMessage = err.Error()
|
|
return nil
|
|
}); renderErr != nil {
|
|
http.Error(w, err.Error(), status)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int, title, body string, fill func(*pageData) error) error {
|
|
summary, err := s.backend.DashboardSummary(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flash := s.popFlash(w, r)
|
|
data := &pageData{
|
|
Title: title,
|
|
BodyTemplate: body,
|
|
Summary: summary,
|
|
Flash: flash,
|
|
CSRFToken: s.ensureCSRFToken(w, r),
|
|
PickerRoots: s.pickerRoots(),
|
|
MutationAllowed: summary.Sudo.Available,
|
|
}
|
|
if fill != nil {
|
|
if err := fill(data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var bodyHTML strings.Builder
|
|
if err := s.templates.ExecuteTemplate(&bodyHTML, body, data); err != nil {
|
|
return err
|
|
}
|
|
data.BodyHTML = template.HTML(bodyHTML.String())
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
return s.templates.ExecuteTemplate(w, "page", data)
|
|
}
|
|
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Dashboard", "dashboard_content", func(data *pageData) error {
|
|
data.Section = "dashboard"
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.VMs = vms
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMList(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "VMs", "vm_list_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.VMs = vms
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMNew(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderVMNewPage(w, r, vmCreateForm{
|
|
VCPU: strconv.Itoa(model.DefaultVCPUCount),
|
|
Memory: strconv.Itoa(model.DefaultMemoryMiB),
|
|
SystemOverlaySize: model.FormatSizeBytes(model.DefaultSystemOverlaySize),
|
|
WorkDiskSize: model.FormatSizeBytes(model.DefaultWorkDiskSize),
|
|
}, "")
|
|
}
|
|
|
|
func (s *Server) renderVMNewPage(w http.ResponseWriter, r *http.Request, form vmCreateForm, formErr string) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Create VM", "vm_new_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.Images = images
|
|
data.VMCreateForm = form
|
|
data.ErrorMessage = formErr
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMCreate(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, params, err := s.parseVMCreateForm(r)
|
|
if err != nil {
|
|
return s.renderVMNewPage(w, r, form, err.Error())
|
|
}
|
|
if !allowed {
|
|
return s.renderVMNewPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
|
}
|
|
op, err := s.backend.BeginVMCreate(r.Context(), params)
|
|
if err != nil {
|
|
return s.renderVMNewPage(w, r, form, err.Error())
|
|
}
|
|
http.Redirect(w, r, "/operations/vm-create/"+url.PathEscape(op.ID), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMShow(w http.ResponseWriter, r *http.Request) error {
|
|
_, vmStats, err := s.backend.GetVMStats(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
image, _ := s.backend.FindImage(r.Context(), vm.ImageID)
|
|
return s.renderPage(w, r, http.StatusOK, vm.Name, "vm_show_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.VM = vm
|
|
data.VMImage = image
|
|
data.VMStats = vmStats
|
|
data.VMSetForm = vmSetForm{
|
|
VCPU: strconv.Itoa(vm.Spec.VCPUCount),
|
|
Memory: strconv.Itoa(vm.Spec.MemoryMiB),
|
|
WorkDiskSize: model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
|
NATEnabled: vm.Spec.NATEnabled,
|
|
}
|
|
if vm.State == model.VMStateRunning {
|
|
ports, err := s.backend.PortsVM(r.Context(), vm.ID)
|
|
if err != nil {
|
|
data.VMPortsError = err.Error()
|
|
} else {
|
|
data.VMPorts = ports
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMLogs(w http.ResponseWriter, r *http.Request) error {
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logText, err := tailFile(vm.Runtime.LogPath, 200)
|
|
if err != nil {
|
|
logText = err.Error()
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, vm.Name+" Logs", "vm_logs_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.VM = vm
|
|
data.LogText = logText
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.StartVM(ctx, id)
|
|
return err
|
|
}, "VM started")
|
|
}
|
|
|
|
func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.StopVM(ctx, id)
|
|
return err
|
|
}, "VM stopped")
|
|
}
|
|
|
|
func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) error {
|
|
return s.runVMAction(w, r, func(ctx context.Context, id string) error {
|
|
_, err := s.backend.RestartVM(ctx, id)
|
|
return err
|
|
}, "VM restarted")
|
|
}
|
|
|
|
func (s *Server) handleVMDelete(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.DeleteVM(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, "/vms/"+url.PathEscape(r.PathValue("id")), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "VM deleted")
|
|
http.Redirect(w, r, "/vms", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMSet(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/vms/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
vm, err := s.backend.FindVM(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
params, err := s.parseVMSetForm(r, vm)
|
|
if err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
|
|
s.setFlash(w, "info", "No VM settings changed")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.SetVM(r.Context(), params); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "VM settings updated")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) runVMAction(w http.ResponseWriter, r *http.Request, action func(context.Context, string) error, successMessage string) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/vms/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if err := action(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", successMessage)
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageList(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Images", "image_list_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
images, err := s.backend.ListImages(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data.Images = images
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageRegisterForm(w http.ResponseWriter, r *http.Request) error {
|
|
return s.renderImageRegisterPage(w, r, imageRegisterForm{}, "")
|
|
}
|
|
|
|
func (s *Server) renderImageRegisterPage(w http.ResponseWriter, r *http.Request, form imageRegisterForm, formErr string) error {
|
|
return s.renderPage(w, r, http.StatusOK, "Register Image", "image_register_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.ImageRegisterForm = form
|
|
data.ErrorMessage = formErr
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImageRegister(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, params, err := s.parseImageRegisterForm(r)
|
|
if err != nil {
|
|
return s.renderImageRegisterPage(w, r, form, err.Error())
|
|
}
|
|
if !allowed {
|
|
return s.renderImageRegisterPage(w, r, form, "mutating actions are unavailable until `sudo -v` succeeds")
|
|
}
|
|
image, err := s.backend.RegisterImage(r.Context(), params)
|
|
if err != nil {
|
|
return s.renderImageRegisterPage(w, r, form, err.Error())
|
|
}
|
|
s.setFlash(w, "success", "Image registered")
|
|
http.Redirect(w, r, "/images/"+url.PathEscape(image.ID), http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageShow(w http.ResponseWriter, r *http.Request) error {
|
|
image, err := s.backend.FindImage(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vms, err := s.backend.ListVMs(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userCount := 0
|
|
for _, vm := range vms {
|
|
if vm.ImageID == image.ID {
|
|
userCount++
|
|
}
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, image.Name, "image_show_content", func(data *pageData) error {
|
|
data.Section = "images"
|
|
data.Image = image
|
|
data.ImageUsers = userCount
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleImagePromote(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/images/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.PromoteImage(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "Image promoted")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.verifyPOST(w, r); err != nil {
|
|
return err
|
|
}
|
|
allowed, err := s.requireMutationAllowed(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := "/images/" + url.PathEscape(r.PathValue("id"))
|
|
if !allowed {
|
|
s.setFlash(w, "error", "mutating actions are unavailable until `sudo -v` succeeds")
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
if _, err := s.backend.DeleteImage(r.Context(), r.PathValue("id")); err != nil {
|
|
s.setFlash(w, "error", err.Error())
|
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
s.setFlash(w, "success", "Image deleted")
|
|
http.Redirect(w, r, "/images", http.StatusSeeOther)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleVMCreateOperationPage(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.renderPage(w, r, http.StatusOK, "Creating VM", "operation_content", func(data *pageData) error {
|
|
data.Section = "vms"
|
|
data.OperationKind = "vm"
|
|
data.VMCreateOperation = &op
|
|
data.OperationStatusURL = "/api/operations/vm-create/" + url.PathEscape(op.ID)
|
|
if op.VMID != "" {
|
|
data.OperationSuccessURL = "/vms/" + url.PathEscape(op.VMID)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleVMCreateOperationAPI(w http.ResponseWriter, r *http.Request) error {
|
|
op, err := s.backend.VMCreateStatus(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, api.VMCreateStatusResult{Operation: op})
|
|
}
|
|
|
|
func (s *Server) handleFSAPI(w http.ResponseWriter, r *http.Request) error {
|
|
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
|
if path == "" {
|
|
path = s.pickerRoots()[0].Path
|
|
}
|
|
path = filepath.Clean(path)
|
|
if !filepath.IsAbs(path) {
|
|
return fmt.Errorf("path must be absolute")
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", path)
|
|
}
|
|
kind := r.URL.Query().Get("kind")
|
|
if kind != "dir" {
|
|
kind = "file"
|
|
}
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := fsListingResponse{
|
|
Path: path,
|
|
Kind: kind,
|
|
Entries: make([]fsEntry, 0, len(entries)+1),
|
|
Roots: s.pickerRoots(),
|
|
}
|
|
parent := filepath.Dir(path)
|
|
if parent != path {
|
|
result.Parent = parent
|
|
result.Entries = append(result.Entries, fsEntry{Name: "..", Path: parent, Kind: "up"})
|
|
}
|
|
for _, entry := range entries {
|
|
entryKind := "file"
|
|
if entry.IsDir() {
|
|
entryKind = "dir"
|
|
}
|
|
result.Entries = append(result.Entries, fsEntry{
|
|
Name: entry.Name(),
|
|
Path: filepath.Join(path, entry.Name()),
|
|
Kind: entryKind,
|
|
})
|
|
}
|
|
sort.Slice(result.Entries, func(i, j int) bool {
|
|
left, right := result.Entries[i], result.Entries[j]
|
|
leftRank := kindRank(left.Kind)
|
|
rightRank := kindRank(right.Kind)
|
|
if leftRank != rightRank {
|
|
return leftRank < rightRank
|
|
}
|
|
return strings.ToLower(left.Name) < strings.ToLower(right.Name)
|
|
})
|
|
return writeJSON(w, result)
|
|
}
|
|
|
|
func kindRank(kind string) int {
|
|
switch kind {
|
|
case "up":
|
|
return 0
|
|
case "dir":
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func (s *Server) pickerRoots() []pickerRoot {
|
|
seen := map[string]struct{}{}
|
|
roots := []pickerRoot{{Label: "Filesystem", Path: "/"}}
|
|
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
|
roots = append(roots, pickerRoot{Label: "Home", Path: home})
|
|
}
|
|
layout := s.backend.Layout()
|
|
if layout.StateDir != "" {
|
|
roots = append(roots, pickerRoot{Label: "State", Path: layout.StateDir})
|
|
}
|
|
result := make([]pickerRoot, 0, len(roots))
|
|
for _, root := range roots {
|
|
root.Path = filepath.Clean(root.Path)
|
|
if _, ok := seen[root.Path]; ok {
|
|
continue
|
|
}
|
|
seen[root.Path] = struct{}{}
|
|
result = append(result, root)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) verifyPOST(w http.ResponseWriter, r *http.Request) error {
|
|
if r.Method != http.MethodPost {
|
|
return nil
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
if err := verifySameOrigin(r); err != nil {
|
|
return err
|
|
}
|
|
tokenCookie, err := r.Cookie("banger_csrf")
|
|
if err != nil {
|
|
return errors.New("missing csrf cookie")
|
|
}
|
|
if tokenCookie.Value == "" || r.FormValue("csrf_token") != tokenCookie.Value {
|
|
return errors.New("csrf token mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifySameOrigin(r *http.Request) error {
|
|
for _, raw := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
|
|
if strings.TrimSpace(raw) == "" {
|
|
continue
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid origin: %w", err)
|
|
}
|
|
if parsed.Host != r.Host {
|
|
return errors.New("cross-origin POST rejected")
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
|
|
if cookie, err := r.Cookie("banger_csrf"); err == nil && strings.TrimSpace(cookie.Value) != "" {
|
|
return cookie.Value
|
|
}
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(err)
|
|
}
|
|
token := hex.EncodeToString(buf)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_csrf",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
return token
|
|
}
|
|
|
|
func (s *Server) setFlash(w http.ResponseWriter, kind, message string) {
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte(kind + "\n" + message))
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_flash",
|
|
Value: payload,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) popFlash(w http.ResponseWriter, r *http.Request) *flashMessage {
|
|
cookie, err := r.Cookie("banger_flash")
|
|
if err != nil || cookie.Value == "" {
|
|
return nil
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "banger_flash",
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
data, err := base64.RawURLEncoding.DecodeString(cookie.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
parts := strings.SplitN(string(data), "\n", 2)
|
|
if len(parts) != 2 {
|
|
return nil
|
|
}
|
|
return &flashMessage{Kind: parts[0], Message: parts[1]}
|
|
}
|
|
|
|
func (s *Server) requireMutationAllowed(ctx context.Context) (bool, error) {
|
|
summary, err := s.backend.DashboardSummary(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return summary.Sudo.Available, nil
|
|
}
|
|
|
|
func (s *Server) parseVMCreateForm(r *http.Request) (vmCreateForm, api.VMCreateParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return vmCreateForm{}, api.VMCreateParams{}, err
|
|
}
|
|
form := vmCreateForm{
|
|
Name: strings.TrimSpace(r.FormValue("name")),
|
|
ImageName: strings.TrimSpace(r.FormValue("image_name")),
|
|
VCPU: strings.TrimSpace(r.FormValue("vcpu")),
|
|
Memory: strings.TrimSpace(r.FormValue("memory")),
|
|
SystemOverlaySize: strings.TrimSpace(r.FormValue("system_overlay_size")),
|
|
WorkDiskSize: strings.TrimSpace(r.FormValue("work_disk_size")),
|
|
NATEnabled: r.FormValue("nat_enabled") == "on",
|
|
NoStart: r.FormValue("no_start") == "on",
|
|
}
|
|
vcpu, err := strconv.Atoi(form.VCPU)
|
|
if err != nil {
|
|
return form, api.VMCreateParams{}, errors.New("vcpu must be an integer")
|
|
}
|
|
memory, err := strconv.Atoi(form.Memory)
|
|
if err != nil {
|
|
return form, api.VMCreateParams{}, errors.New("memory must be an integer")
|
|
}
|
|
params := api.VMCreateParams{
|
|
Name: form.Name,
|
|
ImageName: form.ImageName,
|
|
VCPUCount: &vcpu,
|
|
MemoryMiB: &memory,
|
|
SystemOverlaySize: form.SystemOverlaySize,
|
|
WorkDiskSize: form.WorkDiskSize,
|
|
NATEnabled: form.NATEnabled,
|
|
NoStart: form.NoStart,
|
|
}
|
|
return form, params, nil
|
|
}
|
|
|
|
func (s *Server) parseVMSetForm(r *http.Request, vm model.VMRecord) (api.VMSetParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return api.VMSetParams{}, err
|
|
}
|
|
params := api.VMSetParams{IDOrName: vm.ID}
|
|
if raw := strings.TrimSpace(r.FormValue("vcpu")); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return api.VMSetParams{}, errors.New("vcpu must be an integer")
|
|
}
|
|
if value != vm.Spec.VCPUCount {
|
|
params.VCPUCount = &value
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("memory")); raw != "" {
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return api.VMSetParams{}, errors.New("memory must be an integer")
|
|
}
|
|
if value != vm.Spec.MemoryMiB {
|
|
params.MemoryMiB = &value
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("work_disk_size")); raw != "" && raw != model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes) {
|
|
params.WorkDiskSize = raw
|
|
}
|
|
if raw := strings.TrimSpace(r.FormValue("nat_enabled")); raw != "" {
|
|
value := raw == "true"
|
|
if value != vm.Spec.NATEnabled {
|
|
params.NATEnabled = &value
|
|
}
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func (s *Server) parseImageRegisterForm(r *http.Request) (imageRegisterForm, api.ImageRegisterParams, error) {
|
|
if err := s.verifyPOST(nilResponseWriter{}, r); err != nil {
|
|
return imageRegisterForm{}, api.ImageRegisterParams{}, err
|
|
}
|
|
form := imageRegisterForm{
|
|
Name: strings.TrimSpace(r.FormValue("name")),
|
|
RootfsPath: strings.TrimSpace(r.FormValue("rootfs_path")),
|
|
WorkSeedPath: strings.TrimSpace(r.FormValue("work_seed_path")),
|
|
KernelPath: strings.TrimSpace(r.FormValue("kernel_path")),
|
|
InitrdPath: strings.TrimSpace(r.FormValue("initrd_path")),
|
|
ModulesDir: strings.TrimSpace(r.FormValue("modules_dir")),
|
|
Docker: r.FormValue("docker") == "on",
|
|
}
|
|
params := api.ImageRegisterParams{
|
|
Name: form.Name,
|
|
RootfsPath: form.RootfsPath,
|
|
WorkSeedPath: form.WorkSeedPath,
|
|
KernelPath: form.KernelPath,
|
|
InitrdPath: form.InitrdPath,
|
|
ModulesDir: form.ModulesDir,
|
|
Docker: form.Docker,
|
|
}
|
|
return form, params, nil
|
|
}
|
|
|
|
type nilResponseWriter struct{}
|
|
|
|
func (nilResponseWriter) Header() http.Header { return http.Header{} }
|
|
func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil }
|
|
func (nilResponseWriter) WriteHeader(statusCode int) {}
|
|
|
|
func writeJSON(w http.ResponseWriter, value any) error {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
return json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func tailFile(path string, maxLines int) (string, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "", errors.New("log path is unavailable")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
|
if maxLines > 0 && len(lines) > maxLines {
|
|
lines = lines[len(lines)-maxLines:]
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
func findImage(images []model.Image, id string) model.Image {
|
|
for _, image := range images {
|
|
if image.ID == id {
|
|
return image
|
|
}
|
|
}
|
|
return model.Image{}
|
|
}
|
|
|
|
func endpointHref(endpoint string) string {
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
|
return endpoint
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func shortID(id string) string {
|
|
if len(id) <= 12 {
|
|
return id
|
|
}
|
|
return id[:12]
|
|
}
|
|
|
|
func sumInt64(values ...int64) int64 {
|
|
var total int64
|
|
for _, value := range values {
|
|
total += value
|
|
}
|
|
return total
|
|
}
|
|
|
|
func formatBytes(bytes int64) string {
|
|
const (
|
|
ki = 1024
|
|
mi = ki * 1024
|
|
gi = mi * 1024
|
|
ti = gi * 1024
|
|
)
|
|
switch {
|
|
case bytes >= ti:
|
|
return fmt.Sprintf("%.1f TiB", float64(bytes)/float64(ti))
|
|
case bytes >= gi:
|
|
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gi))
|
|
case bytes >= mi:
|
|
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mi))
|
|
case bytes >= ki:
|
|
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(ki))
|
|
default:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
}
|
|
|
|
func formatBytesCompact(bytes int64) string {
|
|
const (
|
|
ki = 1024
|
|
mi = ki * 1024
|
|
gi = mi * 1024
|
|
ti = gi * 1024
|
|
)
|
|
type unit struct {
|
|
size int64
|
|
suffix string
|
|
}
|
|
units := []unit{
|
|
{size: ti, suffix: "T"},
|
|
{size: gi, suffix: "G"},
|
|
{size: mi, suffix: "M"},
|
|
{size: ki, suffix: "K"},
|
|
}
|
|
abs := bytes
|
|
if abs < 0 {
|
|
abs = -abs
|
|
}
|
|
for _, candidate := range units {
|
|
if abs >= candidate.size {
|
|
value := float64(bytes) / float64(candidate.size)
|
|
if math.Abs(value-math.Round(value)) < 0.05 {
|
|
return fmt.Sprintf("%.0f%s", math.Round(value), candidate.suffix)
|
|
}
|
|
return fmt.Sprintf("%.1f%s", value, candidate.suffix)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
|
|
func percentOf(used, total any) int {
|
|
totalValue := numericValue(total)
|
|
if totalValue <= 0 {
|
|
return 0
|
|
}
|
|
usedValue := numericValue(used)
|
|
percent := int(math.Round((usedValue / totalValue) * 100))
|
|
switch {
|
|
case percent < 0:
|
|
return 0
|
|
case percent > 100:
|
|
return 100
|
|
default:
|
|
return percent
|
|
}
|
|
}
|
|
|
|
func numericValue(value any) float64 {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return float64(typed)
|
|
case int8:
|
|
return float64(typed)
|
|
case int16:
|
|
return float64(typed)
|
|
case int32:
|
|
return float64(typed)
|
|
case int64:
|
|
return float64(typed)
|
|
case uint:
|
|
return float64(typed)
|
|
case uint8:
|
|
return float64(typed)
|
|
case uint16:
|
|
return float64(typed)
|
|
case uint32:
|
|
return float64(typed)
|
|
case uint64:
|
|
return float64(typed)
|
|
case float32:
|
|
return float64(typed)
|
|
case float64:
|
|
return typed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func formatPercent(value float64) string {
|
|
return fmt.Sprintf("%.1f%%", value)
|
|
}
|
|
|
|
func relativeTime(ts time.Time) string {
|
|
if ts.IsZero() {
|
|
return "-"
|
|
}
|
|
delta := time.Since(ts)
|
|
switch {
|
|
case delta < time.Minute:
|
|
return "just now"
|
|
case delta < time.Hour:
|
|
return fmt.Sprintf("%d minutes ago", int(delta.Minutes()))
|
|
case delta < 24*time.Hour:
|
|
return fmt.Sprintf("%d hours ago", int(delta.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%d days ago", int(delta.Hours()/24))
|
|
}
|
|
}
|
|
|
|
func formatBool(value bool) string {
|
|
if value {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|
|
|
|
func stateClass(state model.VMState) string {
|
|
switch state {
|
|
case model.VMStateRunning:
|
|
return "running"
|
|
case model.VMStateStopped:
|
|
return "stopped"
|
|
case model.VMStateError:
|
|
return "error"
|
|
default:
|
|
return "created"
|
|
}
|
|
}
|