package providers import ( "context" "fmt" "net" "time" "github.com/hetznercloud/hcloud-go/v2/hcloud" ) // HetznerProvider implements CloudProvider for Hetzner Cloud type HetznerProvider struct { client *hcloud.Client } // NewHetznerProvider creates a new Hetzner provider func NewHetznerProvider(token string) (*HetznerProvider, error) { if token == "" { return nil, fmt.Errorf("hetzner API token required") } client := hcloud.NewClient(hcloud.WithToken(token)) return &HetznerProvider{client: client}, nil } func (p *HetznerProvider) Name() string { return "hetzner" } func (p *HetznerProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) { // Parse zone/location location := opts.Zone if location == "" { location = "fsn1" // Default: Falkenstein } image := opts.Image if image == "" { image = "ubuntu-24.04" } serverType := opts.Flavor if serverType == "" { serverType = "cx22" // 2 vCPU, 4GB RAM } // Register SSH key if provided var sshKeys []*hcloud.SSHKey if opts.SSHPublicKey != "" { keyName := fmt.Sprintf("recovery-%s-%d", opts.Name, time.Now().Unix()) sshKey, _, err := p.client.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{ Name: keyName, PublicKey: opts.SSHPublicKey, }) if err != nil { return nil, fmt.Errorf("failed to register SSH key: %w", err) } sshKeys = append(sshKeys, sshKey) } // Create server createOpts := hcloud.ServerCreateOpts{ Name: opts.Name, ServerType: &hcloud.ServerType{Name: serverType}, Image: &hcloud.Image{Name: image}, Location: &hcloud.Location{Name: location}, SSHKeys: sshKeys, } if opts.UserData != "" { createOpts.UserData = opts.UserData } result, _, err := p.client.Server.Create(ctx, createOpts) if err != nil { return nil, fmt.Errorf("failed to create server: %w", err) } // Wait for server to be running server := result.Server for i := 0; i < 60; i++ { server, _, err = p.client.Server.GetByID(ctx, server.ID) if err != nil { return nil, fmt.Errorf("failed to get server status: %w", err) } if server.Status == hcloud.ServerStatusRunning { break } time.Sleep(5 * time.Second) } var publicIP string if server.PublicNet.IPv4.IP != nil { publicIP = server.PublicNet.IPv4.IP.String() } return &VM{ ID: fmt.Sprintf("%d", server.ID), Name: server.Name, PublicIP: publicIP, Status: string(server.Status), Provider: "hetzner", Zone: server.Datacenter.Location.Name, CreatedAt: server.Created, }, nil } func (p *HetznerProvider) DeleteVM(ctx context.Context, vmID string) error { var id int64 fmt.Sscanf(vmID, "%d", &id) server, _, err := p.client.Server.GetByID(ctx, id) if err != nil { return err } if server == nil { return fmt.Errorf("server not found: %s", vmID) } _, _, err = p.client.Server.DeleteWithResult(ctx, server) return err } func (p *HetznerProvider) GetVM(ctx context.Context, vmID string) (*VM, error) { var id int64 fmt.Sscanf(vmID, "%d", &id) server, _, err := p.client.Server.GetByID(ctx, id) if err != nil { return nil, err } if server == nil { return nil, fmt.Errorf("server not found: %s", vmID) } var publicIP string if server.PublicNet.IPv4.IP != nil { publicIP = server.PublicNet.IPv4.IP.String() } return &VM{ ID: fmt.Sprintf("%d", server.ID), Name: server.Name, PublicIP: publicIP, Status: string(server.Status), Provider: "hetzner", Zone: server.Datacenter.Location.Name, CreatedAt: server.Created, }, nil } func (p *HetznerProvider) WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error { if port == 0 { port = 22 } deadline := time.Now().Add(timeout) address := fmt.Sprintf("%s:%d", ip, port) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", address, 5*time.Second) if err == nil { conn.Close() return nil } select { case <-ctx.Done(): return ctx.Err() case <-time.After(5 * time.Second): } } return fmt.Errorf("SSH not available at %s after %v", address, timeout) } func (p *HetznerProvider) ListFlavors(ctx context.Context) ([]Flavor, error) { types, err := p.client.ServerType.All(ctx) if err != nil { return nil, err } var result []Flavor for _, t := range types { result = append(result, Flavor{ ID: t.Name, Name: t.Description, CPUs: t.Cores, Memory: int(t.Memory * 1024), // Convert GB to MB Disk: t.Disk, }) } return result, nil } func (p *HetznerProvider) ListImages(ctx context.Context, filter string) ([]Image, error) { images, err := p.client.Image.All(ctx) if err != nil { return nil, err } var result []Image for _, img := range images { if img.Type != hcloud.ImageTypeSystem { continue // Only show system images } if filter == "" || contains(img.Name, filter) || contains(img.Description, filter) { result = append(result, Image{ ID: img.Name, Name: img.Description, }) } } return result, nil } func (p *HetznerProvider) ListZones(ctx context.Context) ([]string, error) { locations, err := p.client.Location.All(ctx) if err != nil { return nil, err } var zones []string for _, l := range locations { zones = append(zones, l.Name) } return zones, nil }