September 27, 2026

Proxmox VE API & Otomasi VM.

Membedah REST API Proxmox VE dari dalam: autentikasi token vs ticket, struktur endpoint, clone VM dari template cloud-init, polling task lewat UPID, LXC, alokasi IP, permission, sampai contoh client TypeScript yang tahan banting.

Web UI Proxmox VE di port 8006 sebenarnya cuma client biasa. Setiap klik di sana (buat VM, start, resize disk, buka console) diterjemahkan jadi request HTTP ke REST API yang sama persis dengan yang bisa kita panggil sendiri. Artinya, apa pun yang bisa dilakukan lewat UI bisa diotomasi.

Catatan ini lahir dari pengalaman membangun dua panel yang bicara langsung dengan API Proxmox: Proxmox Management Server (Fastify + TypeScript) dan modul virtualisasi di Web Host Manager , ditambah eksperimen di home server sendiri. Kalau Proxmox-nya belum terpasang, mulai dulu dari catatan Proxmox (VM) .

Gambaran Besar: pveproxy, pvedaemon, dan /api2/json

Di setiap node Proxmox ada dua daemon yang penting untuk kita:

  • pveproxy: mendengarkan HTTPS di port 8006, melayani web UI dan API, lalu meneruskan request ke daemon lain bila perlu (termasuk ke node lain dalam satu cluster).
  • pvedaemon: menjalankan operasi yang butuh hak istimewa (membuat VM, mengubah konfigurasi, dll.).

Semua endpoint berada di bawah base URL:

https://<host>:8006/api2/json/

Segmen json adalah format respons. Struktur path-nya hierarkis dan cukup mudah ditebak:

PathIsi
/versionVersi Proxmox VE
/cluster/resources?type=vmSemua VM & container di seluruh cluster (satu request)
/cluster/nextidVMID berikutnya yang masih kosong
/nodesDaftar node
/nodes/{node}/qemuVM (QEMU/KVM) di satu node
/nodes/{node}/qemu/{vmid}/configKonfigurasi VM
/nodes/{node}/qemu/{vmid}/status/startAksi power (start/stop/shutdown/reboot)
/nodes/{node}/qemu/{vmid}/cloneClone VM atau template
/nodes/{node}/lxcContainer LXC
/nodes/{node}/tasks/{upid}/statusStatus task yang sedang berjalan
/nodes/{node}/networkInterface jaringan node (bridge, bond, vlan)
/access/...User, role, ACL, token, login

Setiap respons sukses dibungkus dalam objek { "data": ... }. Referensi lengkap semua endpoint beserta parameternya ada di API Viewer resmi ; bookmark halaman itu, karena akan sering dibuka.

Satu hal yang sering terlewat: API Proxmox adalah API per-node, tapi cluster-aware. Kamu bisa mengirim request /nodes/pve2/... ke pve1, dan pveproxy di pve1 akan meneruskannya ke pve2. Praktis, tapi berarti kalau pve1 mati, semua otomasi yang di-hardcode ke pve1 ikut mati.

Autentikasi: API Token vs Ticket

Ada dua cara untuk membuktikan identitas ke API.

1. Ticket (sesi berbasis login)

Kirim username dan password ke POST /access/ticket:

curl -k -d 'username=root@pam' --data-urlencode 'password=RAHASIA' \
  https://pve.example.lan:8006/api2/json/access/ticket

Responsnya berisi dua nilai penting:

  • ticket: dikirim balik sebagai cookie PVEAuthCookie di setiap request.
  • CSRFPreventionToken: wajib dikirim sebagai header CSRFPreventionToken untuk request yang mengubah data (POST/PUT/DELETE).

Ticket berlaku 2 jam. Artinya client harus menyimpan ticket, memperbaruinya sebelum kedaluwarsa, dan menangani 401 ketika ticket ternyata sudah tidak valid.

Ticket masih relevan untuk beberapa hal: login yang memakai 2FA (parameter otp/tfa-challenge), dan alur console (noVNC/xterm.js) di mana browser perlu cookie sesi.

2. API Token (stateless)

