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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user