diff --git a/internal/lambdalabs/v1/capabilities.go b/internal/lambdalabs/v1/capabilities.go index 7025cd81..ba348ab5 100644 --- a/internal/lambdalabs/v1/capabilities.go +++ b/internal/lambdalabs/v1/capabilities.go @@ -6,10 +6,10 @@ import ( v1 "github.com/brevdev/compute/pkg/v1" ) -// GetCapabilities returns the capabilities of Lambda Labs +// getLambdaLabsCapabilities returns the unified capabilities for Lambda Labs // Based on API documentation at https://cloud.lambda.ai/api/v1/openapi.json -func (c *LambdaLabsClient) GetCapabilities(_ context.Context) (v1.Capabilities, error) { - capabilities := v1.Capabilities{ +func getLambdaLabsCapabilities() v1.Capabilities { + return v1.Capabilities{ // SUPPORTED FEATURES (with API evidence): // Instance Management @@ -18,15 +18,21 @@ func (c *LambdaLabsClient) GetCapabilities(_ context.Context) (v1.Capabilities, v1.CapabilityCreateTerminateInstance, // Combined create/terminate capability v1.CapabilityRebootInstance, // POST /api/v1/instance-operations/restart - // Firewall Management - v1.CapabilityModifyFirewall, // Firewall rulesets API available - // UNSUPPORTED FEATURES (no API evidence found): + // - v1.CapabilityModifyFirewall // Firewall management is project-level, not instance-level // - v1.CapabilityStopStartInstance // No stop/start endpoints // - v1.CapabilityResizeInstanceVolume // No volume resizing endpoints // - v1.CapabilityMachineImage // No image endpoints // - v1.CapabilityTags // No tagging endpoints } +} + +// GetCapabilities returns the capabilities of Lambda Labs client +func (c *LambdaLabsClient) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getLambdaLabsCapabilities(), nil +} - return capabilities, nil +// GetCapabilities returns the capabilities for Lambda Labs credential +func (c *LambdaLabsCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getLambdaLabsCapabilities(), nil } diff --git a/internal/lambdalabs/v1/client.go b/internal/lambdalabs/v1/client.go index fa99cad6..7f10897d 100644 --- a/internal/lambdalabs/v1/client.go +++ b/internal/lambdalabs/v1/client.go @@ -2,25 +2,78 @@ package v1 import ( "context" + "crypto/sha256" + "fmt" + "net/http" + openapi "github.com/brevdev/cloud/internal/lambdalabs/gen/lambdalabs" v1 "github.com/brevdev/compute/pkg/v1" ) +// LambdaLabsCredential implements the CloudCredential interface for Lambda Labs +type LambdaLabsCredential struct { + RefID string + APIKey string +} + +var _ v1.CloudCredential = &LambdaLabsCredential{} + +// NewLambdaLabsCredential creates a new Lambda Labs credential +func NewLambdaLabsCredential(refID, apiKey string) *LambdaLabsCredential { + return &LambdaLabsCredential{ + RefID: refID, + APIKey: apiKey, + } +} + +// GetReferenceID returns the reference ID for this credential +func (c *LambdaLabsCredential) GetReferenceID() string { + return c.RefID +} + +// GetAPIType returns the API type for Lambda Labs +func (c *LambdaLabsCredential) GetAPIType() v1.APIType { + return v1.APITypeGlobal +} + +// GetCloudProviderID returns the cloud provider ID for Lambda Labs +func (c *LambdaLabsCredential) GetCloudProviderID() v1.CloudProviderID { + return "lambdalabs" +} + +// GetTenantID returns the tenant ID for Lambda Labs +func (c *LambdaLabsCredential) GetTenantID() (string, error) { + return fmt.Sprintf("lambdalabs-%x", sha256.Sum256([]byte(c.APIKey))), nil +} + +// MakeClient creates a new Lambda Labs client from this credential +func (c *LambdaLabsCredential) MakeClient(_ context.Context, _ string) (v1.CloudClient, error) { + return NewLambdaLabsClient(c.RefID, c.APIKey), nil +} + // LambdaLabsClient implements the CloudClient interface for Lambda Labs // It embeds NotImplCloudClient to handle unsupported features type LambdaLabsClient struct { v1.NotImplCloudClient + refID string apiKey string baseURL string + client *openapi.APIClient } var _ v1.CloudClient = &LambdaLabsClient{} // NewLambdaLabsClient creates a new Lambda Labs client -func NewLambdaLabsClient(apiKey string) *LambdaLabsClient { +func NewLambdaLabsClient(refID, apiKey string) *LambdaLabsClient { + config := openapi.NewConfiguration() + config.HTTPClient = http.DefaultClient + client := openapi.NewAPIClient(config) + return &LambdaLabsClient{ + refID: refID, apiKey: apiKey, baseURL: "https://cloud.lambda.ai/api/v1", + client: client, } } @@ -49,5 +102,11 @@ func (c *LambdaLabsClient) GetTenantID() (string, error) { // GetReferenceID returns the reference ID for this client func (c *LambdaLabsClient) GetReferenceID() string { - return "lambdalabs-client" + return c.refID +} + +func (c *LambdaLabsClient) makeAuthContext(ctx context.Context) context.Context { + return context.WithValue(ctx, openapi.ContextBasicAuth, openapi.BasicAuth{ + UserName: c.apiKey, + }) } diff --git a/internal/lambdalabs/v1/instance.go b/internal/lambdalabs/v1/instance.go index 64dda9bd..9ecc05f7 100644 --- a/internal/lambdalabs/v1/instance.go +++ b/internal/lambdalabs/v1/instance.go @@ -3,74 +3,223 @@ package v1 import ( "context" "fmt" + "strings" "time" + openapi "github.com/brevdev/cloud/internal/lambdalabs/gen/lambdalabs" v1 "github.com/brevdev/compute/pkg/v1" ) +const lambdaLabsTimeNameFormat = "2006-01-02-15-04-05Z07-00" + // CreateInstance creates a new instance in Lambda Labs // Supported via: POST /api/v1/instance-operations/launch -func (c *LambdaLabsClient) CreateInstance(_ context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { - // TODO: Implement Lambda Labs instance creation - // This would typically involve: - // 1. Validating the instance type and location - // 2. Creating the instance via Lambda Labs API - // 3. Waiting for the instance to be ready - // 4. Returning the instance details +func (c *LambdaLabsClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { + keyPairName := attrs.RefID + if attrs.KeyPairName != nil { + keyPairName = *attrs.KeyPairName + } - return &v1.Instance{ - Name: attrs.Name, - RefID: attrs.RefID, - CreatedAt: time.Now(), - CloudID: v1.CloudProviderInstanceID("lambda-instance-id"), // TODO: Get from API response - Location: attrs.Location, - SubLocation: attrs.SubLocation, - InstanceType: attrs.InstanceType, - ImageID: attrs.ImageID, - DiskSize: attrs.DiskSize, - Status: v1.Status{ - LifecycleStatus: v1.LifecycleStatusRunning, - }, - Tags: attrs.Tags, - }, nil + if attrs.PublicKey != "" { + request := openapi.AddSSHKeyRequest{ + Name: keyPairName, + PublicKey: &attrs.PublicKey, + } + + _, resp, err := c.client.DefaultAPI.AddSSHKey(c.makeAuthContext(ctx)).AddSSHKeyRequest(request).Execute() + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil && !strings.Contains(err.Error(), "name must be unique") { + return nil, fmt.Errorf("failed to add SSH key: %w", err) + } + } + + location := attrs.Location + if location == "" { + location = "us-west-1" + } + + quantity := int32(1) + request := openapi.LaunchInstanceRequest{ + RegionName: location, + InstanceTypeName: attrs.InstanceType, + SshKeyNames: []string{keyPairName}, + Quantity: &quantity, + FileSystemNames: []string{}, + } + + name := fmt.Sprintf("%s--%s", c.GetReferenceID(), time.Now().UTC().Format(lambdaLabsTimeNameFormat)) + if attrs.Name != "" { + name = fmt.Sprintf("%s--%s--%s", c.GetReferenceID(), attrs.Name, time.Now().UTC().Format(lambdaLabsTimeNameFormat)) + } + request.Name = *openapi.NewNullableString(&name) + + resp, httpResp, err := c.client.DefaultAPI.LaunchInstance(c.makeAuthContext(ctx)).LaunchInstanceRequest(request).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return nil, fmt.Errorf("failed to launch instance: %w", err) + } + + if len(resp.Data.InstanceIds) != 1 { + return nil, fmt.Errorf("expected 1 instance ID, got %d", len(resp.Data.InstanceIds)) + } + + instanceID := v1.CloudProviderInstanceID(resp.Data.InstanceIds[0]) + return c.GetInstance(ctx, instanceID) } // GetInstance retrieves an instance by ID // Supported via: GET /api/v1/instances/{id} -func (c *LambdaLabsClient) GetInstance(_ context.Context, _ v1.CloudProviderInstanceID) (*v1.Instance, error) { - // TODO: Implement Lambda Labs instance retrieval - return nil, fmt.Errorf("not implemented") +func (c *LambdaLabsClient) GetInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) (*v1.Instance, error) { + resp, httpResp, err := c.client.DefaultAPI.GetInstance(c.makeAuthContext(ctx), string(instanceID)).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return nil, fmt.Errorf("failed to get instance: %w", err) + } + + return convertLambdaLabsInstanceToV1Instance(resp.Data), nil } // TerminateInstance terminates an instance // Supported via: POST /api/v1/instance-operations/terminate -func (c *LambdaLabsClient) TerminateInstance(_ context.Context, _ v1.CloudProviderInstanceID) error { - // TODO: Implement Lambda Labs instance termination - return fmt.Errorf("not implemented") +func (c *LambdaLabsClient) TerminateInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) error { + request := openapi.TerminateInstanceRequest{ + InstanceIds: []string{string(instanceID)}, + } + + _, httpResp, err := c.client.DefaultAPI.TerminateInstance(c.makeAuthContext(ctx)).TerminateInstanceRequest(request).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return fmt.Errorf("failed to terminate instance: %w", err) + } + + return nil } // ListInstances lists all instances // Supported via: GET /api/v1/instances -func (c *LambdaLabsClient) ListInstances(_ context.Context, _ v1.ListInstancesArgs) ([]v1.Instance, error) { - // TODO: Implement Lambda Labs instance listing - return nil, fmt.Errorf("not implemented") +func (c *LambdaLabsClient) ListInstances(ctx context.Context, _ v1.ListInstancesArgs) ([]v1.Instance, error) { + resp, httpResp, err := c.client.DefaultAPI.ListInstances(c.makeAuthContext(ctx)).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return nil, fmt.Errorf("failed to list instances: %w", err) + } + + instances := make([]v1.Instance, 0, len(resp.Data)) + for _, llInstance := range resp.Data { + instance := convertLambdaLabsInstanceToV1Instance(llInstance) + instances = append(instances, *instance) + } + + return instances, nil } // RebootInstance reboots an instance // Supported via: POST /api/v1/instance-operations/restart -func (c *LambdaLabsClient) RebootInstance(_ context.Context, _ v1.CloudProviderInstanceID) error { - // TODO: Implement Lambda Labs instance rebooting - return fmt.Errorf("not implemented") +func (c *LambdaLabsClient) RebootInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) error { + request := openapi.RestartInstanceRequest{ + InstanceIds: []string{string(instanceID)}, + } + + _, httpResp, err := c.client.DefaultAPI.RestartInstance(c.makeAuthContext(ctx)).RestartInstanceRequest(request).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return fmt.Errorf("failed to reboot instance: %w", err) + } + + return nil } // MergeInstanceForUpdate merges instance data for updates +func convertLambdaLabsInstanceToV1Instance(llInstance openapi.Instance) *v1.Instance { + var publicIP, privateIP, hostname, name string + + if llInstance.Ip.IsSet() { + publicIP = *llInstance.Ip.Get() + } + if llInstance.PrivateIp.IsSet() { + privateIP = *llInstance.PrivateIp.Get() + } + if llInstance.Hostname.IsSet() { + hostname = *llInstance.Hostname.Get() + } + if llInstance.Name.IsSet() { + name = *llInstance.Name.Get() + } + + var cloudCredRefID string + var createdAt time.Time + if name != "" { + parts := strings.Split(name, "--") + if len(parts) > 0 { + cloudCredRefID = parts[0] + } + if len(parts) > 1 { + createdAt, _ = time.Parse("2006-01-02-15-04-05Z07-00", parts[1]) + } + } + + refID := "" + if len(llInstance.SshKeyNames) > 0 { + refID = llInstance.SshKeyNames[0] + } + + return &v1.Instance{ + Name: name, + RefID: refID, + CloudCredRefID: cloudCredRefID, + CreatedAt: createdAt, + CloudID: v1.CloudProviderInstanceID(llInstance.Id), + PublicIP: publicIP, + PrivateIP: privateIP, + PublicDNS: publicIP, + Hostname: hostname, + InstanceType: llInstance.InstanceType.Name, + Status: v1.Status{ + LifecycleStatus: convertLambdaLabsStatusToV1Status(llInstance.Status), + }, + Location: llInstance.Region.Name, + SSHUser: "ubuntu", + SSHPort: 22, + Stoppable: false, + Rebootable: true, + } +} + +func convertLambdaLabsStatusToV1Status(status string) v1.LifecycleStatus { + switch status { + case "booting": + return v1.LifecycleStatusPending + case "active": + return v1.LifecycleStatusRunning + case "terminating": + return v1.LifecycleStatusTerminating + case "terminated": + return v1.LifecycleStatusTerminated + case "error": + return v1.LifecycleStatusFailed + case "unhealthy": + return v1.LifecycleStatusRunning + default: + return v1.LifecycleStatusPending + } +} + func (c *LambdaLabsClient) MergeInstanceForUpdate(_ v1.Instance, newInst v1.Instance) v1.Instance { - // TODO: Implement instance merging logic return newInst } -// MergeInstanceTypeForUpdate merges instance type data for updates func (c *LambdaLabsClient) MergeInstanceTypeForUpdate(_ v1.InstanceType, newIt v1.InstanceType) v1.InstanceType { - // TODO: Implement instance type merging logic return newIt } diff --git a/internal/lambdalabs/v1/instancetype.go b/internal/lambdalabs/v1/instancetype.go index 403e2df4..09f788d1 100644 --- a/internal/lambdalabs/v1/instancetype.go +++ b/internal/lambdalabs/v1/instancetype.go @@ -2,39 +2,69 @@ package v1 import ( "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" "time" "github.com/alecthomas/units" + "github.com/bojanz/currency" + openapi "github.com/brevdev/cloud/internal/lambdalabs/gen/lambdalabs" v1 "github.com/brevdev/compute/pkg/v1" ) // GetInstanceTypes retrieves available instance types from Lambda Labs // Supported via: GET /api/v1/instance-types -func (c *LambdaLabsClient) GetInstanceTypes(_ context.Context, _ v1.GetInstanceTypeArgs) ([]v1.InstanceType, error) { - // TODO: Implement Lambda Labs instance type retrieval - // This would typically involve: - // 1. Calling Lambda Labs API to get available instance types - // 2. Filtering based on the provided arguments - // 3. Converting to the standard InstanceType format - - // Example stub implementation - instanceTypes := []v1.InstanceType{ - { - ID: v1.InstanceTypeID("gpu_1x_a10"), - Location: "us-east-1", - AvailableAzs: []string{"us-east-1a", "us-east-1b"}, - SubLocation: "us-east-1a", - Type: "gpu_1x_a10", - SupportedGPUs: []v1.GPU{{Count: 1, Memory: 24 * units.GiB, Manufacturer: "NVIDIA", Name: "A10", Type: "A10"}}, - SupportedStorage: []v1.Storage{{Type: "ssd", Size: 100 * units.GiB}}, - ElasticRootVolume: true, - Memory: 24 * units.GiB, - VCPU: 4, - SupportedArchitectures: []string{"x86_64"}, - IsAvailable: true, - BasePrice: nil, // TODO: Get actual pricing using currency.New - Provider: "lambdalabs", - }, +func (c *LambdaLabsClient) GetInstanceTypes(ctx context.Context, args v1.GetInstanceTypeArgs) ([]v1.InstanceType, error) { + resp, httpResp, err := c.client.DefaultAPI.InstanceTypes(c.makeAuthContext(ctx)).Execute() + if httpResp != nil { + defer func() { _ = httpResp.Body.Close() }() + } + if err != nil { + return nil, fmt.Errorf("failed to get instance types: %w", err) + } + + var instanceTypes []v1.InstanceType + for _, llInstanceTypeData := range resp.Data { + for _, region := range llInstanceTypeData.RegionsWithCapacityAvailable { + instanceType, err := convertLambdaLabsInstanceTypeToV1InstanceType( + region.Name, + llInstanceTypeData.InstanceType, + true, + ) + if err != nil { + return nil, fmt.Errorf("failed to convert instance type: %w", err) + } + instanceTypes = append(instanceTypes, instanceType) + } + } + + if len(args.Locations) > 0 && !args.Locations.IsAll() { + filtered := make([]v1.InstanceType, 0) + for _, it := range instanceTypes { + for _, loc := range args.Locations { + if it.Location == loc { + filtered = append(filtered, it) + break + } + } + } + instanceTypes = filtered + } + + if len(args.InstanceTypes) > 0 { + filtered := make([]v1.InstanceType, 0) + for _, it := range instanceTypes { + for _, itName := range args.InstanceTypes { + if it.Type == itName { + filtered = append(filtered, it) + break + } + } + } + instanceTypes = filtered } return instanceTypes, nil @@ -48,6 +78,114 @@ func (c *LambdaLabsClient) GetInstanceTypePollTime() time.Duration { // GetLocations retrieves available locations from Lambda Labs // UNSUPPORTED: No location listing endpoints found in Lambda Labs API +func convertLambdaLabsInstanceTypeToV1InstanceType(location string, llInstanceType openapi.InstanceType, isAvailable bool) (v1.InstanceType, error) { + var gpus []v1.GPU + if !strings.Contains(llInstanceType.Description, "CPU") { + gpu := parseGPUFromDescription(llInstanceType.Description) + gpus = append(gpus, gpu) + } + + amount, err := currency.NewAmountFromInt64(int64(llInstanceType.PriceCentsPerHour), "USD") + if err != nil { + return v1.InstanceType{}, fmt.Errorf("failed to create price amount: %w", err) + } + + instanceType := v1.InstanceType{ + Location: location, + Type: llInstanceType.Name, + SupportedGPUs: gpus, + SupportedStorage: []v1.Storage{ + { + Type: "ssd", + Size: units.GiB * units.Base2Bytes(llInstanceType.Specs.StorageGib), + }, + }, + Memory: units.GiB * units.Base2Bytes(llInstanceType.Specs.MemoryGib), + VCPU: llInstanceType.Specs.Vcpus, + SupportedArchitectures: []string{"x86_64"}, + Stoppable: false, + Rebootable: true, + IsAvailable: isAvailable, + BasePrice: &amount, + Provider: "lambdalabs", + } + + instanceType.ID = v1.InstanceTypeID(fmt.Sprintf("lambdalabs-%s-%s", location, llInstanceType.Name)) + + return instanceType, nil +} + +func parseGPUFromDescription(description string) v1.GPU { + countRegex := regexp.MustCompile(`(\d+)x`) + memoryRegex := regexp.MustCompile(`(\d+) GB`) + nameRegex := regexp.MustCompile(`x (.*?) \(`) + + var gpu v1.GPU + + if matches := countRegex.FindStringSubmatch(description); len(matches) > 1 { + if count, err := strconv.ParseInt(matches[1], 10, 32); err == nil { + gpu.Count = int32(count) + } + } + + if matches := memoryRegex.FindStringSubmatch(description); len(matches) > 1 { + if memory, err := strconv.Atoi(matches[1]); err == nil { + gpu.Memory = units.GiB * units.Base2Bytes(memory) + } + } + + if matches := nameRegex.FindStringSubmatch(description); len(matches) > 1 { + gpu.Name = strings.TrimSpace(matches[1]) + gpu.Type = gpu.Name + } + + gpu.Manufacturer = "NVIDIA" + + return gpu +} + +const lambdaLocationsData = `[ + {"location_name": "us-west-1", "description": "California, USA", "country": "USA"}, + {"location_name": "us-west-2", "description": "Arizona, USA", "country": "USA"}, + {"location_name": "us-west-3", "description": "Utah, USA", "country": "USA"}, + {"location_name": "us-south-1", "description": "Texas, USA", "country": "USA"}, + {"location_name": "us-east-1", "description": "Virginia, USA", "country": "USA"}, + {"location_name": "us-midwest-1", "description": "Illinois, USA", "country": "USA"}, + {"location_name": "australia-southeast-1", "description": "Australia", "country": "AUS"}, + {"location_name": "europe-central-1", "description": "Germany", "country": "DEU"}, + {"location_name": "asia-south-1", "description": "India", "country": "IND"}, + {"location_name": "me-west-1", "description": "Israel", "country": "ISR"}, + {"location_name": "europe-south-1", "description": "Italy", "country": "ITA"}, + {"location_name": "asia-northeast-1", "description": "Osaka, Japan", "country": "JPN"}, + {"location_name": "asia-northeast-2", "description": "Tokyo, Japan", "country": "JPN"}, + {"location_name": "us-east-3", "description": "Washington D.C, USA", "country": "USA"}, + {"location_name": "us-east-2", "description": "Washington D.C, USA", "country": "USA"}, + {"location_name": "australia-east-1", "description": "Sydney, Australia", "country": "AUS"}, + {"location_name": "us-south-3", "description": "Central Texas, USA", "country": "USA"}, + {"location_name": "us-south-2", "description": "North Texas, USA", "country": "USA"} +]` + +type LambdaLocation struct { + LocationName string `json:"location_name"` + Description string `json:"description"` + Country string `json:"country"` +} + func (c *LambdaLabsClient) GetLocations(_ context.Context, _ v1.GetLocationsArgs) ([]v1.Location, error) { - return nil, v1.ErrNotImplemented + var regionData []LambdaLocation + if err := json.Unmarshal([]byte(lambdaLocationsData), ®ionData); err != nil { + return nil, fmt.Errorf("failed to parse location data: %w", err) + } + + locations := make([]v1.Location, 0, len(regionData)) + for _, region := range regionData { + locations = append(locations, v1.Location{ + Name: region.LocationName, + Description: region.Description, + Available: true, + Country: region.Country, + }) + } + + return locations, nil }