Files
recover-server/internal/providers/exoscale.go
Olaf Berberich 29a2886402 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>
2025-12-08 00:31:27 +00:00

320 lines
7.8 KiB
Go

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
}