Initial commit: Proxmox cloud image template tool

This commit is contained in:
2025-11-14 20:35:06 +07:00
commit 8d057bfd94
10 changed files with 629 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
proxmox-cloud-image
*.exe
*.dll
*.so
*.dylib
*.test
*.out
go.work

172
README.md Normal file
View File

@@ -0,0 +1,172 @@
# Proxmox Cloud Image Tool
Tool untuk membuat **template** di Proxmox menggunakan cloud image (Ubuntu, Debian, CentOS, dll) dengan Golang.
## Features
- Download cloud image dari URL
- Customize image (resize disk, inject SSH key)
- Otomatis create template di Proxmox
- Support konfigurasi via CLI flags atau YAML file
- Progress bar untuk download
## Requirements
- Go 1.19+
- SSH access ke Proxmox host
- `qemu-img` dan `virt-customize` (libguestfs-tools)
Install dependencies di Ubuntu/Debian:
```bash
sudo apt install qemu-utils libguestfs-tools
```
## Installation
### Build from source:
```bash
git clone <repo-url>
cd cloud-image
go build -o proxmox-cloud-image
```
### Install globally (Linux):
```bash
sudo cp proxmox-cloud-image /usr/local/bin/
sudo chmod +x /usr/local/bin/proxmox-cloud-image
```
Setelah install, bisa langsung dipanggil dari mana aja:
```bash
proxmox-cloud-image -h
```
## Usage
### Menggunakan CLI flags:
```bash
proxmox-cloud-image \
-image-url "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" \
-vm-name "ubuntu-template" \
-vm-id 9000 \
-proxmox-host "192.168.1.100" \
-proxmox-user "root@pam" \
-storage "local-lvm" \
-memory 2048 \
-cores 2 \
-disk-size "20G" \
-bridge "vmbr0" \
-ssh-key "/root/.ssh/id_rsa.pub"
```
### Auto-find VM ID (mulai dari 10000):
```bash
proxmox-cloud-image \
-image-url "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" \
-vm-name "ubuntu-template" \
-proxmox-host "192.168.1.100"
```
Kalo `-vm-id` tidak diisi atau diset `0`, tool akan otomatis cari VM ID kosong mulai dari 10000.
### Dengan VLAN:
```bash
proxmox-cloud-image \
-image-url "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img" \
-vm-name "ubuntu-template" \
-vm-id 9000 \
-proxmox-host "192.168.1.100" \
-bridge "vmbr0" \
-vlan-tag 100
```
### Menggunakan config file:
```bash
proxmox-cloud-image -config config.yaml
```
Contoh `config.yaml`:
```yaml
image_url: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
vm_name: "ubuntu-template"
vm_id: 0
storage: "local-lvm"
memory: 2048
cores: 2
disk_size: "20G"
bridge: "vmbr0"
vlan_tag: 100
ssh_key: "/root/.ssh/id_rsa.pub"
proxmox_host: "192.168.1.100"
proxmox_user: "root@pam"
```
## Cloud Image URLs
### Ubuntu
- Ubuntu 22.04 (Jammy): `https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img`
- Ubuntu 20.04 (Focal): `https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-amd64.img`
### Debian
- Debian 12 (Bookworm): `https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2`
- Debian 11 (Bullseye): `https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-generic-amd64.qcow2`
### CentOS Stream
- CentOS Stream 9: `https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2`
## Flags
| Flag | Default | Description |
|------|---------|-------------|
| `-config` | - | Path ke config file (YAML) |
| `-image-url` | - | URL cloud image (required) |
| `-vm-name` | cloud-vm | Nama template |
| `-vm-id` | 0 | Template ID (0 = auto-find dari 10000+) |
| `-storage` | local-lvm | Nama storage Proxmox |
| `-memory` | 2048 | Memory dalam MB |
| `-cores` | 2 | Jumlah CPU cores |
| `-disk-size` | 20G | Ukuran disk |
| `-bridge` | vmbr0 | Network bridge |
| `-vlan-tag` | 0 | VLAN tag (0 = no VLAN) |
| `-ssh-key` | - | Path ke SSH public key |
| `-proxmox-host` | - | IP/hostname Proxmox (required) |
| `-proxmox-user` | root@pam | Proxmox user |
| `-proxmox-pass` | - | Proxmox password |
## How It Works
1. Download cloud image dari URL yang diberikan
2. Customize image (resize, inject SSH key jika ada)
3. Upload image ke Proxmox host via SCP
4. Create VM menggunakan `qm` commands
5. Import disk dan configure VM
6. Setup cloud-init
7. **Convert VM menjadi template** dengan `qm template`
## Clone Template
Setelah template dibuat, kamu bisa clone untuk membuat VM baru:
```bash
qm clone 9000 100 --name my-vm --full
qm set 100 --ipconfig0 ip=192.168.1.100/24,gw=192.168.1.1
qm set 100 --sshkeys /root/.ssh/id_rsa.pub
qm start 100
```
## Notes
- Tool ini menggunakan SSH untuk koneksi ke Proxmox
- Pastikan SSH key sudah di-setup untuk passwordless login
- Image akan di-download ke `/tmp` dan di-upload ke Proxmox
- Template tidak bisa di-start, harus di-clone dulu
## License
MIT