Token adalah cara yang disarankan untuk otomasi. Tidak ada login, tidak ada ticket, tidak ada CSRF token. Cukup satu header:

Authorization: PVEAPIToken=automation@pve!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Formatnya USER@REALM!TOKENID=SECRET. Membuatnya:

pveum user add automation@pve --comment "User untuk panel otomasi"
pveum user token add automation@pve panel --privsep 1 --comment "Panel"

Nilai secret hanya ditampilkan sekali saat token dibuat; setelah itu tidak bisa diambil lagi lewat API. Simpan langsung di secret manager atau .env yang terenkripsi.

Perbandingan

AspekTicketAPI Token
Masa berlaku2 jam, harus di-refreshSampai dicabut / --expire
CSRF tokenWajib untuk writeTidak perlu
Butuh password userYaTidak
Bisa dibatasi lebih sempit dari user-nyaTidakYa (--privsep 1)
Cocok untukUI, console, 2FABackend, cron, CI, panel

Pelajaran dari Menulis Client Berbasis Ticket

Panel lama yang aku tulis ulang dulu memakai ticket, dan dari situ ada beberapa jebakan yang layak dicatat:

  • Deduplikasi login. Kalau 20 request datang bersamaan dan ticket belum ada, jangan sampai terjadi 20 kali POST /access/ticket. Simpan promise login yang sedang berjalan dan biarkan request lain menunggunya.
  • Batasi retry saat 401. Pola “kalau 401, login ulang lalu ulangi request” terlihat aman, sampai akun dicabut dan client terjebak rekursi tanpa akhir. Tetapkan batas (misal 2 kali), setelah itu lempar error.
  • Simpan ticket di storage bersama (Redis/DB) bila ada banyak worker, supaya satu login dipakai bersama, bukan satu login per proses.

Kalau bisa memakai token, sebagian besar masalah di atas hilang dengan sendirinya.

Permission: Path, Role, dan Privilege Separation

Sistem izin Proxmox berbasis ACL dengan tiga unsur: path, subjek (user/group/token), dan role.

  • Path menunjuk objek: /, /nodes/{node}, /vms/{vmid}, /storage/{storeid}, /pool/{pool}, /sdn/....
  • Role adalah kumpulan privilege. Bawaannya antara lain Administrator, PVEVMAdmin, PVEVMUser, PVEAuditor, PVEDatastoreUser.
  • Privilege adalah unit terkecil: VM.Allocate, VM.Clone, VM.PowerMgmt, VM.Config.Cloudinit, Datastore.AllocateSpace, SDN.Use, Sys.Audit, dan seterusnya.

Izin diwariskan ke bawah (--propagate 1 secara default), jadi role di /vms berlaku untuk semua VM.

Jebakan Privilege Separation

Dengan --privsep 1 (default), izin efektif token adalah irisan izin user dan izin token. Token baru tanpa ACL sendiri = token tanpa izin sama sekali, meskipun user-nya punya role lengkap. Ini penyebab klasik error 403 Permission check failed di hari pertama:

# Beri izin ke user DAN ke token-nya
pveum acl modify /vms --users automation@pve --roles PVEVMAdmin
pveum acl modify /vms --tokens 'automation@pve!panel' --roles PVEVMAdmin
pveum acl modify /storage/local-lvm --tokens 'automation@pve!panel' --roles PVEDatastoreUser

# Cek izin efektif token
pveum user token permissions automation@pve panel

Izin per Endpoint

API Viewer mencantumkan izin yang dibutuhkan setiap endpoint. Beberapa yang sering bikin bingung:

OperasiIzin yang dibutuhkan
Buat VM (POST /nodes/{node}/qemu)VM.Allocate di /vms/{vmid} atau pool; Datastore.AllocateSpace di storage; SDN.Use di bridge/VLAN yang dipakai
CloneVM.Clone di VM sumber + VM.Allocate di VMID baru (atau pool) + izin storage & bridge
Buat LXC privilegedTambahan Sys.Modify di /
Lihat status task milik user lainSys.Audit di /nodes/{node}
Baca IP via guest agentVM.GuestAgent.Audit atau VM.GuestAgent.Unrestricted

