...
This commit is contained in:
		
							
								
								
									
										528
									
								
								pkg/builders/hetznerinstall/builder.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										528
									
								
								pkg/builders/hetznerinstall/builder.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,528 @@ | ||||
| package hetznerinstall | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"strings" | ||||
| 	"text/template" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // Struct to parse lsblk JSON output | ||||
| type lsblkOutput struct { | ||||
| 	BlockDevices []lsblkDevice `json:"blockdevices"` | ||||
| } | ||||
|  | ||||
| type lsblkDevice struct { | ||||
| 	Name string `json:"name"` | ||||
| 	Rota bool   `json:"rota"` // Rotational device (false for SSD/NVMe) | ||||
| 	Type string `json:"type"` // disk, part, lvm, etc. | ||||
| } | ||||
|  | ||||
| const installImageConfigPath = "/root/.installimage" // Standard path in Rescue System | ||||
|  | ||||
| // DefaultImage is the default OS image to install. | ||||
| const DefaultImage = "Ubuntu-2404" | ||||
|  | ||||
| // Partition represents a partition definition in the installimage config. | ||||
| type Partition struct { | ||||
| 	MountPoint string // e.g., "/", "/boot", "swap" | ||||
| 	FileSystem string // e.g., "ext4", "swap" | ||||
| 	Size       string // e.g., "512M", "all", "8G" | ||||
| } | ||||
|  | ||||
| // HetznerInstallBuilder configures and runs the Hetzner installimage process. | ||||
| type HetznerInstallBuilder struct { | ||||
| 	// Drives are now auto-detected | ||||
| 	Hostname    string      // Target hostname | ||||
| 	Image       string      // OS Image name, e.g., "Ubuntu-2404" | ||||
| 	Partitions  []Partition // Partition layout | ||||
| 	Swraid      bool        // Enable software RAID | ||||
| 	SwraidLevel int         // RAID level (0, 1, 5, 6, 10) | ||||
| 	ClearPart   bool        // Wipe disks before partitioning | ||||
| 	// Add PostInstallScript path later if needed | ||||
| 	detectedDrives []string // Stores drives detected by detectSSDDevicePaths | ||||
| } | ||||
|  | ||||
| // NewBuilder creates a new HetznerInstallBuilder with default settings. | ||||
| func NewBuilder() *HetznerInstallBuilder { | ||||
| 	return &HetznerInstallBuilder{ | ||||
| 		Image:       DefaultImage, | ||||
| 		ClearPart:   true, // Default to wiping disks | ||||
| 		Swraid:      false, | ||||
| 		SwraidLevel: 0, | ||||
| 		Partitions: []Partition{ // Default simple layout | ||||
| 			{MountPoint: "/boot", FileSystem: "ext4", Size: "512M"}, | ||||
| 			{MountPoint: "/", FileSystem: "ext4", Size: "all"}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // WithHostname sets the target hostname. | ||||
| func (b *HetznerInstallBuilder) WithHostname(hostname string) *HetznerInstallBuilder { | ||||
| 	b.Hostname = hostname | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // WithImage sets the OS image to install. | ||||
| func (b *HetznerInstallBuilder) WithImage(image string) *HetznerInstallBuilder { | ||||
| 	b.Image = image | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // WithPartitions sets the partition layout. Replaces the default. | ||||
| func (b *HetznerInstallBuilder) WithPartitions(partitions ...Partition) *HetznerInstallBuilder { | ||||
| 	if len(partitions) > 0 { | ||||
| 		b.Partitions = partitions | ||||
| 	} | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // WithSoftwareRAID enables and configures software RAID. | ||||
| func (b *HetznerInstallBuilder) WithSoftwareRAID(enable bool, level int) *HetznerInstallBuilder { | ||||
| 	b.Swraid = enable | ||||
| 	if enable { | ||||
| 		b.SwraidLevel = level | ||||
| 	} else { | ||||
| 		b.SwraidLevel = 0 // Ensure level is 0 if RAID is disabled | ||||
| 	} | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // WithClearPart enables or disables wiping disks. | ||||
| func (b *HetznerInstallBuilder) WithClearPart(clear bool) *HetznerInstallBuilder { | ||||
| 	b.ClearPart = clear | ||||
| 	return b | ||||
| } | ||||
|  | ||||
| // Validate checks if the builder configuration is valid *before* running install. | ||||
| // Note: Drive validation happens in RunInstall after auto-detection. | ||||
| func (b *HetznerInstallBuilder) Validate() error { | ||||
| 	if b.Hostname == "" { | ||||
| 		return fmt.Errorf("hostname must be specified using WithHostname()") | ||||
| 	} | ||||
| 	if b.Image == "" { | ||||
| 		return fmt.Errorf("OS image must be specified using WithImage()") | ||||
| 	} | ||||
| 	if len(b.Partitions) == 0 { | ||||
| 		return fmt.Errorf("at least one partition must be specified using WithPartitions()") | ||||
| 	} | ||||
| 	// Add more validation as needed (e.g., valid RAID levels, partition sizes) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // GenerateConfig generates the content for the installimage config file. | ||||
| func (b *HetznerInstallBuilder) GenerateConfig() (string, error) { | ||||
| 	if err := b.Validate(); err != nil { | ||||
| 		return "", fmt.Errorf("validation failed: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// Use detectedDrives for the template | ||||
| 	if len(b.detectedDrives) == 0 { | ||||
| 		// This should ideally be caught earlier in RunInstall, but double-check | ||||
| 		return "", fmt.Errorf("internal error: GenerateConfig called with no detected drives") | ||||
| 	} | ||||
|  | ||||
| 	tmplData := struct { | ||||
| 		*HetznerInstallBuilder          // Embed original builder fields | ||||
| 		Drives                 []string // Override Drives field for the template | ||||
| 	}{ | ||||
| 		HetznerInstallBuilder: b, | ||||
| 		Drives:                b.detectedDrives, | ||||
| 	} | ||||
|  | ||||
| 	tmpl := `{{range $i, $drive := .Drives}}DRIVE{{add $i 1}} {{$drive}} | ||||
| {{end}} | ||||
| SWRAID {{if .Swraid}}1{{else}}0{{end}} | ||||
| SWRAIDLEVEL {{.SwraidLevel}} | ||||
|  | ||||
| HOSTNAME {{.Hostname}} | ||||
| BOOTLOADER grub | ||||
| IMAGE {{.Image}} | ||||
|  | ||||
| {{range .Partitions}}PART {{.MountPoint}} {{.FileSystem}} {{.Size}} | ||||
| {{end}} | ||||
| # Wipe disks | ||||
| CLEARPART {{if .ClearPart}}yes{{else}}no{{end}} | ||||
| ` | ||||
| 	// Using text/template requires a function map for simple arithmetic like add | ||||
| 	funcMap := template.FuncMap{ | ||||
| 		"add": func(a, b int) int { | ||||
| 			return a + b | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	t, err := template.New("installimageConfig").Funcs(funcMap).Parse(tmpl) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("failed to parse config template: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var configContent bytes.Buffer | ||||
| 	// Execute template with the overridden Drives data | ||||
| 	if err := t.Execute(&configContent, tmplData); err != nil { | ||||
| 		return "", fmt.Errorf("failed to execute config template: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return configContent.String(), nil | ||||
| } | ||||
|  | ||||
| // detectSSDDevicePaths finds non-rotational block devices (SSDs, NVMe). | ||||
| // Assumes lsblk is available and supports JSON output. | ||||
| func detectSSDDevicePaths() ([]string, error) { | ||||
| 	fmt.Println("Attempting to detect SSD/NVMe devices using lsblk...") | ||||
| 	cmd := exec.Command("lsblk", "-J", "-o", "NAME,ROTA,TYPE") | ||||
| 	output, err := cmd.Output() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to execute lsblk: %w. Output: %s", err, string(output)) | ||||
| 	} | ||||
|  | ||||
| 	var data lsblkOutput | ||||
| 	if err := json.Unmarshal(output, &data); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to parse lsblk JSON output: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var ssdPaths []string | ||||
| 	for _, device := range data.BlockDevices { | ||||
| 		// We only care about top-level disks, not partitions | ||||
| 		if device.Type == "disk" && !device.Rota { | ||||
| 			fullPath := "/dev/" + device.Name | ||||
| 			fmt.Printf("Detected potential SSD/NVMe device: %s\n", fullPath) | ||||
| 			ssdPaths = append(ssdPaths, fullPath) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(ssdPaths) == 0 { | ||||
| 		fmt.Println("Warning: No SSD/NVMe devices detected via lsblk.") | ||||
| 		// Don't return an error here, let RunInstall decide if it's fatal | ||||
| 	} else { | ||||
| 		fmt.Printf("Detected SSD/NVMe devices: %v\n", ssdPaths) | ||||
| 	} | ||||
|  | ||||
| 	return ssdPaths, nil | ||||
| } | ||||
|  | ||||
| // findAndStopRaidArrays attempts to find and stop all active RAID arrays. | ||||
| // Uses multiple methods to ensure arrays are properly stopped. | ||||
| func findAndStopRaidArrays() error { | ||||
| 	fmt.Println("--- Attempting to find and stop active RAID arrays ---") | ||||
| 	var overallErr error | ||||
|  | ||||
| 	// Method 1: Use lsblk to find md devices | ||||
| 	fmt.Println("Method 1: Finding md devices using lsblk...") | ||||
| 	cmdLsblk := exec.Command("lsblk", "-J", "-o", "NAME,TYPE") | ||||
| 	output, err := cmdLsblk.Output() | ||||
| 	if err != nil { | ||||
| 		fmt.Fprintf(os.Stderr, "Warning: Failed to execute lsblk to find md devices: %v. Trying alternative methods.\n", err) | ||||
| 	} else { | ||||
| 		var data lsblkOutput | ||||
| 		if err := json.Unmarshal(output, &data); err != nil { | ||||
| 			fmt.Fprintf(os.Stderr, "Warning: Failed to parse lsblk JSON for md devices: %v. Trying alternative methods.\n", err) | ||||
| 		} else { | ||||
| 			for _, device := range data.BlockDevices { | ||||
| 				// Check for various RAID types lsblk might report | ||||
| 				isRaid := strings.HasPrefix(device.Type, "raid") || device.Type == "md" | ||||
| 				if strings.HasPrefix(device.Name, "md") && isRaid { | ||||
| 					mdPath := "/dev/" + device.Name | ||||
| 					fmt.Printf("Attempting to stop md device: %s\n", mdPath) | ||||
| 					// Try executing via bash -c | ||||
| 					stopCmdStr := fmt.Sprintf("mdadm --stop %s", mdPath) | ||||
| 					cmdStop := exec.Command("bash", "-c", stopCmdStr) | ||||
| 					stopOutput, stopErr := cmdStop.CombinedOutput() // Capture both stdout and stderr | ||||
| 					if stopErr != nil { | ||||
| 						fmt.Fprintf(os.Stderr, "Warning: Failed to stop %s: %v. Output: %s\n", mdPath, stopErr, string(stopOutput)) | ||||
| 						if overallErr == nil { | ||||
| 							overallErr = fmt.Errorf("failed to stop some md devices") | ||||
| 						} | ||||
| 					} else { | ||||
| 						fmt.Printf("Stopped %s successfully.\n", mdPath) | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Method 2: Use /proc/mdstat to find arrays | ||||
| 	fmt.Println("Method 2: Finding md devices using /proc/mdstat...") | ||||
| 	cmdCat := exec.Command("cat", "/proc/mdstat") | ||||
| 	mdstatOutput, mdstatErr := cmdCat.Output() | ||||
| 	if mdstatErr != nil { | ||||
| 		fmt.Fprintf(os.Stderr, "Warning: Failed to read /proc/mdstat: %v\n", mdstatErr) | ||||
| 	} else { | ||||
| 		// Parse mdstat output to find active arrays | ||||
| 		// Example line: md0 : active raid1 sda1[0] sdb1[1] | ||||
| 		lines := strings.Split(string(mdstatOutput), "\n") | ||||
| 		for _, line := range lines { | ||||
| 			if strings.Contains(line, "active") { | ||||
| 				parts := strings.Fields(line) | ||||
| 				if len(parts) >= 1 && strings.HasPrefix(parts[0], "md") { | ||||
| 					mdPath := "/dev/" + parts[0] | ||||
| 					fmt.Printf("Found active array in mdstat: %s\n", mdPath) | ||||
| 					stopCmd := exec.Command("mdadm", "--stop", mdPath) | ||||
| 					stopOutput, stopErr := stopCmd.CombinedOutput() | ||||
| 					if stopErr != nil { | ||||
| 						fmt.Fprintf(os.Stderr, "Warning: Failed to stop %s: %v. Output: %s\n", mdPath, stopErr, string(stopOutput)) | ||||
| 					} else { | ||||
| 						fmt.Printf("Stopped %s successfully.\n", mdPath) | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Method 3: Brute force attempt to stop common md devices | ||||
| 	fmt.Println("Method 3: Attempting to stop common md devices...") | ||||
| 	commonMdPaths := []string{"/dev/md0", "/dev/md1", "/dev/md2", "/dev/md3", "/dev/md127"} | ||||
| 	for _, mdPath := range commonMdPaths { | ||||
| 		fmt.Printf("Attempting to stop %s (brute force)...\n", mdPath) | ||||
| 		stopCmd := exec.Command("mdadm", "--stop", mdPath) | ||||
| 		stopOutput, _ := stopCmd.CombinedOutput() // Ignore errors, just try | ||||
| 		fmt.Printf("Output: %s\n", string(stopOutput)) | ||||
| 	} | ||||
|  | ||||
| 	// Sync to ensure changes are written | ||||
| 	syncCmd := exec.Command("sync") | ||||
| 	syncCmd.Run() // Ignore errors | ||||
|  | ||||
| 	fmt.Println("--- Finished attempting to stop RAID arrays ---") | ||||
| 	return overallErr | ||||
| } | ||||
|  | ||||
| // zeroSuperblocks attempts to zero mdadm superblocks on all given devices. | ||||
| func zeroSuperblocks(physicalDevices []string) error { | ||||
| 	fmt.Println("--- Zeroing mdadm superblocks on physical devices ---") | ||||
| 	var overallErr error | ||||
|  | ||||
| 	for _, devicePath := range physicalDevices { | ||||
| 		fmt.Printf("Executing: mdadm --zero-superblock %s\n", devicePath) | ||||
| 		// Try executing via bash -c | ||||
| 		zeroCmdStr := fmt.Sprintf("mdadm --zero-superblock %s", devicePath) | ||||
| 		cmdZero := exec.Command("bash", "-c", zeroCmdStr) | ||||
| 		zeroOutput, zeroErr := cmdZero.CombinedOutput() // Capture both stdout and stderr | ||||
| 		if zeroErr != nil { | ||||
| 			// Log error but continue | ||||
| 			fmt.Fprintf(os.Stderr, "Warning: Failed to zero superblock on %s: %v. Output: %s\n", devicePath, zeroErr, string(zeroOutput)) | ||||
| 			if overallErr == nil { | ||||
| 				overallErr = fmt.Errorf("failed to zero superblock on some devices") | ||||
| 			} | ||||
| 		} else { | ||||
| 			fmt.Printf("Zeroed superblock on %s successfully.\n", devicePath) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Sync to ensure changes are written | ||||
| 	syncCmd := exec.Command("sync") | ||||
| 	syncCmd.Run() // Ignore errors | ||||
|  | ||||
| 	fmt.Println("--- Finished zeroing superblocks ---") | ||||
| 	return overallErr | ||||
| } | ||||
|  | ||||
| // overwriteDiskStart uses dd to zero out the beginning of a disk. | ||||
| // EXTREMELY DANGEROUS. Use only when absolutely necessary to destroy metadata. | ||||
| func overwriteDiskStart(devicePath string) error { | ||||
| 	fmt.Printf("☢️☢️ EXTREME WARNING: Overwriting start of disk %s with zeros using dd!\n", devicePath) | ||||
| 	// Write 10MB of zeros. Should be enough to kill most metadata (MBR, GPT, RAID superblocks) | ||||
| 	// bs=1M count=10 | ||||
| 	ddCmdStr := fmt.Sprintf("dd if=/dev/zero of=%s bs=1M count=10 oflag=direct", devicePath) | ||||
| 	fmt.Printf("Executing: %s\n", ddCmdStr) | ||||
|  | ||||
| 	cmdDD := exec.Command("bash", "-c", ddCmdStr) | ||||
| 	ddOutput, ddErr := cmdDD.CombinedOutput() | ||||
| 	if ddErr != nil { | ||||
| 		// Log error but consider it potentially non-fatal if subsequent wipefs works | ||||
| 		fmt.Fprintf(os.Stderr, "Warning: dd command on %s failed: %v. Output: %s\n", devicePath, ddErr, string(ddOutput)) | ||||
| 		// Return the error so the caller knows something went wrong | ||||
| 		return fmt.Errorf("dd command failed on %s: %w", devicePath, ddErr) | ||||
| 	} | ||||
|  | ||||
| 	fmt.Printf("✅ Successfully overwrote start of %s with zeros.\n", devicePath) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // wipeDevice erases partition table signatures from a given device path. | ||||
| // USE WITH EXTREME CAUTION. | ||||
| func wipeDevice(devicePath string) error { | ||||
| 	fmt.Printf("⚠️ WARNING: Preparing to wipe partition signatures from device %s\n", devicePath) | ||||
| 	fmt.Printf("Executing: wipefs --all --force %s\n", devicePath) | ||||
|  | ||||
| 	cmd := exec.Command("wipefs", "--all", "--force", devicePath) | ||||
| 	cmd.Stdout = os.Stdout | ||||
| 	cmd.Stderr = os.Stderr | ||||
|  | ||||
| 	err := cmd.Run() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to wipe device %s: %w", devicePath, err) | ||||
| 	} | ||||
|  | ||||
| 	fmt.Printf("✅ Successfully wiped partition signatures from %s\n", devicePath) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // executeInstallImage attempts to execute the installimage command using multiple methods. | ||||
| // Returns the first successful execution or the last error. | ||||
| func executeInstallImage(configPath string) error { | ||||
| 	fmt.Println("--- Attempting to execute installimage using multiple methods ---") | ||||
|  | ||||
| 	// Define all the methods we'll try | ||||
| 	methods := []struct { | ||||
| 		name    string | ||||
| 		cmdArgs []string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:    "Method 1: Interactive bash shell", | ||||
| 			cmdArgs: []string{"bash", "-i", "-c", fmt.Sprintf("installimage -a -c %s", configPath)}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 2: Login bash shell", | ||||
| 			cmdArgs: []string{"bash", "-l", "-c", fmt.Sprintf("installimage -a -c %s", configPath)}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 3: Source profile first", | ||||
| 			cmdArgs: []string{"bash", "-c", fmt.Sprintf("source /etc/profile && installimage -a -c %s", configPath)}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 4: Try absolute path /usr/sbin/installimage", | ||||
| 			cmdArgs: []string{"/usr/sbin/installimage", "-a", "-c", configPath}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 5: Try absolute path /root/bin/installimage", | ||||
| 			cmdArgs: []string{"/root/bin/installimage", "-a", "-c", configPath}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 6: Try absolute path /bin/installimage", | ||||
| 			cmdArgs: []string{"/bin/installimage", "-a", "-c", configPath}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "Method 7: Try absolute path /sbin/installimage", | ||||
| 			cmdArgs: []string{"/sbin/installimage", "-a", "-c", configPath}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	var lastErr error | ||||
| 	for _, method := range methods { | ||||
| 		fmt.Printf("Trying %s\n", method.name) | ||||
| 		fmt.Printf("Executing: %s\n", strings.Join(method.cmdArgs, " ")) | ||||
|  | ||||
| 		cmd := exec.Command(method.cmdArgs[0], method.cmdArgs[1:]...) | ||||
| 		cmd.Stdout = os.Stdout | ||||
| 		cmd.Stderr = os.Stderr | ||||
|  | ||||
| 		err := cmd.Run() | ||||
| 		if err == nil { | ||||
| 			fmt.Printf("✅ Success with %s\n", method.name) | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		fmt.Printf("❌ Failed with %s: %v\n", method.name, err) | ||||
| 		lastErr = err | ||||
|  | ||||
| 		// Short pause between attempts | ||||
| 		time.Sleep(500 * time.Millisecond) | ||||
| 	} | ||||
|  | ||||
| 	fmt.Println("--- All installimage execution methods failed ---") | ||||
| 	return fmt.Errorf("all installimage execution methods failed, last error: %w", lastErr) | ||||
| } | ||||
|  | ||||
| // RunInstall detects drives if needed, wipes them, generates config, and executes installimage. | ||||
| // Assumes it's running within the Hetzner Rescue System. | ||||
| func (b *HetznerInstallBuilder) RunInstall() error { | ||||
| 	// 1. Auto-Detect Drives | ||||
| 	fmt.Println("Attempting auto-detection of SSD/NVMe drives...") | ||||
| 	detected, err := detectSSDDevicePaths() | ||||
| 	if err != nil { | ||||
| 		// Make detection failure fatal if we rely solely on it | ||||
| 		return fmt.Errorf("failed to auto-detect SSD devices: %w. Cannot proceed without target drives.", err) | ||||
| 	} | ||||
| 	if len(detected) == 0 { | ||||
| 		return fmt.Errorf("auto-detection did not find any suitable SSD/NVMe drives. Cannot proceed.") | ||||
| 	} | ||||
| 	b.detectedDrives = detected // Store detected drives | ||||
| 	fmt.Printf("Using auto-detected drives for installation: %v\n", b.detectedDrives) | ||||
|  | ||||
| 	// 2. Validate other parameters (Hostname, Image, Partitions) | ||||
| 	if err := b.Validate(); err != nil { | ||||
| 		return fmt.Errorf("pre-install validation failed: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 3. Find and stop all RAID arrays (using multiple methods) | ||||
| 	if err := findAndStopRaidArrays(); err != nil { | ||||
| 		// Log the warning but proceed, as zeroing might partially succeed | ||||
| 		fmt.Fprintf(os.Stderr, "Warning during RAID array stopping: %v. Proceeding with disk cleaning...\n", err) | ||||
| 	} | ||||
|  | ||||
| 	// 4. Zero superblocks on all detected drives | ||||
| 	if err := zeroSuperblocks(b.detectedDrives); err != nil { | ||||
| 		// Log the warning but proceed to dd/wipefs, as zeroing might partially succeed | ||||
| 		fmt.Fprintf(os.Stderr, "Warning during superblock zeroing: %v. Proceeding with dd/wipefs...\n", err) | ||||
| 	} | ||||
|  | ||||
| 	// 5. Overwrite start of disks using dd (Forceful metadata destruction) | ||||
| 	fmt.Println("--- Preparing to Overwrite Disk Starts (dd) ---") | ||||
| 	var ddFailed bool | ||||
| 	for _, drivePath := range b.detectedDrives { | ||||
| 		if err := overwriteDiskStart(drivePath); err != nil { | ||||
| 			// Log the error, mark as failed, but continue to try wipefs | ||||
| 			fmt.Fprintf(os.Stderr, "ERROR during dd on %s: %v. Will still attempt wipefs.\n", drivePath, err) | ||||
| 			ddFailed = true // If dd fails, we rely heavily on wipefs | ||||
| 		} | ||||
| 	} | ||||
| 	fmt.Println("--- Finished Overwriting Disk Starts (dd) ---") | ||||
| 	// Sync filesystem buffers to disk | ||||
| 	fmt.Println("Syncing after dd...") | ||||
| 	syncCmdDD := exec.Command("sync") | ||||
| 	if syncErr := syncCmdDD.Run(); syncErr != nil { | ||||
| 		fmt.Fprintf(os.Stderr, "Warning: sync after dd failed: %v\n", syncErr) | ||||
| 	} | ||||
|  | ||||
| 	// 6. Wipe Target Drives (Partition Signatures) using wipefs (as a fallback/cleanup) | ||||
| 	fmt.Println("--- Preparing to Wipe Target Devices (wipefs) ---") | ||||
| 	for _, drivePath := range b.detectedDrives { // Use detectedDrives | ||||
| 		if err := wipeDevice(drivePath); err != nil { | ||||
| 			// If dd also failed, this wipefs failure is critical. Otherwise, maybe okay. | ||||
| 			if ddFailed { | ||||
| 				return fmt.Errorf("CRITICAL: dd failed AND wipefs failed on %s: %w. Aborting installation.", drivePath, err) | ||||
| 			} else { | ||||
| 				fmt.Fprintf(os.Stderr, "Warning: wipefs failed on %s after dd succeeded: %v. Proceeding cautiously.\n", drivePath, err) | ||||
| 				// Allow proceeding if dd succeeded, but log prominently. | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	fmt.Println("--- Finished Wiping Target Devices (wipefs) ---") | ||||
| 	// Sync filesystem buffers to disk again | ||||
| 	fmt.Println("Syncing after wipefs...") | ||||
| 	syncCmdWipe := exec.Command("sync") | ||||
| 	if syncErr := syncCmdWipe.Run(); syncErr != nil { | ||||
| 		fmt.Fprintf(os.Stderr, "Warning: sync after wipefs failed: %v\n", syncErr) | ||||
| 	} | ||||
|  | ||||
| 	// 7. Generate installimage Config (using detectedDrives) | ||||
| 	fmt.Println("Generating installimage configuration...") | ||||
| 	configContent, err := b.GenerateConfig() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to generate config: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 8. Write Config File | ||||
| 	fmt.Printf("Writing configuration to %s...\n", installImageConfigPath) | ||||
| 	fmt.Printf("--- Config Content ---\n%s\n----------------------\n", configContent) // Log the config | ||||
| 	err = os.WriteFile(installImageConfigPath, []byte(configContent), 0600)           // Secure permissions | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to write config file %s: %w", installImageConfigPath, err) | ||||
| 	} | ||||
| 	fmt.Printf("Successfully wrote configuration to %s\n", installImageConfigPath) | ||||
|  | ||||
| 	// 9. Execute installimage using multiple methods | ||||
| 	err = executeInstallImage(installImageConfigPath) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("installimage execution failed: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// If installimage succeeds, it usually triggers a reboot. | ||||
| 	// This part of the code might not be reached in a typical successful run. | ||||
| 	fmt.Println("installimage command finished. System should reboot shortly if successful.") | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										25
									
								
								pkg/builders/hetznerinstall/cmd/build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										25
									
								
								pkg/builders/hetznerinstall/cmd/build.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| #!/bin/bash | ||||
| set -e | ||||
|  | ||||
| # Change to the script's directory to ensure relative paths work | ||||
| cd "$(dirname "$0")" | ||||
|  | ||||
| echo "Building Hetzner Installer for Linux on AMD64..." | ||||
|  | ||||
| # Create build directory if it doesn't exist | ||||
| mkdir -p build | ||||
|  | ||||
| # Build the Hetzner installer binary | ||||
| echo "Building Hetzner installer..." | ||||
| CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ | ||||
|   -ldflags="-s -w" \ | ||||
|   -trimpath \ | ||||
|   -o build/hetzner_installer \ | ||||
|   main.go # Reference main.go in the current directory | ||||
|  | ||||
| # Set executable permissions | ||||
| chmod +x build/hetzner_installer | ||||
|  | ||||
| # Output binary info | ||||
| echo "Build complete!" | ||||
| ls -lh build/ | ||||
							
								
								
									
										53
									
								
								pkg/builders/hetznerinstall/cmd/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								pkg/builders/hetznerinstall/cmd/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
|  | ||||
| 	"github.com/freeflowuniverse/heroagent/pkg/system/builders/hetznerinstall" | ||||
| ) | ||||
|  | ||||
| func main() { | ||||
| 	// Define command-line flags | ||||
| 	hostname := flag.String("hostname", "", "Target hostname for the server (required)") | ||||
| 	image := flag.String("image", hetznerinstall.DefaultImage, "OS image to install (e.g., Ubuntu-2404)") | ||||
|  | ||||
| 	flag.Parse() | ||||
|  | ||||
| 	// Validate required flags | ||||
| 	if *hostname == "" { | ||||
| 		fmt.Fprintln(os.Stderr, "Error: -hostname flag is required.") | ||||
| 		flag.Usage() | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 	// Drives are now always auto-detected by the builder | ||||
|  | ||||
| 	// Create a new HetznerInstall builder | ||||
| 	builder := hetznerinstall.NewBuilder(). | ||||
| 		WithHostname(*hostname). | ||||
| 		WithImage(*image) | ||||
|  | ||||
| 	// Example: Add custom partitions (optional, overrides default) | ||||
| 	// builder.WithPartitions( | ||||
| 	// 	hetznerinstall.Partition{MountPoint: "/boot", FileSystem: "ext4", Size: "1G"}, | ||||
| 	// 	hetznerinstall.Partition{MountPoint: "swap", FileSystem: "swap", Size: "4G"}, | ||||
| 	// 	hetznerinstall.Partition{MountPoint: "/", FileSystem: "ext4", Size: "all"}, | ||||
| 	// ) | ||||
|  | ||||
| 	// Example: Enable Software RAID 1 (optional) | ||||
| 	// builder.WithSoftwareRAID(true, 1) | ||||
|  | ||||
| 	// Run the Hetzner installation process | ||||
| 	// The builder will handle drive detection/validation internally if drives were not set | ||||
| 	fmt.Printf("Starting Hetzner installation for hostname %s using image %s...\n", | ||||
| 		*hostname, *image) | ||||
| 	if err := builder.RunInstall(); err != nil { | ||||
| 		fmt.Fprintf(os.Stderr, "Error during Hetzner installation: %v\n", err) | ||||
| 		os.Exit(1) // Ensure we exit with non-zero status on error | ||||
| 	} | ||||
|  | ||||
| 	// Note: If RunInstall succeeds, the system typically reboots, | ||||
| 	// so this message might not always be seen. | ||||
| 	fmt.Println("Hetzner installation process initiated successfully!") | ||||
| } | ||||
							
								
								
									
										134
									
								
								pkg/builders/hetznerinstall/cmd/run.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										134
									
								
								pkg/builders/hetznerinstall/cmd/run.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| #!/bin/bash | ||||
| set -e # Exit immediately if a command exits with a non-zero status. | ||||
|  | ||||
| # --- Configuration --- | ||||
| # Required Environment Variables: | ||||
| # SERVER:   IPv4 or IPv6 address of the target Hetzner server (already in Rescue Mode). | ||||
| # HOSTNAME: The desired hostname for the installed system. | ||||
| # Drives are now always auto-detected by the installer binary. | ||||
|  | ||||
| LOG_FILE="hetzner_install_$(date +%Y%m%d_%H%M%S).log" | ||||
| REMOTE_USER="root" # Hetzner Rescue Mode typically uses root | ||||
| REMOTE_DIR="/tmp/hetzner_installer_$$" # Temporary directory on the remote server | ||||
| BINARY_NAME="hetzner_installer" | ||||
| BUILD_DIR="build" | ||||
|  | ||||
| # --- Helper Functions --- | ||||
| log() { | ||||
|     local timestamp=$(date +"%Y-%m-%d %H:%M:%S") | ||||
|     echo "[$timestamp] $1" | tee -a "$LOG_FILE" | ||||
| } | ||||
|  | ||||
| cleanup_remote() { | ||||
|     if [ -n "$SERVER" ]; then | ||||
|         log "Cleaning up remote directory $REMOTE_DIR on $SERVER..." | ||||
|         ssh "$REMOTE_USER@$SERVER" "rm -rf $REMOTE_DIR" || log "Warning: Failed to clean up remote directory (might be okay if server rebooted)." | ||||
|     fi | ||||
| } | ||||
|  | ||||
| # --- Main Script --- | ||||
| cd "$(dirname "$0")" | ||||
|  | ||||
| log "=== Starting Hetzner Installimage Deployment ===" | ||||
| log "Log file: $LOG_FILE" | ||||
| log "IMPORTANT: Ensure the target server ($SERVER) is booted into Hetzner Rescue Mode!" | ||||
|  | ||||
| # Check required environment variables | ||||
| if [ -z "$SERVER" ]; then | ||||
|     log "❌ ERROR: SERVER environment variable is not set." | ||||
|     log "Please set it to the IP address of the target server (in Rescue Mode)." | ||||
|     exit 1 | ||||
| fi | ||||
| if [ -z "$HOSTNAME" ]; then | ||||
|     log "❌ ERROR: HOSTNAME environment variable is not set." | ||||
| 	log "Please set it to the desired hostname for the installed system." | ||||
| 	exit 1 | ||||
| fi | ||||
| # Drives are auto-detected by the binary. | ||||
|  | ||||
| # Validate SERVER IP (basic check) | ||||
| if ! [[ "$SERVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && \ | ||||
|    ! [[ "$SERVER" =~ ^[0-9a-fA-F:]+$ ]]; then | ||||
|     log "❌ ERROR: SERVER ($SERVER) does not look like a valid IPv4 or IPv6 address." | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| log "Target Server: $SERVER" | ||||
| log "Target Hostname: $HOSTNAME" | ||||
| log "Target Drives: Auto-detected by the installer." | ||||
|  | ||||
| # Build the Hetzner installer binary | ||||
| log "Building $BINARY_NAME binary..." | ||||
| ./build.sh | tee -a "$LOG_FILE" | ||||
|  | ||||
| # Check if binary exists | ||||
| BINARY_PATH="$BUILD_DIR/$BINARY_NAME" | ||||
| if [ ! -f "$BINARY_PATH" ]; then | ||||
|     log "❌ ERROR: $BINARY_NAME binary not found at $BINARY_PATH after build." | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| log "Binary size:" | ||||
| ls -lh "$BINARY_PATH" | tee -a "$LOG_FILE" | ||||
|  | ||||
| # Set up trap for cleanup | ||||
| trap cleanup_remote EXIT | ||||
|  | ||||
| # Create deployment directory on server | ||||
| log "Creating temporary directory $REMOTE_DIR on server..." | ||||
| # Use -t to force pseudo-terminal allocation for mkdir (less critical but consistent) | ||||
| ssh -t "$REMOTE_USER@$SERVER" "mkdir -p $REMOTE_DIR" 2>&1 | tee -a "$LOG_FILE" | ||||
| if [ $? -ne 0 ]; then | ||||
|     log "❌ ERROR: Failed to create remote directory $REMOTE_DIR on $SERVER." | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| # Transfer the binary to the server | ||||
| log "Transferring $BINARY_NAME binary to $SERVER:$REMOTE_DIR/ ..." | ||||
| rsync -avz --progress "$BINARY_PATH" "$REMOTE_USER@$SERVER:$REMOTE_DIR/" 2>&1 | tee -a "$LOG_FILE" | ||||
| if [ $? -ne 0 ]; then | ||||
|     log "❌ ERROR: Failed to transfer binary to $SERVER." | ||||
|     exit 1 | ||||
| fi | ||||
|  | ||||
| # Ensure binary is executable on the server | ||||
| log "Setting permissions on server..." | ||||
| # Use -t | ||||
| ssh -t "$REMOTE_USER@$SERVER" "chmod +x $REMOTE_DIR/$BINARY_NAME" 2>&1 | tee -a "$LOG_FILE" || { log "❌ ERROR: Failed to set permissions on remote binary."; exit 1; } | ||||
| # Use -t | ||||
| ssh -t "$REMOTE_USER@$SERVER" "ls -la $REMOTE_DIR/" 2>&1 | tee -a "$LOG_FILE" | ||||
|  | ||||
| # Construct remote command arguments (only hostname needed now) | ||||
| # Note: The binary expects -hostname | ||||
| REMOTE_CMD_ARGS="-hostname \"$HOSTNAME\"" | ||||
|  | ||||
| # Run the Hetzner installer (Go binary) on the server | ||||
| log "Running Go installer binary $BINARY_NAME on server $SERVER..." | ||||
| REMOTE_FULL_CMD="cd $REMOTE_DIR && ./$BINARY_NAME $REMOTE_CMD_ARGS" | ||||
| log "Command: $REMOTE_FULL_CMD" | ||||
|  | ||||
| # Execute the command and capture output. Use -t for better output. | ||||
| INSTALL_OUTPUT=$(ssh -t "$REMOTE_USER@$SERVER" "$REMOTE_FULL_CMD" 2>&1) | ||||
| INSTALL_EXIT_CODE=$? | ||||
|  | ||||
| log "--- Go Installer Binary Output ---" | ||||
| echo "$INSTALL_OUTPUT" | tee -a "$LOG_FILE" | ||||
| log "--- End Go Installer Binary Output ---" | ||||
| log "Go installer binary exit code: $INSTALL_EXIT_CODE" | ||||
|  | ||||
| # Analyze results - relies on Go binary output now | ||||
| if [[ "$INSTALL_OUTPUT" == *"installimage command finished. System should reboot shortly if successful."* ]]; then | ||||
|     log "✅ SUCCESS: Go installer reported successful initiation. The server should be rebooting into the new OS." | ||||
|     log "Verification of the installed OS must be done manually after reboot." | ||||
| elif [[ "$INSTALL_OUTPUT" == *"Error during Hetzner installation"* || $INSTALL_EXIT_CODE -ne 0 ]]; then | ||||
|     log "❌ ERROR: Go installer reported an error or exited with code $INSTALL_EXIT_CODE." | ||||
|     log "Check the output above for details. Common issues include installimage errors or config problems." | ||||
|     # Don't exit immediately, allow cleanup trap to run | ||||
| else | ||||
|     # This might happen if the SSH connection is abruptly closed by the reboot during installimage | ||||
|     log "⚠️ WARNING: The Go installer finished with exit code $INSTALL_EXIT_CODE, but the output might be incomplete due to server reboot." | ||||
|     log "Assuming the installimage process was initiated. Manual verification is required after reboot." | ||||
| fi | ||||
|  | ||||
| log "=== Hetzner Installimage Deployment Script Finished ===" | ||||
| # Cleanup trap will run on exit | ||||
		Reference in New Issue
	
	Block a user