Initial commit: Disaster recovery CLI tool
A Go-based CLI tool for recovering servers from backups to new cloud VMs. Features: - Multi-cloud support: Exoscale, Cloudscale, Hetzner Cloud - Backup sources: Local filesystem, Hetzner Storage Box - 6-stage restore pipeline with /etc whitelist protection - DNS migration with safety checks and auto-rollback - Dry-run by default, requires --yes to execute - Cloud-init for SSH key injection Packages: - cmd/recover-server: CLI commands (recover, migrate-dns, list, cleanup) - internal/providers: Cloud provider implementations - internal/backup: Backup source implementations - internal/restore: 6-stage restore pipeline - internal/dns: Exoscale DNS management - internal/ui: Prompts, progress, dry-run display - internal/config: Environment and host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
226
internal/providers/cloudscale.go
Normal file
226
internal/providers/cloudscale.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cloudscale-ch/cloudscale-go-sdk/v6"
|
||||
)
|
||||
|
||||
// CloudscaleProvider implements CloudProvider for Cloudscale.ch
|
||||
type CloudscaleProvider struct {
|
||||
client *cloudscale.Client
|
||||
}
|
||||
|
||||
// NewCloudscaleProvider creates a new Cloudscale provider
|
||||
func NewCloudscaleProvider(token string) (*CloudscaleProvider, error) {
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("cloudscale API token required")
|
||||
}
|
||||
|
||||
client := cloudscale.NewClient(http.DefaultClient)
|
||||
client.AuthToken = token
|
||||
|
||||
return &CloudscaleProvider{client: client}, nil
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) Name() string {
|
||||
return "cloudscale"
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) {
|
||||
zone := opts.Zone
|
||||
if zone == "" {
|
||||
zone = "lpg1" // Default: Lupfig
|
||||
}
|
||||
|
||||
image := opts.Image
|
||||
if image == "" {
|
||||
image = "ubuntu-24.04"
|
||||
}
|
||||
|
||||
flavor := opts.Flavor
|
||||
if flavor == "" {
|
||||
flavor = "flex-4-2" // 4 vCPU, 2GB RAM
|
||||
}
|
||||
|
||||
req := &cloudscale.ServerRequest{
|
||||
Name: opts.Name,
|
||||
Flavor: flavor,
|
||||
Image: image,
|
||||
Zone: zone,
|
||||
SSHKeys: []string{opts.SSHPublicKey},
|
||||
VolumeSizeGB: int(opts.DiskSizeGB),
|
||||
}
|
||||
|
||||
if opts.UserData != "" {
|
||||
req.UserData = opts.UserData
|
||||
}
|
||||
|
||||
server, err := p.client.Servers.Create(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
// Wait for server to be running
|
||||
for i := 0; i < 60; i++ {
|
||||
server, err = p.client.Servers.Get(ctx, server.UUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get server status: %w", err)
|
||||
}
|
||||
if server.Status == "running" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
// Get public IP
|
||||
var publicIP string
|
||||
for _, iface := range server.Interfaces {
|
||||
if iface.Type == "public" {
|
||||
for _, addr := range iface.Addresses {
|
||||
if addr.Version == 4 {
|
||||
publicIP = addr.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &VM{
|
||||
ID: server.UUID,
|
||||
Name: server.Name,
|
||||
PublicIP: publicIP,
|
||||
Status: server.Status,
|
||||
Provider: "cloudscale",
|
||||
Zone: server.Zone.Slug,
|
||||
CreatedAt: server.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) DeleteVM(ctx context.Context, vmID string) error {
|
||||
return p.client.Servers.Delete(ctx, vmID)
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) GetVM(ctx context.Context, vmID string) (*VM, error) {
|
||||
server, err := p.client.Servers.Get(ctx, vmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var publicIP string
|
||||
for _, iface := range server.Interfaces {
|
||||
if iface.Type == "public" {
|
||||
for _, addr := range iface.Addresses {
|
||||
if addr.Version == 4 {
|
||||
publicIP = addr.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &VM{
|
||||
ID: server.UUID,
|
||||
Name: server.Name,
|
||||
PublicIP: publicIP,
|
||||
Status: server.Status,
|
||||
Provider: "cloudscale",
|
||||
Zone: server.Zone.Slug,
|
||||
CreatedAt: server.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) 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 *CloudscaleProvider) ListFlavors(ctx context.Context) ([]Flavor, error) {
|
||||
// Make raw API request to /v1/flavors
|
||||
req, err := p.client.NewRequest(ctx, "GET", "v1/flavors", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
var flavors []cloudscale.Flavor
|
||||
if err := p.client.Do(ctx, req, &flavors); err != nil {
|
||||
return nil, fmt.Errorf("failed to list flavors: %w", err)
|
||||
}
|
||||
|
||||
var result []Flavor
|
||||
for _, f := range flavors {
|
||||
result = append(result, Flavor{
|
||||
ID: f.Slug,
|
||||
Name: f.Name,
|
||||
CPUs: f.VCPUCount,
|
||||
Memory: f.MemoryGB * 1024, // Convert to MB
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) ListImages(ctx context.Context, filter string) ([]Image, error) {
|
||||
// Make raw API request to /v1/images
|
||||
req, err := p.client.NewRequest(ctx, "GET", "v1/images", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
var images []cloudscale.Image
|
||||
if err := p.client.Do(ctx, req, &images); err != nil {
|
||||
return nil, fmt.Errorf("failed to list images: %w", err)
|
||||
}
|
||||
|
||||
var result []Image
|
||||
for _, img := range images {
|
||||
if filter == "" || contains(img.Slug, filter) || contains(img.Name, filter) {
|
||||
result = append(result, Image{
|
||||
ID: img.Slug,
|
||||
Name: img.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *CloudscaleProvider) ListZones(ctx context.Context) ([]string, error) {
|
||||
regions, err := p.client.Regions.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var zones []string
|
||||
for _, r := range regions {
|
||||
for _, z := range r.Zones {
|
||||
zones = append(zones, z.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
||||
319
internal/providers/exoscale.go
Normal file
319
internal/providers/exoscale.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
v3 "github.com/exoscale/egoscale/v3"
|
||||
"github.com/exoscale/egoscale/v3/credentials"
|
||||
)
|
||||
|
||||
// ExoscaleProvider implements CloudProvider for Exoscale
|
||||
type ExoscaleProvider struct {
|
||||
creds *credentials.Credentials
|
||||
}
|
||||
|
||||
// NewExoscaleProvider creates a new Exoscale provider
|
||||
func NewExoscaleProvider(apiKey, apiSecret string) (*ExoscaleProvider, error) {
|
||||
creds := credentials.NewStaticCredentials(apiKey, apiSecret)
|
||||
return &ExoscaleProvider{creds: creds}, nil
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) Name() string {
|
||||
return "exoscale"
|
||||
}
|
||||
|
||||
// getClientForZone creates a zone-specific client
|
||||
func (p *ExoscaleProvider) getClientForZone(zone string) (*v3.Client, error) {
|
||||
endpoint := p.getEndpointForZone(zone)
|
||||
return v3.NewClient(p.creds, v3.ClientOptWithEndpoint(endpoint))
|
||||
}
|
||||
|
||||
// getEndpointForZone maps zone names to API endpoints
|
||||
func (p *ExoscaleProvider) getEndpointForZone(zone string) v3.Endpoint {
|
||||
endpoints := map[string]v3.Endpoint{
|
||||
"ch-gva-2": v3.CHGva2,
|
||||
"ch-dk-2": v3.CHDk2,
|
||||
"de-fra-1": v3.DEFra1,
|
||||
"de-muc-1": v3.DEMuc1,
|
||||
"at-vie-1": v3.ATVie1,
|
||||
"at-vie-2": v3.ATVie2,
|
||||
"bg-sof-1": v3.BGSof1,
|
||||
}
|
||||
|
||||
if endpoint, ok := endpoints[zone]; ok {
|
||||
return endpoint
|
||||
}
|
||||
return v3.CHGva2 // Default
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) {
|
||||
// Get zone endpoint
|
||||
zone := opts.Zone
|
||||
if zone == "" {
|
||||
zone = "ch-gva-2" // Default zone
|
||||
}
|
||||
|
||||
// Create client for specific zone
|
||||
client, err := p.getClientForZone(zone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create zone client: %w", err)
|
||||
}
|
||||
|
||||
// Find template (image)
|
||||
templates, err := client.ListTemplates(ctx, v3.ListTemplatesWithVisibility("public"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list templates: %w", err)
|
||||
}
|
||||
|
||||
selectedTemplate, err := templates.FindTemplate(opts.Image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template not found: %s (%w)", opts.Image, err)
|
||||
}
|
||||
|
||||
// Find instance type
|
||||
instanceTypes, err := client.ListInstanceTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance types: %w", err)
|
||||
}
|
||||
|
||||
selectedType, err := instanceTypes.FindInstanceTypeByIdOrFamilyAndSize(opts.Flavor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instance type not found: %s (%w)", opts.Flavor, err)
|
||||
}
|
||||
|
||||
// Determine disk size
|
||||
diskSize := opts.DiskSizeGB
|
||||
if diskSize == 0 {
|
||||
diskSize = 50 // Default 50GB
|
||||
}
|
||||
|
||||
// Register the SSH key temporarily
|
||||
sshKeyName := fmt.Sprintf("recovery-%s-%d", opts.Name, time.Now().Unix())
|
||||
sshKeyOp, err := client.RegisterSSHKey(ctx, v3.RegisterSSHKeyRequest{
|
||||
Name: sshKeyName,
|
||||
PublicKey: opts.SSHPublicKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register SSH key: %w", err)
|
||||
}
|
||||
|
||||
// Wait for SSH key registration
|
||||
sshKeyOp, err = client.Wait(ctx, sshKeyOp, v3.OperationStateSuccess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH key registration failed: %w", err)
|
||||
}
|
||||
|
||||
// Create the instance
|
||||
createReq := v3.CreateInstanceRequest{
|
||||
Name: opts.Name,
|
||||
InstanceType: &selectedType,
|
||||
Template: &selectedTemplate,
|
||||
DiskSize: diskSize,
|
||||
SSHKey: &v3.SSHKey{Name: sshKeyName},
|
||||
}
|
||||
|
||||
// Add user data if provided
|
||||
if opts.UserData != "" {
|
||||
createReq.UserData = opts.UserData
|
||||
}
|
||||
|
||||
op, err := client.CreateInstance(ctx, createReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
// Wait for operation to complete
|
||||
op, err = client.Wait(ctx, op, v3.OperationStateSuccess)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("instance creation failed: %w", err)
|
||||
}
|
||||
|
||||
// Get the created instance
|
||||
if op.Reference == nil {
|
||||
return nil, fmt.Errorf("operation completed but no reference returned")
|
||||
}
|
||||
|
||||
instance, err := client.GetInstance(ctx, op.Reference.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get created instance: %w", err)
|
||||
}
|
||||
|
||||
// Extract public IP
|
||||
var publicIP string
|
||||
if instance.PublicIP != nil {
|
||||
publicIP = instance.PublicIP.String()
|
||||
}
|
||||
|
||||
return &VM{
|
||||
ID: string(instance.ID),
|
||||
Name: instance.Name,
|
||||
PublicIP: publicIP,
|
||||
Status: string(instance.State),
|
||||
Provider: "exoscale",
|
||||
Zone: zone,
|
||||
CreatedAt: instance.CreatedAT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) DeleteVM(ctx context.Context, vmID string) error {
|
||||
// We need to find which zone the VM is in
|
||||
zones := []string{"ch-gva-2", "ch-dk-2", "de-fra-1", "de-muc-1", "at-vie-1", "at-vie-2", "bg-sof-1"}
|
||||
|
||||
for _, zone := range zones {
|
||||
client, err := p.getClientForZone(zone)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
op, err := client.DeleteInstance(ctx, v3.UUID(vmID))
|
||||
if err != nil {
|
||||
continue // Try next zone
|
||||
}
|
||||
|
||||
_, err = client.Wait(ctx, op, v3.OperationStateSuccess)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("VM %s not found in any zone", vmID)
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) GetVM(ctx context.Context, vmID string) (*VM, error) {
|
||||
zones := []string{"ch-gva-2", "ch-dk-2", "de-fra-1", "de-muc-1", "at-vie-1", "at-vie-2", "bg-sof-1"}
|
||||
|
||||
for _, zone := range zones {
|
||||
client, err := p.getClientForZone(zone)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
instance, err := client.GetInstance(ctx, v3.UUID(vmID))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var publicIP string
|
||||
if instance.PublicIP != nil {
|
||||
publicIP = instance.PublicIP.String()
|
||||
}
|
||||
|
||||
return &VM{
|
||||
ID: string(instance.ID),
|
||||
Name: instance.Name,
|
||||
PublicIP: publicIP,
|
||||
Status: string(instance.State),
|
||||
Provider: "exoscale",
|
||||
Zone: zone,
|
||||
CreatedAt: instance.CreatedAT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("VM %s not found", vmID)
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) 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):
|
||||
// Continue trying
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("SSH not available at %s after %v", address, timeout)
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) ListFlavors(ctx context.Context) ([]Flavor, error) {
|
||||
// Use default zone for listing
|
||||
client, err := p.getClientForZone("ch-gva-2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types, err := client.ListInstanceTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instance types: %w", err)
|
||||
}
|
||||
|
||||
var flavors []Flavor
|
||||
for _, it := range types.InstanceTypes {
|
||||
flavors = append(flavors, Flavor{
|
||||
ID: string(it.ID),
|
||||
Name: string(it.Size),
|
||||
CPUs: int(it.Cpus),
|
||||
Memory: int(it.Memory),
|
||||
})
|
||||
}
|
||||
|
||||
return flavors, nil
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) ListImages(ctx context.Context, filter string) ([]Image, error) {
|
||||
client, err := p.getClientForZone("ch-gva-2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templates, err := client.ListTemplates(ctx, v3.ListTemplatesWithVisibility("public"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list templates: %w", err)
|
||||
}
|
||||
|
||||
var images []Image
|
||||
for _, tmpl := range templates.Templates {
|
||||
if filter == "" || contains(tmpl.Name, filter) {
|
||||
images = append(images, Image{
|
||||
ID: string(tmpl.ID),
|
||||
Name: tmpl.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func (p *ExoscaleProvider) ListZones(ctx context.Context) ([]string, error) {
|
||||
return []string{
|
||||
"ch-gva-2", // Geneva
|
||||
"ch-dk-2", // Zurich
|
||||
"de-fra-1", // Frankfurt
|
||||
"de-muc-1", // Munich
|
||||
"at-vie-1", // Vienna 1
|
||||
"at-vie-2", // Vienna 2
|
||||
"bg-sof-1", // Sofia
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
40
internal/providers/factory.go
Normal file
40
internal/providers/factory.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewProvider creates a cloud provider by name
|
||||
func NewProvider(name string, config map[string]string) (CloudProvider, error) {
|
||||
switch name {
|
||||
case "exoscale":
|
||||
apiKey := config["api_key"]
|
||||
apiSecret := config["api_secret"]
|
||||
if apiKey == "" || apiSecret == "" {
|
||||
return nil, fmt.Errorf("exoscale requires api_key and api_secret")
|
||||
}
|
||||
return NewExoscaleProvider(apiKey, apiSecret)
|
||||
|
||||
case "cloudscale":
|
||||
token := config["token"]
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("cloudscale requires token")
|
||||
}
|
||||
return NewCloudscaleProvider(token)
|
||||
|
||||
case "hetzner":
|
||||
token := config["token"]
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("hetzner requires token")
|
||||
}
|
||||
return NewHetznerProvider(token)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// SupportedProviders returns list of supported cloud providers
|
||||
func SupportedProviders() []string {
|
||||
return []string{"exoscale", "cloudscale", "hetzner"}
|
||||
}
|
||||
233
internal/providers/hetzner.go
Normal file
233
internal/providers/hetzner.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
96
internal/providers/provider.go
Normal file
96
internal/providers/provider.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VMOptions contains options for creating a new VM
|
||||
type VMOptions struct {
|
||||
Name string
|
||||
Zone string // e.g., "ch-gva-2", "lpg1", "fsn1"
|
||||
Flavor string // Instance type/size
|
||||
Image string // OS image name or ID
|
||||
SSHPublicKey string // Ephemeral public key content
|
||||
UserData string // Cloud-init script
|
||||
DiskSizeGB int64 // Root disk size
|
||||
Tags map[string]string // Optional tags/labels
|
||||
}
|
||||
|
||||
// VM represents a created virtual machine
|
||||
type VM struct {
|
||||
ID string
|
||||
Name string
|
||||
PublicIP string
|
||||
PrivateIP string
|
||||
Status string
|
||||
Provider string
|
||||
Zone string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Flavor represents an instance type/size
|
||||
type Flavor struct {
|
||||
ID string
|
||||
Name string
|
||||
CPUs int
|
||||
Memory int // MB
|
||||
Disk int // GB (if applicable)
|
||||
}
|
||||
|
||||
// Image represents an OS image
|
||||
type Image struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// CloudProvider defines the interface for cloud providers
|
||||
type CloudProvider interface {
|
||||
// Name returns the provider name
|
||||
Name() string
|
||||
|
||||
// CreateVM creates a new virtual machine
|
||||
CreateVM(ctx context.Context, opts VMOptions) (*VM, error)
|
||||
|
||||
// DeleteVM deletes a virtual machine by ID
|
||||
DeleteVM(ctx context.Context, vmID string) error
|
||||
|
||||
// GetVM gets VM details by ID
|
||||
GetVM(ctx context.Context, vmID string) (*VM, error)
|
||||
|
||||
// WaitForSSH waits until SSH is available on the VM
|
||||
WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error
|
||||
|
||||
// ListFlavors lists available instance types
|
||||
ListFlavors(ctx context.Context) ([]Flavor, error)
|
||||
|
||||
// ListImages lists available OS images
|
||||
ListImages(ctx context.Context, filter string) ([]Image, error)
|
||||
|
||||
// ListZones lists available zones/regions
|
||||
ListZones(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// GenerateCloudInit creates a cloud-init user-data script
|
||||
func GenerateCloudInit(ephemeralPubKey string) string {
|
||||
return `#cloud-config
|
||||
ssh_pwauth: false
|
||||
users:
|
||||
- name: root
|
||||
ssh_authorized_keys:
|
||||
- "` + ephemeralPubKey + `"
|
||||
package_update: true
|
||||
packages:
|
||||
- rsync
|
||||
- docker.io
|
||||
- docker-compose
|
||||
- wireguard-tools
|
||||
write_files:
|
||||
- path: /var/tmp/recovery-ready
|
||||
content: "ready"
|
||||
permissions: '0644'
|
||||
runcmd:
|
||||
- systemctl enable docker
|
||||
- systemctl start docker
|
||||
`
|
||||
}
|
||||
Reference in New Issue
Block a user