Poin SDN.Use sering terlupa: di versi Proxmox yang lebih baru, memasang NIC VM ke bridge vmbr0 pun butuh izin atas bridge tersebut.

Prinsipnya: buat user khusus otomasi, jangan pakai root@pam. Kalau token bocor, kerusakannya terbatas pada apa yang diizinkan ACL.

Request Body: Form, Bukan JSON

API Proxmox paling aman dipanggil dengan body application/x-www-form-urlencoded. Beberapa parameter yang kelihatan seperti struktur sebenarnya hanyalah string dengan format khusus:

net0      = virtio,bridge=vmbr0,firewall=1
scsi0     = local-lvm:32,discard=on
ipconfig0 = ip=10.10.0.21/24,gw=10.10.0.1

Semua opsi konfigurasi VM ada di satu namespace datar: cores, memory, net0, scsi0, ide2, ipconfig0, dan seterusnya. Untuk menghapus opsi, kirim delete=net1,serial0.

Task & UPID: Kenapa API-nya “Asinkron”

Operasi berat (clone, create, start, migrate, backup, resize) tidak ditunggu sampai selesai. API langsung membalas dengan string UPID (Unique Process ID):

UPID:pve1:000A1B2C:0123ABCD:66F5E3A1:qmclone:9000:automation@pve!panel:

Formatnya: UPID:node:pid:pstart:starttime:tipe:id:user: (angka dalam hex). Dari UPID kita tahu task berjalan di node mana, jenisnya apa, dan untuk VMID berapa.

Untuk mengetahui hasilnya, poll:

GET /nodes/{node}/tasks/{upid}/status

Respons berisi status (running atau stopped) dan, setelah selesai, exitstatus. Yang perlu diingat:

  • stopped tidak berarti sukses. Task sukses hanya kalau exitstatus === "OK". Selain itu (misalnya teks error) berarti gagal. Ada juga status WARNINGS: n untuk task yang selesai dengan peringatan.
  • Log lengkap ada di GET /nodes/{node}/tasks/{upid}/log, dan sangat berguna untuk ditampilkan di UI sebagai progress (misal persentase clone disk).
  • Poll dengan interval wajar (1–2 detik) dan beri batas waktu total. Toleransi beberapa kegagalan poll (node sibuk, jaringan putus sesaat), tapi jangan tanpa batas.

Di Proxmox Management Server, status task ini dimasukkan ke antrean BullMQ dan dipancarkan ke UI lewat WebSocket , sehingga pengguna melihat progress secara real time alih-alih menunggu spinner.

Konfigurasi: POST vs PUT

Endpoint /nodes/{node}/qemu/{vmid}/config punya dua varian:

  • PUT: sinkron, tidak mengembalikan apa-apa.
  • POST: asinkron, bisa mengembalikan UPID. Dokumentasi menyarankan POST untuk perubahan yang melibatkan hotplug atau alokasi storage.

Jadi client harus siap menerima UPID atau null dari POST config.

Timeout di Sisi Client

Sebagian endpoint memang lambat menjawab bahkan sebelum UPID dikembalikan (misalnya clone dan resize di storage lambat). Jangan pakai timeout global 10 detik untuk semuanya; beri pengecualian atau timeout lebih panjang untuk endpoint semacam ini.

Alur Otomasi VM: Template Cloud-Init + Clone

Membuat VM dari ISO lewat API itu merepotkan (butuh installer interaktif). Pola yang umum dipakai adalah template cloud-init: satu VM disiapkan sekali, dijadikan template, lalu setiap VM baru adalah clone yang dikonfigurasi lewat cloud-init.

Menyiapkan Template (Sekali Saja, di Node)

# Unduh cloud image resmi distro (contoh Ubuntu/Debian cloud image .img/.qcow2)
qm create 9000 --name tpl-ubuntu --memory 2048 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci
qm set 9000 --scsi0 local-lvm:0,import-from=/root/ubuntu-cloudimg-amd64.img
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot order=scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
  • --ide2 ...:cloudinit membuat drive CD kecil berisi data cloud-init yang di-generate Proxmox dari opsi ciuser, sshkeys, ipconfig0, dll.
  • --serial0 socket --vga serial0 dibutuhkan karena banyak cloud image mengharapkan serial console.
  • --agent enabled=1 supaya nanti kita bisa membaca IP dari dalam VM lewat QEMU Guest Agent (paket qemu-guest-agent tetap perlu terpasang di dalam guest).