13
config.example.yaml Normal file
View File

@@ -0,0 +1,13 @@
image_url: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
vm_name: "ubuntu-template"
vm_id: 0
storage: "local-lvm"
memory: 2048
cores: 2
disk_size: "20G"
bridge: "vmbr0"
vlan_tag: 0
ssh_key: "/root/.ssh/id_rsa.pub"
proxmox_host: "192.168.1.100"
proxmox_user: "root@pam"
proxmox_pass: ""

21
config.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
func loadConfigFile(path string, config *Config) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(data, config); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
return nil
}

89
customize.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func customizeImage(config *Config) error {
fmt.Println("Customizing image...")
if err := checkDependencies(); err != nil {
return err
}
imagePath := config.ImageURL
if strings.HasSuffix(imagePath, ".qcow2") {
if err := resizeImage(imagePath, config.DiskSize); err != nil {
return err
}
}
if config.SSHKey != "" {
if err := injectSSHKey(imagePath, config.SSHKey); err != nil {
return err
}
}
return nil
}
func checkDependencies() error {
deps := []string{"qemu-img", "virt-customize"}
for _, dep := range deps {
if _, err := exec.LookPath(dep); err != nil {
return fmt.Errorf("required dependency '%s' not found. Please install libguestfs-tools and qemu-utils", dep)
}
}
return nil
}
func resizeImage(imagePath, size string) error {
fmt.Printf("Resizing image to %s...\n", size)
cmd := exec.Command("qemu-img", "resize", imagePath, size)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to resize image: %w", err)
}
return nil
}
func injectSSHKey(imagePath, sshKeyPath string) error {
fmt.Printf("Injecting SSH key from %s...\n", sshKeyPath)
keyData, err := os.ReadFile(sshKeyPath)
if err != nil {
return fmt.Errorf("failed to read SSH key: %w", err)
}
tmpDir := "/tmp/cloud-init-" + filepath.Base(imagePath)
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
keyFile := filepath.Join(tmpDir, "authorized_keys")
if err := os.WriteFile(keyFile, keyData, 0644); err != nil {
return fmt.Errorf("failed to write SSH key: %w", err)
}
cmd := exec.Command("virt-customize",
"-a", imagePath,
"--mkdir", "/root/.ssh",
"--upload", keyFile+":/root/.ssh/authorized_keys",
"--chmod", "0600:/root/.ssh/authorized_keys",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to inject SSH key: %w", err)
}
return nil
}

