package main import ( "bytes" "encoding/json" "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) } if config.Firewall { netConfig += ",firewall=1" } 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...") maxRetries := 3 var uploadErr error for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { fmt.Printf("Retry upload attempt %d/%d...\n", attempt, maxRetries) } cmd := scpCmd(config.ImageURL, remotePath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr uploadErr = cmd.Run() if uploadErr == nil { fmt.Println("Upload completed! Verifying uploaded image...") verifyCmd := sshCmd("qemu-img", "check", remotePath) var verifyOut bytes.Buffer verifyCmd.Stdout = &verifyOut verifyCmd.Stderr = &verifyOut if err := verifyCmd.Run(); err != nil { fmt.Printf("Remote image verification failed: %s\n", verifyOut.String()) if attempt < maxRetries { fmt.Println("Re-uploading...") sshCmd("rm", "-f", remotePath).Run() continue } return fmt.Errorf("uploaded image verification failed after %d attempts", maxRetries) } fmt.Println("Remote image verification passed!") break } if attempt == maxRetries { return fmt.Errorf("failed to upload image after %d attempts: %w", maxRetries, uploadErr) } fmt.Printf("Upload failed: %v\n", uploadErr) } fmt.Println("Creating VM and converting to template...") // Create VM createCmd := []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), "--cpu", "host", } fmt.Printf("Running: %s\n", strings.Join(createCmd, " ")) cmd := sshCmd(createCmd...) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to create VM: %w", err) } // Import disk and capture output to get actual disk path importCmd := []string{"qm", "importdisk", fmt.Sprintf("%d", config.VMID), remotePath, config.Storage} fmt.Printf("Running: %s\n", strings.Join(importCmd, " ")) cmd = sshCmd(importCmd...) var importOut bytes.Buffer cmd.Stdout = &importOut cmd.Stderr = &importOut if err := cmd.Run(); err != nil { fmt.Println(importOut.String()) return fmt.Errorf("failed to import disk: %w", err) } output := importOut.String() fmt.Println(output) // Parse disk path from output // Example: "unused0: successfully imported disk 'local:10001/vm-10001-disk-0.raw'" var diskPath string lines := strings.Split(output, "\n") for _, line := range lines { if strings.Contains(line, "successfully imported disk") { start := strings.Index(line, "'") end := strings.LastIndex(line, "'") if start != -1 && end != -1 && end > start { diskPath = line[start+1 : end] break } } } if diskPath == "" { return fmt.Errorf("failed to parse disk path from importdisk output") } fmt.Printf("Imported disk path: %s\n", diskPath) // Configure VM with the actual disk path commands := [][]string{ {"qm", "set", fmt.Sprintf("%d", config.VMID), "--scsihw", "virtio-scsi-pci", "--scsi0", diskPath, }, {"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", "std", }, {"qm", "set", fmt.Sprintf("%d", config.VMID), "--ipconfig0", "ip=dhcp", }, } // Install Qemu Guest Agent if requested if config.GuestAgent { commands = append(commands, []string{ "qm", "set", fmt.Sprintf("%d", config.VMID), "--agent", "enabled=1", }) } // Firewall is now handled as part of the network config (buildNetworkConfig), // so no separate "qm set --firewall" command is required here. commands = append(commands, [][]string{ {"qm", "template", fmt.Sprintf("%d", config.VMID)}, {"rm", "-f", remotePath}, }...) for _, cmdArgs := range commands { fmt.Printf("Running: %s\n", strings.Join(cmdArgs, " ")) var cmdErr error for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { fmt.Printf("Retry command attempt %d/%d...\n", attempt, maxRetries) } cmd := sshCmd(cmdArgs...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr cmdErr = cmd.Run() if cmdErr == nil { if stdout.Len() > 0 { fmt.Println(stdout.String()) } break } fmt.Println(stdout.String()) fmt.Println(stderr.String()) if attempt == maxRetries { return fmt.Errorf("failed to execute command '%s' after %d attempts: %w", strings.Join(cmdArgs, " "), maxRetries, cmdErr) } fmt.Printf("Command failed: %v\n", cmdErr) } } if config.Firewall && len(config.FirewallRules) > 0 { if err := configureFirewallRules(config, sshCmd); err != nil { return fmt.Errorf("failed to configure firewall rules: %w", err) } } 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 } func configureFirewallRules(config *Config, sshCmd func(args ...string) *exec.Cmd) error { fmt.Println("Configuring firewall rules...") firewallConfig := "[OPTIONS]\nenable: 1\n\n[RULES]\n" for _, rule := range config.FirewallRules { ruleLine := fmt.Sprintf("%s %s", strings.ToUpper(rule.Type), strings.ToUpper(rule.Action)) if rule.Protocol != "" { ruleLine += fmt.Sprintf(" -p %s", rule.Protocol) } if rule.Dport != "" { ruleLine += fmt.Sprintf(" -dport %s", rule.Dport) } if rule.Sport != "" { ruleLine += fmt.Sprintf(" -sport %s", rule.Sport) } if rule.Source != "" { ruleLine += fmt.Sprintf(" -source %s", rule.Source) } if rule.Dest != "" { ruleLine += fmt.Sprintf(" -dest %s", rule.Dest) } if rule.Comment != "" { ruleLine += fmt.Sprintf(" -log nolog # %s", rule.Comment) } firewallConfig += ruleLine + "\n" } firewallPath := fmt.Sprintf("/etc/pve/firewall/%d.fw", config.VMID) createCmd := sshCmd("bash", "-c", fmt.Sprintf("cat > %s << 'EOF'\n%sEOF", firewallPath, firewallConfig)) var stdout, stderr bytes.Buffer createCmd.Stdout = &stdout createCmd.Stderr = &stderr if err := createCmd.Run(); err != nil { fmt.Println(stdout.String()) fmt.Println(stderr.String()) return fmt.Errorf("failed to create firewall config: %w", err) } fmt.Printf("Firewall rules configured: %s\n", firewallPath) return nil } func listAvailableStorage(config *Config) error { fmt.Printf("Detecting available storage on %s...\n", config.ProxmoxHost) 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("pvesm", "status", "--output-format", "json") var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("failed to get storage status: %w", err) } // Parse JSON output var storageList []map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &storageList); err != nil { return fmt.Errorf("failed to parse storage status: %w", err) } fmt.Println("\nAvailable storage:") fmt.Println("================") found := false for _, storage := range storageList { nameVal, ok := storage["storage"] if !ok { continue } name, _ := nameVal.(string) typeVal, _ := storage["type"] typeStr, _ := typeVal.(string) activeVal, _ := storage["active"] activeFloat, _ := activeVal.(float64) if activeFloat == 1 { fmt.Printf("- %s (%s) - ACTIVE\n", name, typeStr) found = true } } if !found { fmt.Println("No active storage found!") fmt.Println("\nManual check:") fmt.Println(" ssh root@your-proxmox 'pvesm status'") } return nil }