Alur via API

1. GET  /cluster/nextid                           β†’ VMID baru
2. POST /nodes/{node}/qemu/9000/clone             β†’ UPID (tunggu selesai)
3. POST /nodes/{node}/qemu/{vmid}/config          β†’ cores, memory, ipconfig0, sshkeys...
4. PUT  /nodes/{node}/qemu/{vmid}/resize          β†’ perbesar disk (UPID)
5. POST /nodes/{node}/qemu/{vmid}/status/start    β†’ UPID (tunggu)
6. GET  /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces  β†’ IP aktual

Detail penting di tiap langkah:

  • nextid bukan reservasi. Endpoint ini hanya bilang “saat dicek, VMID ini kosong”. Dua worker yang memanggilnya bersamaan bisa mendapat angka yang sama, dan salah satu clone akan gagal. Solusinya: kunci di sisi aplikasi (mutex/lock DB per cluster) atau tangani error “already exists” dengan retry memakai ID berikutnya.
  • Clone: linked vs full. Parameter full menentukan jenis clone. Clone dari VM biasa selalu full; clone dari template secara default dicoba sebagai linked clone (cepat, hemat ruang, tapi bergantung pada disk template). Untuk VM pelanggan yang harus berdiri sendiri, kirim full=1 dan storage=<target>.
  • Clone ke node lain (target) hanya diizinkan kalau VM sumber ada di shared storage.
  • sshkeys harus URL-encoded. Tipe parameternya urlencoded, jadi nilai key perlu di-encodeURIComponent dulu, lalu di-encode lagi sebagai bagian dari form. Encoding ganda ini memang disengaja, dan kalau dilewatkan hasilnya error validasi yang membingungkan.
  • cipassword bisa dipakai, tapi dokumentasi sendiri menyarankan SSH key .
  • Disk resize hanya bisa memperbesar (size=+20G atau ukuran absolut), tidak bisa mengecilkan.
  • IP dari guest agent baru tersedia setelah OS boot dan agent jalan. Poll dengan jeda dan batas waktu; error di awal itu normal.

LXC: Container dengan API yang Mirip

Container LXC punya endpoint paralel di /nodes/{node}/lxc. Bedanya, container dibuat dari template OS (tarball), bukan disk image:

pveam update
pveam available --section system
pveam download local debian-12-standard_12.7-1_amd64.tar.zst   # nama persis lihat output 'available'

Parameter create yang utama:

ParameterContoh
vmid201
ostemplatelocal:vztmpl/debian-12-standard_..._amd64.tar.zst
hostnamect-web-01
rootfslocal-lvm:8
net0name=eth0,bridge=vmbr0,ip=10.10.0.31/24,gw=10.10.0.1
unprivileged1
featuresnesting=1 (misal untuk Docker di dalam LXC)
ssh-public-keysisi public key

Perbedaan penting dibanding QEMU:

  • Tidak ada cloud-init. IP ditulis langsung di net0 (ip=.../24,gw=... atau ip=dhcp), dan Proxmox yang mengonfigurasi jaringan container.
  • IP bisa dibaca tanpa agent lewat GET /nodes/{node}/lxc/{vmid}/interfaces.
  • Privileged container butuh Sys.Modify di /. Tetaplah di unprivileged kecuali benar-benar perlu.
  • Console container memakai termproxy (xterm.js), sedangkan VM memakai vncproxy (noVNC). Keduanya berujung pada WebSocket yang perlu di-proxy oleh panel. Di Proxmox Management Server keduanya dilewatkan melalui proxy WebSocket di backend supaya browser tidak perlu akses langsung ke port 8006.

Networking: Bridge dan Alokasi IP

Bridge di Node

