package pve import ( "bytes" "context" "encoding/json" "fmt" "net/url" "time" ) // api.go — типизированные обёртки над Proxmox VE REST (api2/json). // Чтения возвращают raw-тело "data"; мутации — UPID (асинхронная задача), // чтобы хендлер вернул идентификатор, а завершение опрашивали через // task_status(wait) — так мы не блокируем единственный loop (§9.6). // // Путь идёт через joinURL (PathEscape), а значения идентификаторов перед // этим ещё валидируются guard'ом в tools-слое — двойная защита. // Version — версия PVE в кластере. func (t *Tenant) Version(ctx context.Context, alias string) (json.RawMessage, error) { return t.GET(ctx, alias, "/version") } // ClusterStatus — состояние кластера (quorum, узлы, версии). func (t *Tenant) ClusterStatus(ctx context.Context, alias string) (json.RawMessage, error) { return t.GET(ctx, alias, "/cluster/status") } // ClusterResources — ресурсы кластера по PVE-типу (vm/storage/node/sdn). func (t *Tenant) ClusterResources(ctx context.Context, alias, rtype string) (json.RawMessage, error) { path := "/cluster/resources" if rtype != "" { path += "?type=" + url.QueryEscape(rtype) } return t.GET(ctx, alias, path) } // GuestResources — VM/CT по типу (qemu|lxc): тянет type=vm и фильтрует по // полю "type" каждой записи (PVE не принимает type=qemu напрямую). func (t *Tenant) GuestResources(ctx context.Context, alias, kind string) (json.RawMessage, error) { raw, err := t.GET(ctx, alias, "/cluster/resources?type=vm") if err != nil { return nil, err } var arr []map[string]any if err := json.Unmarshal(raw, &arr); err != nil { return raw, nil } out := make([]map[string]any, 0, len(arr)) for _, it := range arr { if it["type"] == kind { out = append(out, it) } } return json.Marshal(out) } // Nodes — список нод кластера. func (t *Tenant) Nodes(ctx context.Context, alias string) (json.RawMessage, error) { return t.GET(ctx, alias, "/nodes") } // NodeStatus — детальное состояние ноды. func (t *Tenant) NodeStatus(ctx context.Context, alias, node string) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/status")) } // NodeNetwork — сетевые интерфейсы ноды (мосты/eth). func (t *Tenant) NodeNetwork(ctx context.Context, alias, node string) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/network")) } // VMConfig — конфиг QEMU-ВМ. func (t *Tenant) VMConfig(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/config")) } // VMStatus — текущий статус QEMU-ВМ. func (t *Tenant) VMStatus(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/status/current")) } // ContainerConfig — конфиг LXC. func (t *Tenant) ContainerConfig(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/config")) } // ContainerStatus — текущий статус LXC. func (t *Tenant) ContainerStatus(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/status/current")) } // StorageList — хранилища кластера. func (t *Tenant) StorageList(ctx context.Context, alias string) (json.RawMessage, error) { return t.GET(ctx, alias, "/storage") } // NodeStorage — хранилища конкретной ноды. func (t *Tenant) NodeStorage(ctx context.Context, alias, node string) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/storage")) } // StorageContent — содержимое хранилища (iso/vztmpl/backup/images). func (t *Tenant) StorageContent(ctx context.Context, alias, node, storage, contentType string) (json.RawMessage, error) { path := joinURL("/nodes", node, "/storage", storage, "/content") if contentType != "" { path += "?content=" + url.QueryEscape(contentType) } return t.GET(ctx, alias, path) } // SnapshotList — снапшоты VM/CT. func (t *Tenant) SnapshotList(ctx context.Context, alias, node, vmtype string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot")) } // BackupList — задачи vzdump (по ноде/всему кластеру). func (t *Tenant) BackupList(ctx context.Context, alias, node string) (json.RawMessage, error) { if node != "" { return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks")+"?typefilter=vzdump") } return t.GET(ctx, alias, "/cluster/tasks?typefilter=vzdump") } // TasksList — последние задачи кластера. func (t *Tenant) TasksList(ctx context.Context, alias, node string, limit int) (json.RawMessage, error) { if node != "" { return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks")+"?limit="+itoa(limit)) } return t.GET(ctx, alias, "/cluster/tasks?limit="+itoa(limit)) } // TaskStatus — статус задачи по UPID. func (t *Tenant) TaskStatus(ctx context.Context, alias, node, upid string) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks", upid, "/status")) } // TaskLog — лог задачи по UPID. func (t *Tenant) TaskLog(ctx context.Context, alias, node, upid string, limit int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/tasks", upid, "/log")+"?limit="+itoa(limit)) } // GuestIPs — IP-адреса гостя через QEMU guest-agent (только QEMU, agent:1). func (t *Tenant) GuestIPs(ctx context.Context, alias, node string, vmid int) (json.RawMessage, error) { return t.GET(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/agent/network-get-interfaces")) } // --- Мутации → возвращают UPID или пустую строку (у части POST нет upid) --- // VMStart / VMStop / VMReboot / VMShutdown / VMSuspend / VMResume — lifecycle. // graceful может быть 0 (обычный), 1/2/... для shutdown. Здесь передаём action. func (t *Tenant) VMAction(ctx context.Context, alias, node string, vmid int, action string, form url.Values) (string, error) { if form == nil { form = url.Values{} } return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/status", action), form) } // ContainerAction — lifecycle LXC (start/stop/reboot/shutdown). func (t *Tenant) ContainerAction(ctx context.Context, alias, node string, vmid int, action string) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/status", action), nil) } // VMClone — клонирование из template/vmid. func (t *Tenant) VMClone(ctx context.Context, alias, node string, vmid, newid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/clone"), form) } // ContainerClone — клонирование LXC. func (t *Tenant) ContainerClone(ctx context.Context, alias, node string, vmid, newid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid), "/clone"), form) } // VMDelete — удаление QEMU-ВМ. func (t *Tenant) VMDelete(ctx context.Context, alias, node string, vmid int) (string, error) { return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid))) } // ContainerDelete — удаление LXC. func (t *Tenant) ContainerDelete(ctx context.Context, alias, node string, vmid int) (string, error) { return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/lxc", itoa(vmid))) } // VMConvertTemplate — превращает ВМ в шаблон. func (t *Tenant) VMConvertTemplate(ctx context.Context, alias, node string, vmid int) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/template"), nil) } // NextID — следующий свободный VMID в кластере. func (t *Tenant) NextID(ctx context.Context, alias string) (json.RawMessage, error) { return t.GET(ctx, alias, "/cluster/nextid") } // SnapshotCreate / SnapshotDelete / SnapshotRollback — снапшоты VM/CT. func (t *Tenant) SnapshotCreate(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot"), form) } func (t *Tenant) SnapshotDelete(ctx context.Context, alias, node, vmtype string, vmid int, name string) (string, error) { return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot", name)) } func (t *Tenant) SnapshotRollback(ctx context.Context, alias, node, vmtype string, vmid int, name string) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/snapshot", name, "/rollback"), nil) } // BackupCreate — запускает vzdump (async UPID). func (t *Tenant) BackupCreate(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/vzdump"), withBackupForm(form, vmtype, vmid)) } // BackupRestore (vzdump restore) реализуется через clone из backup volume; // в v1 это отдельный инструмент, см. tools/backup.go. // TaskStop — отменяет/стирает задачу. func (t *Tenant) TaskStop(ctx context.Context, alias, node, upid string) (string, error) { return t.DELETEUPID(ctx, alias, joinURL("/nodes", node, "/tasks", upid)) } // ConfigPost — POST на /config (VM/CT): изменение конфига, включая // добавление/удаление дисков и сетевых интерфейсов (структурный путь). func (t *Tenant) ConfigPost(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/config"), form) } // ResizeDisk — изменение размера диска VM/CT (/resize). func (t *Tenant) ResizeDisk(ctx context.Context, alias, node, vmtype string, vmid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, vmtype, itoa(vmid), "/resize"), form) } // MoveDisk — перенос диска (storage-миграция) на другую подсистему хранения. func (t *Tenant) MoveDisk(ctx context.Context, alias, node string, vmid int, form url.Values) (string, error) { return t.POSTUPID(ctx, alias, joinURL("/nodes", node, "/qemu", itoa(vmid), "/move_disk"), form) } // TaskWait ожидает завершения задачи по UPID (с капом task_poll_max_sec), // возвращая финальный статус. Poll строго bounded (§9.6) — не блокируем // loop дольше капа даже для долгой операции. func (t *Tenant) TaskWait(ctx context.Context, alias, node, upid string) (string, error) { capSec := t.cfg.TaskPollMaxSec if capSec <= 0 { capSec = DefaultTaskPollMaxSec } deadline := time.Now().Add(time.Duration(capSec) * time.Second) for { status, err := t.TaskStatus(ctx, alias, node, upid) if err != nil { return "", err } done := taskIsDone(status) if done { return pretty(json.RawMessage(status)), nil } if time.Now().After(deadline) { return "", fmt.Errorf("task %s still running after %ds (give timeout)", upid, capSec) } if !sleep(ctx, 2*time.Second) { return "", fmt.Errorf("task wait interrupted: %w", ctx.Err()) } } } // taskIsDone сообщает, завершилась ли задача (по статусам PVE). func taskIsDone(raw json.RawMessage) bool { var obj struct { Status string `json:"status"` } if err := json.Unmarshal(raw, &obj); err != nil { return true } switch obj.Status { case "stopped", "failed", "error": // completed (либо упала) return true } return false } // --- низкоуровневый доступ (GET/POST/DELETE) --- // GET — чтение "data". func (t *Tenant) GET(ctx context.Context, alias, path string) (json.RawMessage, error) { c, err := t.Client(ctx, alias) if err != nil { return nil, err } return c.Get(ctx, path) } // POSTUPID — POST и возврат UPID из ответа ("upid" либо null). func (t *Tenant) POSTUPID(ctx context.Context, alias, path string, form url.Values) (string, error) { c, err := t.Client(ctx, alias) if err != nil { return "", err } data, err := c.Post(ctx, path, form) if err != nil { return "", err } return upidOf(data), nil } // DELETEUPID — DELETE и возврат UPID из ответа. func (t *Tenant) DELETEUPID(ctx context.Context, alias, path string) (string, error) { c, err := t.Client(ctx, alias) if err != nil { return "", err } data, err := c.Delete(ctx, path) if err != nil { return "", err } return upidOf(data), nil } // upidOf вытаскивает "upid" из JSON-объекта данных (у части POST его нет). func upidOf(data json.RawMessage) string { var obj struct { UPID string `json:"upid"` } if err := json.Unmarshal(data, &obj); err == nil && obj.UPID != "" { return obj.UPID } return "" } // withBackupForm добавляет типы/цель в form vzdump. func withBackupForm(form url.Values, vmtype string, vmid int) url.Values { if form == nil { form = url.Values{} } if vmtype == "qemu" { form.Set("vmid", itoa(vmid)) form.Set("mode", "snapshot") } else { form.Set("vmid", itoa(vmid)) form.Set("mode", "suspend") } return form } func itoa(i int) string { return fmt.Sprintf("%d", i) } // pretty форматирует raw JSON для отдачи модели. func pretty(raw json.RawMessage) string { if len(raw) == 0 || string(raw) == "null" { return "(no data)" } var b bytes.Buffer if err := json.Indent(&b, raw, "", " "); err != nil { return string(raw) } return b.String() }