76
download.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
func downloadImage(config *Config) error {
filename := getFilenameFromURL(config.ImageURL)
filepath := filepath.Join("/tmp", filename)
if _, err := os.Stat(filepath); err == nil {
fmt.Printf("Image already exists at %s, skipping download\n", filepath)
config.ImageURL = filepath
return nil
}
fmt.Printf("Downloading image from %s...\n", config.ImageURL)
resp, err := http.Get(config.ImageURL)
if err != nil {
return fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
counter := &WriteCounter{Total: resp.ContentLength}
_, err = io.Copy(out, io.TeeReader(resp.Body, counter))
if err != nil {
return fmt.Errorf("failed to save image: %w", err)
}
fmt.Println("\nDownload completed!")
config.ImageURL = filepath
return nil
}
func getFilenameFromURL(url string) string {
parts := strings.Split(url, "/")
return parts[len(parts)-1]
}
type WriteCounter struct {
Total int64
Downloaded int64
}
func (wc *WriteCounter) Write(p []byte) (int, error) {
n := len(p)
wc.Downloaded += int64(n)
wc.printProgress()
return n, nil
}
func (wc *WriteCounter) printProgress() {
fmt.Printf("\r")
if wc.Total > 0 {
percent := float64(wc.Downloaded) / float64(wc.Total) * 100
fmt.Printf("Downloading... %.0f%% (%d/%d MB)", percent, wc.Downloaded/1024/1024, wc.Total/1024/1024)
} else {
fmt.Printf("Downloading... %d MB", wc.Downloaded/1024/1024)
}
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/othman/proxmox-cloud-image
go 1.22.2
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
go.sum Normal file
View File

@@ -0,0 +1,3 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

99
main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"flag"
"fmt"
"os"
)
type Config struct {
ImageURL string `yaml:"image_url"`
VMName string `yaml:"vm_name"`
VMID int `yaml:"vm_id"`
Storage string `yaml:"storage"`
Memory int `yaml:"memory"`
Cores int `yaml:"cores"`
DiskSize string `yaml:"disk_size"`
Bridge string `yaml:"bridge"`
VlanTag int `yaml:"vlan_tag"`
SSHKey string `yaml:"ssh_key"`
ProxmoxHost string `yaml:"proxmox_host"`
ProxmoxUser string `yaml:"proxmox_user"`
ProxmoxPass string `yaml:"proxmox_pass"`
}
func main() {
config := &Config{}
var configFile string
flag.StringVar(&configFile, "config", "", "Config file path (YAML)")
flag.StringVar(&config.ImageURL, "image-url", "", "Cloud image URL to download")
flag.StringVar(&config.VMName, "vm-name", "cloud-vm", "VM name")
flag.IntVar(&config.VMID, "vm-id", 0, "VM ID (0 = auto-find from 10000+)")
flag.StringVar(&config.Storage, "storage", "local-lvm", "Proxmox storage name")
flag.IntVar(&config.Memory, "memory", 2048, "Memory in MB")
flag.IntVar(&config.Cores, "cores", 2, "CPU cores")
flag.StringVar(&config.DiskSize, "disk-size", "20G", "Disk size (e.g., 20G)")
flag.StringVar(&config.Bridge, "bridge", "vmbr0", "Network bridge")
flag.IntVar(&config.VlanTag, "vlan-tag", 0, "VLAN tag (optional, 0 = no VLAN)")
flag.StringVar(&config.SSHKey, "ssh-key", "", "SSH public key file path")
flag.StringVar(&config.ProxmoxHost, "proxmox-host", "", "Proxmox host (e.g., 192.168.1.100)")
flag.StringVar(&config.ProxmoxUser, "proxmox-user", "root@pam", "Proxmox user")
flag.StringVar(&config.ProxmoxPass, "proxmox-pass", "", "Proxmox password")
flag.Parse()
if configFile != "" {
if err := loadConfigFile(configFile, config); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config file: %v\n", err)
os.Exit(1)
}
}
if config.ImageURL == "" {
fmt.Println("Error: -image-url is required")
flag.Usage()
os.Exit(1)
}
if config.ProxmoxHost == "" {
fmt.Println("Error: -proxmox-host is required")
flag.Usage()
os.Exit(1)
}
if err := run(config); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println("Template created successfully!")
}
func run(config *Config) error {
if config.VMID == 0 {
fmt.Println("Auto-finding available VM ID starting from 10000...")
vmid, err := findAvailableVMID(config)
if err != nil {
return fmt.Errorf("failed to find available VM ID: %w", err)
}
config.VMID = vmid
fmt.Printf("Found available VM ID: %d\n", vmid)
}
fmt.Printf("Creating template %s (ID: %d) on Proxmox host %s\n", config.VMName, config.VMID, config.ProxmoxHost)
if err := downloadImage(config); err != nil {
return fmt.Errorf("failed to download image: %w", err)
}
if err := customizeImage(config); err != nil {
return fmt.Errorf("failed to customize image: %w", err)
}
if err := createProxmoxVM(config); err != nil {
return fmt.Errorf("failed to create Proxmox template: %w", err)
}
return nil
}