Di instalasi standar, vmbr0 adalah Linux bridge yang menjembatani NIC fisik. VM yang dipasang ke vmbr0 terlihat seperti mesin lain di LAN. Daftar bridge bisa diambil dari:

GET /nodes/{node}/network?type=any_bridge

Perubahan jaringan node lewat API (POST /nodes/{node}/network) tidak langsung aktif; perlu PUT /nodes/{node}/network untuk me-reload. Salah konfigurasi di sini bisa memutus akses ke node itu sendiri, jadi otomasi jaringan node sebaiknya sangat hati-hati atau tidak dilakukan sama sekali.

Proxmox Tidak Mengurus IP Kamu

Ini kesalahpahaman yang paling sering: Proxmox tidak punya IPAM untuk bridge biasa. ipconfig0 hanya menulis IP yang kamu berikan ke cloud-init; Proxmox tidak mengecek apakah IP itu sudah dipakai VM lain. (Fitur SDN punya IPAM dan DHCP sendiri untuk zona tertentu, tapi itu arsitektur yang berbeda.)

Jadi aplikasi otomasi harus menjadi source of truth untuk IP, port, dan MAC. Di Proxmox Management Server aku memodelkan setiap alamat sebagai baris di database dengan state machine:

free ──reserve──▢ reserved ──assign──▢ assigned ──release──▢ quarantine ──(timeout)──▢ free
                     β”‚
                     └──(provisioning gagal)──▢ free
  • reserved diambil sebelum clone, supaya dua request paralel tidak mendapat IP yang sama.
  • quarantine menahan IP yang baru dilepas selama beberapa waktu, supaya cache ARP di router dan DNS sempat kedaluwarsa sebelum IP dipakai VM lain.

Query pengambilan alamat memakai row lock supaya aman saat konkuren:

SELECT id, address FROM ip_allocations
WHERE pool_id = ? AND state = 'free'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

SKIP LOCKED membuat transaksi lain langsung melompati baris yang sedang dikunci alih-alih menunggu, sehingga sepuluh provisioning paralel mendapat sepuluh IP berbeda tanpa saling blok.

Lalu ada proses rekonsiliasi berkala: bandingkan isi database dengan kenyataan (/cluster/resources, config tiap VM) dan tandai selisihnya. Tanpa ini, database dan cluster pasti pelan-pelan tidak sinkron (VM dihapus manual lewat UI, clone gagal di tengah jalan, dan sebagainya).

Error Handling: Membaca Pesan Error Proxmox

Ada satu kebiasaan Proxmox yang sering membuat log tidak berguna: pesan error yang sebenarnya ada di HTTP reason phrase (status text), sementara body JSON-nya berisi {"data": null}. Kalau client hanya mencetak body, yang terlihat cuma 500 - {"data":null}.

Contoh reason phrase yang bisa muncul:

HTTP/1.1 500 unable to create VM 105 - VM 105 already exists
HTTP/1.1 403 Permission check failed (/vms/105, VM.Allocate)

Untuk error validasi parameter (400), body biasanya berisi objek errors yang memetakan nama parameter ke pesannya. Client yang baik:

  1. Membaca statusText sebagai pesan utama.
  2. Menggabungkan errors per parameter bila ada.
  3. Membedakan kategori: jaringan (node tidak terjangkau, TLS), autentikasi (401), izin (403), validasi (400), dan kegagalan task (exitstatus bukan OK).

Kategori ini berguna untuk UI: “node sedang offline” dan “IP sudah dipakai” butuh tindakan yang sangat berbeda dari operator.

Sertifikat Self-Signed

Proxmox memakai sertifikat self-signed secara default. Jalan pintas rejectUnauthorized: false memang berhasil, tapi membuka peluang MITM. Pilihan yang lebih baik, dari yang paling disarankan:

  1. Pasang sertifikat valid di node (Proxmox mendukung ACME/Let’s Encrypt bawaan).
  2. Pin CA milik cluster (/etc/pve/pve-root-ca.pem) di client.
  3. Baru terakhir: nonaktifkan verifikasi, dan hanya di jaringan privat.

Contoh Client TypeScript

Client minimal berbasis fetch bawaan Node (18+) dengan token, body form, error yang informatif, dan penunggu task. undici dipakai untuk memasang CA khusus.

