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 }