143
proxmox.go Normal file
View File

@@ -0,0 +1,143 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
func findAvailableVMID(config *Config) (int, error) {
sshCmd := func(args ...string) *exec.Cmd {
fullArgs := []string{
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
fmt.Sprintf("%s@%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost),
}
fullArgs = append(fullArgs, args...)
return exec.Command("ssh", fullArgs...)
}
cmd := sshCmd("qm", "list")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, fmt.Errorf("failed to list VMs: %w", err)
}
usedIDs := make(map[int]bool)
lines := strings.Split(stdout.String(), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) > 0 {
if vmid, err := strconv.Atoi(fields[0]); err == nil {
usedIDs[vmid] = true
}
}
}
for vmid := 10000; vmid < 20000; vmid++ {
if !usedIDs[vmid] {
return vmid, nil
}
}
return 0, fmt.Errorf("no available VM ID found in range 10000-19999")
}
func buildNetworkConfig(config *Config) string {
netConfig := fmt.Sprintf("virtio,bridge=%s", config.Bridge)
if config.VlanTag > 0 {
netConfig += fmt.Sprintf(",tag=%d", config.VlanTag)
}
return netConfig
}
func createProxmoxVM(config *Config) error {
fmt.Println("Creating Proxmox template...")
sshCmd := func(args ...string) *exec.Cmd {
fullArgs := []string{
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
fmt.Sprintf("%s@%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost),
}
fullArgs = append(fullArgs, args...)
return exec.Command("ssh", fullArgs...)
}
scpCmd := func(src, dst string) *exec.Cmd {
return exec.Command("scp",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
src,
fmt.Sprintf("%s@%s:%s", strings.Split(config.ProxmoxUser, "@")[0], config.ProxmoxHost, dst),
)
}
imageName := fmt.Sprintf("vm-%d-disk-0.qcow2", config.VMID)
remotePath := fmt.Sprintf("/tmp/%s", imageName)
fmt.Println("Uploading image to Proxmox host...")
cmd := scpCmd(config.ImageURL, remotePath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to upload image: %w", err)
}
fmt.Println("Creating VM and converting to template...")
commands := [][]string{
{"qm", "create", fmt.Sprintf("%d", config.VMID),
"--name", config.VMName,
"--memory", fmt.Sprintf("%d", config.Memory),
"--cores", fmt.Sprintf("%d", config.Cores),
"--net0", buildNetworkConfig(config),
},
{"qm", "importdisk", fmt.Sprintf("%d", config.VMID), remotePath, config.Storage},
{"qm", "set", fmt.Sprintf("%d", config.VMID),
"--scsihw", "virtio-scsi-pci",
"--scsi0", fmt.Sprintf("%s:vm-%d-disk-0", config.Storage, config.VMID),
},
{"qm", "set", fmt.Sprintf("%d", config.VMID),
"--ide2", fmt.Sprintf("%s:cloudinit", config.Storage),
},
{"qm", "set", fmt.Sprintf("%d", config.VMID),
"--boot", "c",
"--bootdisk", "scsi0",
},
{"qm", "set", fmt.Sprintf("%d", config.VMID),
"--serial0", "socket",
"--vga", "serial0",
},
{"qm", "template", fmt.Sprintf("%d", config.VMID)},
{"rm", "-f", remotePath},
}
for _, cmdArgs := range commands {
fmt.Printf("Running: %s\n", strings.Join(cmdArgs, " "))
cmd := sshCmd(cmdArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
fmt.Println(stdout.String())
fmt.Println(stderr.String())
return fmt.Errorf("failed to execute command '%s': %w", strings.Join(cmdArgs, " "), err)
}
if stdout.Len() > 0 {
fmt.Println(stdout.String())
}
}
fmt.Printf("\nTemplate %s (ID: %d) created successfully!\n", config.VMName, config.VMID)
fmt.Printf("You can clone it with: qm clone %d <new-vm-id> --name <new-vm-name>\n", config.VMID)
return nil
}