commit 8d057bfd94522314edc708e2fb360aff86a96776 Author: Othman Hendy Suseno Date: Fri Nov 14 20:35:06 2025 +0700 Initial commit: Proxmox cloud image template tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eaa9b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +proxmox-cloud-image +*.exe +*.dll +*.so +*.dylib +*.test +*.out +go.work diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0eaf5a --- /dev/null +++ b/README.md @@ -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 +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 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..b29b6d9 --- /dev/null +++ b/config.example.yaml @@ -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: "" \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..e84fa03 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/customize.go b/customize.go new file mode 100644 index 0000000..8675a8d --- /dev/null +++ b/customize.go @@ -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 +} diff --git a/download.go b/download.go new file mode 100644 index 0000000..8f78018 --- /dev/null +++ b/download.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a12b2b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/othman/proxmox-cloud-image + +go 1.22.2 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e349af8 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/proxmox.go b/proxmox.go new file mode 100644 index 0000000..e3344f2 --- /dev/null +++ b/proxmox.go @@ -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 --name \n", config.VMID) + + return nil +}