// proxmox.ts
import { readFileSync } from 'node:fs';
import { Agent } from 'undici';

export class ProxmoxError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly fieldErrors?: Record<string, string>,
  ) {
    super(message);
    this.name = 'ProxmoxError';
  }
}

export class TaskFailedError extends Error {
  constructor(readonly task: TaskStatus) {
    super(`Task ${task.type} gagal: ${task.exitstatus}`);
    this.name = 'TaskFailedError';
  }
}

type Params = Record<string, string | number | boolean | undefined>;

export interface TaskStatus {
  status: 'running' | 'stopped';
  exitstatus?: string;
  upid: string;
  node: string;
  type: string;
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export class ProxmoxClient {
  private dispatcher: Agent;

  constructor(
    private baseUrl: string,      // contoh: https://pve.example.lan:8006
    private tokenId: string,      // contoh: automation@pve!panel
    private tokenSecret: string,  // dari env, jangan di-hardcode
    caPath?: string,              // contoh: salinan pve-root-ca.pem
  ) {
    this.dispatcher = new Agent({
      connect: caPath ? { ca: readFileSync(caPath) } : {},
    });
  }

  private toForm(params: Params): URLSearchParams {
    const form = new URLSearchParams();
    for (const [key, value] of Object.entries(params)) {
      if (value === undefined) continue;
      form.append(key, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
    }
    return form;
  }

  async request<T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    path: string,
    params: Params = {},
    timeoutMs = 30_000,
  ): Promise<T> {
    const url = new URL(`/api2/json${path}`, this.baseUrl);
    const init: RequestInit & { dispatcher: Agent } = {
      method,
      headers: { Authorization: `PVEAPIToken=${this.tokenId}=${this.tokenSecret}` },
      dispatcher: this.dispatcher,
      signal: AbortSignal.timeout(timeoutMs),
    };

    if (method === 'GET' || method === 'DELETE') {
      url.search = this.toForm(params).toString();
    } else {
      init.body = this.toForm(params); // otomatis application/x-www-form-urlencoded
    }

    const res = await fetch(url, init);
    const body = (await res.json().catch(() => null)) as
      | { data: T; errors?: Record<string, string> }
      | null;

    if (!res.ok) {
      // Pesan asli Proxmox ada di status text, bukan di body.
      const detail = body?.errors
        ? ' | ' + Object.entries(body.errors).map(([k, v]) => `${k}: ${v}`).join(', ')
        : '';
      throw new ProxmoxError(`${res.status} ${res.statusText}${detail}`, res.status, body?.errors);
    }

    return body!.data;
  }

  async waitForTask(node: string, upid: string, timeoutMs = 10 * 60_000): Promise<TaskStatus> {
    const deadline = Date.now() + timeoutMs;
    let consecutiveErrors = 0;

    while (Date.now() < deadline) {
      try {
        const task = await this.request<TaskStatus>(
          'GET',
          `/nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
        );
        consecutiveErrors = 0;

        if (task.status === 'stopped') {
          if (task.exitstatus !== 'OK') {
            throw new TaskFailedError(task);
          }
          return task;
        }
      } catch (err) {
        if (err instanceof TaskFailedError) throw err; // task gagal: jangan di-retry
        if (++consecutiveErrors >= 5) throw err; // node benar-benar tidak menjawab
      }
      await sleep(2000);
    }
    throw new Error(`Timeout menunggu task ${upid}`);
  }
}

Lalu alur provisioning VM dari template:

// provision.ts
import { ProxmoxClient } from './proxmox';

interface ProvisionInput {
  node: string;
  templateId: number;
  name: string;
  cores: number;
  memoryMb: number;
  diskGrow: string;        // contoh: '+20G'
  storage: string;         // contoh: 'local-lvm'
  ip: string;              // dari alokator IP aplikasi, contoh '10.10.0.21/24'
  gateway: string;
  sshPublicKey: string;
}

export async function provisionVm(pve: ProxmoxClient, input: ProvisionInput) {
  const { node } = input;

  // 1. VMID baru (bukan reservasi; lindungi dengan lock di sisi aplikasi)
  const vmid = Number(await pve.request<string>('GET', '/cluster/nextid'));

  // 2. Full clone dari template
  const cloneUpid = await pve.request<string>(
    'POST',
    `/nodes/${node}/qemu/${input.templateId}/clone`,
    { newid: vmid, name: input.name, full: true, storage: input.storage },
    120_000,
  );
  await pve.waitForTask(node, cloneUpid);

  // 3. Resource + cloud-init
  const configUpid = await pve.request<string | null>('POST', `/nodes/${node}/qemu/${vmid}/config`, {
    cores: input.cores,
    memory: input.memoryMb,
    ciuser: 'deploy',
    // Tipe 'urlencoded': encode dulu, lalu URLSearchParams meng-encode lagi.
    sshkeys: encodeURIComponent(input.sshPublicKey.trim()),
    ipconfig0: `ip=${input.ip},gw=${input.gateway}`,
    nameserver: '1.1.1.1',
  });
  if (configUpid) await pve.waitForTask(node, configUpid);

  // 4. Perbesar disk utama
  const resizeUpid = await pve.request<string>(
    'PUT',
    `/nodes/${node}/qemu/${vmid}/resize`,
    { disk: 'scsi0', size: input.diskGrow },
    120_000,
  );
  if (resizeUpid) await pve.waitForTask(node, resizeUpid);

  // 5. Start
  const startUpid = await pve.request<string>('POST', `/nodes/${node}/qemu/${vmid}/status/start`);
  await pve.waitForTask(node, startUpid);

  return { vmid, node };
}

Beberapa catatan desain:

  • Idempotensi. Kalau proses mati di tengah (misal setelah clone), job yang diulang harus bisa mendeteksi VM yang sudah ada dan melanjutkan, bukan membuat VM kedua. Simpan progres langkah di database job.
  • Rollback. Kalau langkah 3–5 gagal, putuskan: hapus VM (DELETE /nodes/{node}/qemu/{vmid} dengan purge=1) dan kembalikan IP ke free, atau tandai untuk ditangani manual. Yang penting, keputusannya eksplisit.
  • Jalankan di worker, bukan di request HTTP. Clone bisa makan menit. Endpoint API aplikasi cukup membuat job dan mengembalikan ID-nya, lalu progress dikirim lewat WebSocket/SSE.

Pitfall yang Sering Ditemui

GejalaPenyebab umum
401 terus-menerus dengan tokenFormat header salah (harus PVEAPIToken=user@realm!id=secret), atau token sudah kedaluwarsa/dihapus
403 Permission check failed padahal user adminprivsep=1 dan token belum diberi ACL sendiri; atau lupa SDN.Use/Datastore.AllocateSpace
Error body {"data":null} tanpa pesanPesan ada di status text respons
Clone “sukses” tapi VM tidak adaTidak menunggu task, atau tidak mengecek exitstatus
Dua VM rebutan VMIDnextid dipanggil paralel tanpa lock
VM boot tanpa SSH keysshkeys tidak di-URL-encode, atau image bukan cloud image
IP tidak pernah munculagent belum di-enable di config, atau qemu-guest-agent belum terpasang di guest
Request timeout padahal task jalanTimeout client terlalu pendek untuk clone/resize
Otomasi mati saat satu node matiSemua request dikirim ke satu hostname node; pakai beberapa endpoint atau VIP
IP bentrok antar VMMengandalkan Proxmox untuk IPAM; alokasi harus diurus aplikasi

Penutup

API Proxmox itu jujur tapi “mentah”: ia melakukan persis yang diminta, tanpa IPAM, tanpa reservasi VMID, dan tanpa menunggu task selesai. Karena itu hampir semua kerumitan otomasi justru ada di lapisan aplikasi: mengelola state, menunggu task dengan benar, menangani konkurensi, dan menerjemahkan error ke sesuatu yang bisa ditindaklanjuti. Setelah lapisan itu kokoh, membuat panel sendiri, entah untuk home lab atau bisnis hosting, jadi jauh lebih mudah.

Referensi:

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.

Comments