Files
1Panel/agent/utils/vm/helper/provider.go
ssongliu c06c4e338b feat(vm): enable community virtual machines and migrate xpack data (#14279)
* feat(vm): enable community virtual machines and migrate xpack data

* fix(vm): stop ISO download polling on failure or cancellation

* fix(vm): respect deleteFile when deleting failed ISO imports
2026-11-02 18:22:58 +08:00

550 lines
18 KiB
Go

package helper
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/vm/protocol"
"github.com/1Panel-dev/1Panel/agent/utils/vm/types"
)
const (
defaultTimeout = 2 * time.Minute
longRunningTimeout = 30 * time.Minute
slowActionThreshold = 5 * time.Second
)
type Provider struct {
HelperPath string
URI string
Timeout time.Duration
mu sync.Mutex
versionOnce sync.Once
versionGate chan struct{}
checked bool
}
func NewProvider() (*Provider, error) {
return &Provider{URI: protocol.DefaultURI, Timeout: defaultTimeout}, nil
}
func (p *Provider) CheckEnvironment(ctx context.Context) ([]dto.VMCheckItem, error) {
var items []protocol.VMCheckItem
if err := p.call(ctx, protocol.ActionCheck, nil, &items); err != nil {
return nil, err
}
out := make([]dto.VMCheckItem, 0, len(items))
for _, item := range items {
out = append(out, dto.VMCheckItem{
Name: item.Name,
Passed: item.Passed,
Message: item.Message,
IsEnable: item.IsEnable,
IsExist: item.IsExist,
IsActive: item.IsActive,
})
}
return out, nil
}
func (p *Provider) HealthCheck(ctx context.Context, name string) (dto.VMHealthCheckResult, error) {
var result protocol.VMHealthCheckResult
if err := p.callWithTimeout(
ctx,
protocol.ActionHealthCheck,
protocol.VMHealthCheckRequest{Name: name},
&result,
longRunningTimeout,
); err != nil {
return dto.VMHealthCheckResult{}, err
}
out := dto.VMHealthCheckResult{
Pass: result.Pass,
Warn: result.Warn,
Fail: result.Fail,
Items: make([]dto.VMHealthCheckItem, 0, len(result.Items)),
}
for _, item := range result.Items {
out.Items = append(out.Items, dto.VMHealthCheckItem{
Level: item.Level, Check: item.Check, Reason: item.Reason, Params: item.Params,
Detail: item.Detail, FixCommand: item.FixCommand,
})
}
return out, nil
}
func (p *Provider) HelperStatus() bool {
if _, err := p.resolveHelperPath(); err != nil {
return false
}
return true
}
func (p *Provider) List(ctx context.Context) ([]dto.VMInfo, error) {
var items []protocol.VMInfo
if err := p.call(ctx, protocol.ActionList, nil, &items); err != nil {
return nil, err
}
out := make([]dto.VMInfo, 0, len(items))
for _, item := range items {
out = append(out, toDTO(item))
}
return out, nil
}
func (p *Provider) ListResources(ctx context.Context, resourceType string) ([]dto.VMResourceInfo, error) {
var items []protocol.VMResourceInfo
if err := p.call(ctx, protocol.ActionResources, protocol.VMResourceSearch{Type: resourceType}, &items); err != nil {
return nil, err
}
out := make([]dto.VMResourceInfo, 0, len(items))
for _, item := range items {
out = append(out, dto.VMResourceInfo{
Type: item.Type, Name: item.Name, SubType: item.SubType, BridgeName: item.BridgeName, SourceInterface: item.SourceInterface,
CIDR: item.CIDR, Gateway: item.Gateway, DHCPStart: item.DHCPStart, DHCPEnd: item.DHCPEnd,
Path: item.Path, Status: item.Status, AutoStart: item.AutoStart, Capacity: item.Capacity, Available: item.Available,
})
}
return out, nil
}
func (p *Provider) CreateResource(ctx context.Context, req types.VMResourceCreate) error {
return p.call(ctx, protocol.ActionCreateResource, protocol.VMResourceCreate{
Type: req.Type, Name: req.Name, SubType: req.SubType, BridgeName: req.BridgeName, SourceInterface: req.SourceInterface,
CIDR: req.CIDR, Gateway: req.Gateway, DHCPStart: req.DHCPStart, DHCPEnd: req.DHCPEnd,
Path: req.Path, AutoStart: req.AutoStart,
}, nil)
}
func (p *Provider) UpdateResource(ctx context.Context, req types.VMResourceUpdate) error {
return p.call(ctx, protocol.ActionUpdateResource, protocol.VMResourceUpdate{
Type: req.Type, Name: req.Name, AutoStart: req.AutoStart, Status: req.Status,
}, nil)
}
func (p *Provider) DeleteResource(ctx context.Context, resourceType string, name string) error {
return p.call(ctx, protocol.ActionDeleteResource, protocol.VMResourceDelete{Type: resourceType, Name: name}, nil)
}
func (p *Provider) ScanOrphans(ctx context.Context, protectedPaths []string) ([]dto.VMOrphanItem, error) {
var items []protocol.VMOrphanItem
if err := p.callWithTimeout(ctx, protocol.ActionScanOrphans, protocol.VMOrphanScan{ProtectedPaths: protectedPaths}, &items, longRunningTimeout); err != nil {
return nil, err
}
out := make([]dto.VMOrphanItem, 0, len(items))
for _, item := range items {
out = append(out, dto.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
}
return out, nil
}
func (p *Provider) CleanOrphans(ctx context.Context, items []dto.VMOrphanItem, protectedPaths []string) ([]dto.VMOrphanItem, error) {
payload := protocol.VMOrphanClean{ProtectedPaths: protectedPaths, Items: make([]protocol.VMOrphanItem, 0, len(items))}
for _, item := range items {
payload.Items = append(payload.Items, protocol.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
}
var removed []protocol.VMOrphanItem
if err := p.callWithTimeout(ctx, protocol.ActionCleanOrphans, payload, &removed, longRunningTimeout); err != nil {
return nil, err
}
out := make([]dto.VMOrphanItem, 0, len(removed))
for _, item := range removed {
out = append(out, dto.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
}
return out, nil
}
func (p *Provider) Get(ctx context.Context, name string, checkDisks bool) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.call(ctx, protocol.ActionGet, protocol.VMInfoReq{Name: name, CheckDisks: checkDisks}, &item); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) Stats(ctx context.Context, name string) (dto.VMStats, error) {
var item protocol.VMStats
if err := p.call(ctx, protocol.ActionStats, protocol.VMName{Name: name}, &item); err != nil {
return dto.VMStats{}, err
}
return dto.VMStats{
Name: item.Name, Status: item.Status, Timestamp: item.Timestamp, CPUTime: item.CPUTime, VCPU: item.VCPU,
Memory: dto.VMMemoryStats(item.Memory), Disk: dto.VMDiskStats(item.Disk), Network: dto.VMNetworkStats(item.Network),
}, nil
}
func (p *Provider) Create(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.call(ctx, protocol.ActionCreate, toProtocolConfig(cfg), &item); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) CreateFromTemplate(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.callWithTimeout(ctx, protocol.ActionCreateFromTemplate, toProtocolConfig(cfg), &item, longRunningTimeout); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) CreateTemplate(ctx context.Context, req types.VMTemplateCreate) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.callWithTimeout(ctx, protocol.ActionCreateTemplate, protocol.VMTemplateCreate{
Name: req.Name, SourceName: req.SourceName, DiskPath: req.DiskPath, StoragePool: req.StoragePool,
}, &item, longRunningTimeout); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) Update(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.call(ctx, protocol.ActionUpdate, toProtocolConfig(cfg), &item); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) ResizeDisk(ctx context.Context, req dto.VMDiskResize) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.callWithTimeout(ctx, protocol.ActionResizeDisk, protocol.VMDiskResize{Name: req.Name, Target: req.Target, SizeGiB: req.SizeGiB}, &item, longRunningTimeout); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) Rename(ctx context.Context, name string, newName string) (dto.VMInfo, error) {
var item protocol.VMInfo
if err := p.call(ctx, protocol.ActionRename, protocol.VMRename{Name: name, NewName: newName}, &item); err != nil {
return dto.VMInfo{}, err
}
return toDTO(item), nil
}
func (p *Provider) Delete(ctx context.Context, name string, force bool, deleteDisk bool, protectedPaths []string) error {
return p.call(ctx, protocol.ActionDelete, protocol.VMDelete{Name: name, Force: force, DeleteDisk: deleteDisk, ProtectedPaths: protectedPaths}, nil)
}
func (p *Provider) ListSnapshots(ctx context.Context, name string) ([]dto.VMSnapshotInfo, error) {
var items []protocol.VMSnapshotInfo
if err := p.call(ctx, protocol.ActionListSnapshots, protocol.VMName{Name: name}, &items); err != nil {
return nil, err
}
out := make([]dto.VMSnapshotInfo, 0, len(items))
for _, item := range items {
out = append(out, dto.VMSnapshotInfo(item))
}
return out, nil
}
func (p *Provider) CreateSnapshot(ctx context.Context, name string, snapshotName string, description string) error {
return p.call(ctx, protocol.ActionCreateSnapshot, protocol.VMSnapshotReq{
Name: name, SnapshotName: snapshotName, Description: description,
}, nil)
}
func (p *Provider) RecoverSnapshot(ctx context.Context, name string, snapshotName string) error {
return p.call(ctx, protocol.ActionRecoverSnapshot, protocol.VMSnapshotReq{Name: name, SnapshotName: snapshotName}, nil)
}
func (p *Provider) DeleteSnapshot(ctx context.Context, name string, snapshotName string) error {
return p.call(ctx, protocol.ActionDeleteSnapshot, protocol.VMSnapshotReq{Name: name, SnapshotName: snapshotName}, nil)
}
func (p *Provider) Start(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionStart, name)
}
func (p *Provider) Shutdown(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionShutdown, name)
}
func (p *Provider) Reboot(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionReboot, name)
}
func (p *Provider) Destroy(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionDestroy, name)
}
func (p *Provider) Suspend(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionSuspend, name)
}
func (p *Provider) Resume(ctx context.Context, name string) error {
return p.lifecycle(ctx, protocol.ActionResume, name)
}
func (p *Provider) Console(ctx context.Context, name string) (*dto.VMGraphics, error) {
var item protocol.VMGraphics
if err := p.call(ctx, protocol.ActionConsole, protocol.VMName{Name: name}, &item); err != nil {
return nil, err
}
return (*dto.VMGraphics)(&item), nil
}
func (p *Provider) lifecycle(ctx context.Context, action string, name string) error {
return p.call(ctx, action, protocol.VMName{Name: name}, nil)
}
func (p *Provider) checkVersion(ctx context.Context) error {
var info protocol.VersionInfo
if err := p.call(ctx, protocol.ActionVersion, nil, &info); err != nil {
return err
}
if info.API != protocol.MinVersion {
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("unsupported kvm helper api version: %s", info.API))
}
return nil
}
func (p *Provider) call(ctx context.Context, action string, payload interface{}, out interface{}) error {
return p.callWithTimeout(ctx, action, payload, out, p.Timeout)
}
func (p *Provider) callWithTimeout(ctx context.Context, action string, payload interface{}, out interface{}, timeout time.Duration) (retErr error) {
startedAt := time.Now()
defer func() {
logAction(action, time.Since(startedAt), retErr)
}()
if ctx == nil {
ctx = context.Background()
}
if timeout == 0 {
timeout = defaultTimeout
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if action != protocol.ActionVersion {
if err := p.ensureVersion(runCtx); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return buserr.New("ErrCmdTimeout")
}
return err
}
}
req := protocol.Request{Version: protocol.Version, Action: action, URI: p.URI, Payload: payload}
input, err := json.Marshal(req)
if err != nil {
return err
}
helperPath, err := p.resolveHelperPath()
if err != nil {
return err
}
cmd := exec.Command(helperPath)
cmd.Stdin = bytes.NewReader(input)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = runHelperCommand(runCtx, cmd)
if stderr.Len() > 0 && global.LOG != nil {
global.LOG.Warnf("kvm helper stderr: %s", stderr.String())
}
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return buserr.New("ErrCmdTimeout")
}
if err != nil {
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("helper failed: %w, stderr: %s", err, stderr.String()))
}
var resp protocol.Response
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("invalid helper response: %w", err))
}
if !resp.Success {
return mapHelperError(resp.Error)
}
if out == nil {
return nil
}
data, err := json.Marshal(resp.Data)
if err != nil {
return err
}
return json.Unmarshal(data, out)
}
func logAction(action string, duration time.Duration, err error) {
if global.LOG == nil {
return
}
durationMs := duration.Milliseconds()
if err != nil {
global.LOG.Errorf("[kvm] action failed, action=%s, duration_ms=%d, err=%v", action, durationMs, err)
return
}
if duration >= slowActionThreshold {
global.LOG.Warnf("[kvm] slow action, action=%s, duration_ms=%d", action, durationMs)
}
}
func runHelperCommand(ctx context.Context, cmd *exec.Cmd) error {
if err := ctx.Err(); err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case err := <-done:
return err
case <-ctx.Done():
if cmd.Process != nil {
_ = cmd.Process.Signal(os.Interrupt)
}
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case err := <-done:
return err
case <-timer.C:
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
return <-done
}
}
}
func (p *Provider) ensureVersion(ctx context.Context) error {
p.versionOnce.Do(func() {
p.versionGate = make(chan struct{}, 1)
})
select {
case p.versionGate <- struct{}{}:
defer func() { <-p.versionGate }()
case <-ctx.Done():
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err
}
if p.checked {
return nil
}
if err := p.checkVersion(ctx); err != nil {
return err
}
p.checked = true
return nil
}
func (p *Provider) resolveHelperPath() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.HelperPath != "" {
return p.HelperPath, nil
}
path, err := findHelperPath()
if err != nil {
return "", err
}
p.HelperPath = path
return path, nil
}
func mapHelperError(item *protocol.Error) error {
if item == nil {
return buserr.New("ErrVMCommandFailed")
}
switch item.Code {
case "MAIN_DISK_DELETE_FAILED":
return item
case "VM_NOT_FOUND":
return buserr.New("ErrVMNotFound")
case "VM_ALREADY_EXISTS":
return buserr.New("ErrVMAlreadyExists")
case "VM_IMAGE_ALREADY_EXISTS":
return buserr.WithDetail("ErrVMImageAlreadyExists", item.Detail, errors.New(item.Detail))
case "DISK_RESIZE_BLOCKED":
if item.Detail == "referenceCheck" {
return buserr.New("ErrVMDiskReferenceCheck")
}
if item.Detail == "referenced" {
return buserr.New("ErrVMDiskReferenced")
}
return buserr.New("ErrVMDiskResizeBlocked")
case "VM_RUNNING":
return buserr.New("ErrVMRunning")
case "LIBVIRT_CONNECT":
return buserr.WithDetail("ErrVMLibvirtConnect", item.Detail, errors.New(item.Detail))
case "PERMISSION_DENIED":
return buserr.WithDetail("ErrVMPermission", item.Detail, errors.New(item.Detail))
case "NETWORK_NOT_FOUND":
return buserr.WithDetail("ErrVMNetworkNotFound", item.Detail, errors.New(item.Detail))
case "INVALID_PATH":
return buserr.WithDetail("ErrVMInvalidPath", item.Detail, errors.New(item.Detail))
case "INVALID_INPUT":
return buserr.WithDetail("ErrVMInvalidInput", item.Detail, errors.New(item.Detail))
default:
detail := item.Detail
if detail == "" {
detail = item.Message
}
return buserr.WithErr("ErrVMCommandFailed", errors.New(detail))
}
}
func findHelperPath() (string, error) {
if path := os.Getenv("ONEPANEL_KVM"); path != "" {
return path, nil
}
self, err := os.Executable()
if err == nil {
candidate := filepath.Join(filepath.Dir(self), protocol.HelperBinary)
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
if path, err := exec.LookPath(protocol.HelperBinary); err == nil {
return path, nil
}
return "", buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("%s not found", protocol.HelperBinary))
}
func toProtocolConfig(cfg types.VMConfig) protocol.VMConfig {
return protocol.VMConfig{
Name: cfg.Name, CPU: cfg.CPU, MemoryMiB: cfg.MemoryMiB, DiskSizeGiB: cfg.DiskSizeGiB,
DiskBus: cfg.DiskBus, DiskPath: cfg.DiskPath, StoragePool: cfg.StoragePool, ISOPaths: cfg.ISOPaths, ImagePath: cfg.ImagePath,
Network: cfg.Network, BootOrder: cfg.BootOrder, AutoStart: cfg.AutoStart,
StartAfterCreate: cfg.StartAfterCreate,
}
}
func toDTO(item protocol.VMInfo) dto.VMInfo {
out := dto.VMInfo{
Name: item.Name, UUID: item.UUID, CPU: item.CPU, Memory: item.Memory, DiskSize: item.DiskSize,
DiskBus: item.DiskBus, DiskPath: item.DiskPath, StoragePool: item.StoragePool, ISOPaths: item.ISOPaths, ImagePath: item.ImagePath,
Network: item.Network, BootOrder: item.BootOrder, AutoStart: item.AutoStart, Status: item.Status,
}
for _, disk := range item.Disks {
out.Disks = append(out.Disks, dto.VMDisk(disk))
}
for _, net := range item.Interfaces {
out.Interfaces = append(out.Interfaces, dto.VMInterface(net))
}
if item.Graphics != nil {
graphics := dto.VMGraphics(*item.Graphics)
out.Graphics = &graphics
}
return out
}