From bcaeac609dc101c591fb0057237a97c10fb62df1 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 30 Jul 2026 12:50:35 -0700 Subject: [PATCH 1/2] Verda --- go.mod | 1 + go.sum | 2 + v1/providers/verda/bootstrap.go | 137 +++++++ v1/providers/verda/capabilities.go | 20 + v1/providers/verda/client.go | 165 ++++++++ v1/providers/verda/client_test.go | 536 ++++++++++++++++++++++++++ v1/providers/verda/errors.go | 36 ++ v1/providers/verda/instance.go | 439 +++++++++++++++++++++ v1/providers/verda/instancetype.go | 201 ++++++++++ v1/providers/verda/location.go | 59 +++ v1/providers/verda/validation_test.go | 74 ++++ 11 files changed, 1670 insertions(+) create mode 100644 v1/providers/verda/bootstrap.go create mode 100644 v1/providers/verda/capabilities.go create mode 100644 v1/providers/verda/client.go create mode 100644 v1/providers/verda/client_test.go create mode 100644 v1/providers/verda/errors.go create mode 100644 v1/providers/verda/instance.go create mode 100644 v1/providers/verda/instancetype.go create mode 100644 v1/providers/verda/location.go create mode 100644 v1/providers/verda/validation_test.go diff --git a/go.mod b/go.mod index b44c82e..bc7a4fa 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/sfcompute/nodes-go v0.1.0-alpha.4 github.com/sfcompute/sfc-go v0.1.0-preview.3 github.com/stretchr/testify v1.11.1 + github.com/verda-cloud/verdacloud-sdk-go v1.4.2 golang.org/x/crypto v0.52.0 golang.org/x/text v0.37.0 google.golang.org/grpc v1.79.3 diff --git a/go.sum b/go.sum index 61bb01b..26a8343 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= +github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/v1/providers/verda/bootstrap.go b/v1/providers/verda/bootstrap.go new file mode 100644 index 0000000..a3d91fb --- /dev/null +++ b/v1/providers/verda/bootstrap.go @@ -0,0 +1,137 @@ +package verda + +import ( + "fmt" + "net" + "strings" + + v1 "github.com/brevdev/cloud/v1" +) + +const ( + dockerFirewallScriptPath = "/usr/local/sbin/brev-apply-docker-firewall.sh" + dockerFirewallDropInPath = "/etc/systemd/system/docker.service.d/10-brev-firewall.conf" +) + +func buildStartupScript(rules v1.FirewallRules) (string, error) { + ufwRules, dockerRules, err := firewallRuleCommands(rules.IngressRules) + if err != nil { + return "", err + } + + var script strings.Builder + script.WriteString(`#!/bin/bash +set -u + +if ! command -v ufw >/dev/null 2>&1; then + apt-get update -y + DEBIAN_FRONTEND=noninteractive apt-get install -y ufw iptables +fi + +mkdir -p /usr/local/sbin /etc/systemd/system/docker.service.d +cat > ` + dockerFirewallScriptPath + ` <<'BREV_FIREWALL' +#!/bin/sh +iptables -N DOCKER-USER 2>/dev/null || true +iptables -F DOCKER-USER || true +iptables -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +iptables -A DOCKER-USER -i docker0 ! -o docker0 -j ACCEPT +iptables -A DOCKER-USER -i br+ ! -o br+ -j ACCEPT +iptables -A DOCKER-USER -i cni+ ! -o cni+ -j ACCEPT +iptables -A DOCKER-USER -i cali+ ! -o cali+ -j ACCEPT +iptables -A DOCKER-USER -i docker0 -o docker0 -j ACCEPT +iptables -A DOCKER-USER -i br+ -o br+ -j ACCEPT +iptables -A DOCKER-USER -i cni+ -o cni+ -j ACCEPT +iptables -A DOCKER-USER -i cali+ -o cali+ -j ACCEPT +iptables -A DOCKER-USER -i lo -j ACCEPT +iptables -A DOCKER-USER -i wt0 -j ACCEPT +`) + for _, command := range dockerRules { + script.WriteString(command) + script.WriteByte('\n') + } + script.WriteString(`iptables -A DOCKER-USER -j DROP +exit 0 +BREV_FIREWALL +chmod 0755 ` + dockerFirewallScriptPath + ` + +cat > ` + dockerFirewallDropInPath + ` <<'BREV_DROP_IN' +[Service] +ExecStartPost=-` + dockerFirewallScriptPath + ` +BREV_DROP_IN + +systemctl daemon-reload || true +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp +`) + for _, command := range ufwRules { + script.WriteString(command) + script.WriteByte('\n') + } + script.WriteString(`ufw --force enable +` + dockerFirewallScriptPath + ` || true +`) + return script.String(), nil +} + +func firewallRuleCommands(rules []v1.FirewallRule) ([]string, []string, error) { + var ufwCommands []string + var dockerCommands []string + for _, rule := range rules { + if rule.FromPort < 1 || rule.ToPort > 65535 || rule.FromPort > rule.ToPort { + return nil, nil, fmt.Errorf( + "invalid firewall port range %d-%d", + rule.FromPort, + rule.ToPort, + ) + } + + sources := rule.IPRanges + if len(sources) == 0 { + sources = []string{"0.0.0.0/0"} + } + for _, source := range sources { + ip, network, err := net.ParseCIDR(source) + if err != nil { + return nil, nil, fmt.Errorf("invalid firewall CIDR %q: %w", source, err) + } + if ip.To4() == nil { + return nil, nil, fmt.Errorf("IPv6 firewall CIDR %q is not supported", source) + } + source = network.String() + + if rule.FromPort == rule.ToPort { + ufwCommands = append(ufwCommands, fmt.Sprintf( + "ufw allow from %s to any port %d", + source, + rule.FromPort, + )) + } else { + for _, protocol := range []string{"tcp", "udp"} { + ufwCommands = append(ufwCommands, fmt.Sprintf( + "ufw allow from %s to any port %d:%d proto %s", + source, + rule.FromPort, + rule.ToPort, + protocol, + )) + } + } + + portSpec := fmt.Sprintf("%d", rule.FromPort) + if rule.FromPort != rule.ToPort { + portSpec = fmt.Sprintf("%d:%d", rule.FromPort, rule.ToPort) + } + for _, protocol := range []string{"tcp", "udp"} { + dockerCommands = append(dockerCommands, fmt.Sprintf( + "iptables -A DOCKER-USER -s %s -p %s --dport %s -j ACCEPT", + source, + protocol, + portSpec, + )) + } + } + } + return ufwCommands, dockerCommands, nil +} diff --git a/v1/providers/verda/capabilities.go b/v1/providers/verda/capabilities.go new file mode 100644 index 0000000..a28c37f --- /dev/null +++ b/v1/providers/verda/capabilities.go @@ -0,0 +1,20 @@ +package verda + +import ( + "context" + + v1 "github.com/brevdev/cloud/v1" +) + +func getCapabilities() v1.Capabilities { + return v1.Capabilities{ + v1.CapabilityCreateInstance, + v1.CapabilityTerminateInstance, + v1.CapabilityCreateTerminateInstance, + v1.CapabilityStopStartInstance, + } +} + +func (c *VerdaClient) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getCapabilities(), nil +} diff --git a/v1/providers/verda/client.go b/v1/providers/verda/client.go new file mode 100644 index 0000000..50a6951 --- /dev/null +++ b/v1/providers/verda/client.go @@ -0,0 +1,165 @@ +package verda + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" + + v1 "github.com/brevdev/cloud/v1" + validation "github.com/go-ozzo/ozzo-validation/v4" + verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +const ( + CloudProviderID = "verda" + DefaultAPIURL = verdago.DefaultBaseURL +) + +type VerdaCredential struct { + RefID string + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + APIURL string `json:"api_url"` +} + +var _ v1.CloudCredential = &VerdaCredential{} + +func NewVerdaCredential(refID, clientID, clientSecret string) *VerdaCredential { + credential := &VerdaCredential{ + RefID: refID, + ClientID: clientID, + ClientSecret: clientSecret, + } + credential.SetDefaults() + return credential +} + +func (c *VerdaCredential) SetDefaults() { + if c.APIURL == "" { + c.APIURL = DefaultAPIURL + } + c.APIURL = strings.TrimRight(c.APIURL, "/") +} + +func (c *VerdaCredential) Validate() error { + c.SetDefaults() + if err := validation.ValidateStruct( + c, + validation.Field(&c.ClientID, validation.Required), + validation.Field(&c.ClientSecret, validation.Required), + validation.Field(&c.APIURL, validation.Required), + ); err != nil { + return errors.Wrap(err, "failed to validate verda credential") + } + return nil +} + +func (c *VerdaCredential) GetReferenceID() string { + return c.RefID +} + +func (c *VerdaCredential) GetAPIType() v1.APIType { + return v1.APITypeGlobal +} + +func (c *VerdaCredential) GetCloudProviderID() v1.CloudProviderID { + return CloudProviderID +} + +func (c *VerdaCredential) GetTenantID() (string, error) { + return makeTenantID(c.ClientID) +} + +func makeTenantID(clientID string) (string, error) { + hashedClientID, err := v1.HashSensitiveString(clientID) + if err != nil { + return "", errors.Wrap(err, "failed to hash verda client ID") + } + return fmt.Sprintf("%s-%s", CloudProviderID, hashedClientID), nil +} + +func (c *VerdaCredential) MakeClient(ctx context.Context, location string) (v1.CloudClient, error) { + return c.MakeClientWithOptions(ctx, location) +} + +func (c *VerdaCredential) MakeClientWithOptions(_ context.Context, location string, opts ...VerdaClientOption) (v1.CloudClient, error) { + return NewVerdaClient(*c, location, opts...) +} + +func (c *VerdaCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getCapabilities(), nil +} + +type VerdaClient struct { + v1.NotImplCloudClient + + refID string + clientID string + location string + client *verdago.Client + httpClient *http.Client +} + +var _ v1.CloudClient = &VerdaClient{} + +type VerdaClientOption func(*VerdaClient) + +func WithHTTPClient(httpClient *http.Client) VerdaClientOption { + return func(c *VerdaClient) { + c.httpClient = httpClient + } +} + +func NewVerdaClient(credential VerdaCredential, location string, opts ...VerdaClientOption) (*VerdaClient, error) { + if err := credential.Validate(); err != nil { + return nil, err + } + + client := &VerdaClient{ + refID: credential.RefID, + clientID: credential.ClientID, + location: location, + httpClient: http.DefaultClient, + } + for _, opt := range opts { + opt(client) + } + + sdkClient, err := verdago.NewClient( + verdago.WithBaseURL(credential.APIURL), + verdago.WithClientID(credential.ClientID), + verdago.WithClientSecret(credential.ClientSecret), + verdago.WithHTTPClient(client.httpClient), + verdago.WithUserAgent("brev-cloud"), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create verda SDK client") + } + client.client = sdkClient + return client, nil +} + +func (c *VerdaClient) GetReferenceID() string { + return c.refID +} + +func (c *VerdaClient) GetAPIType() v1.APIType { + return v1.APITypeGlobal +} + +func (c *VerdaClient) GetCloudProviderID() v1.CloudProviderID { + return CloudProviderID +} + +func (c *VerdaClient) GetTenantID() (string, error) { + return makeTenantID(c.clientID) +} + +func (c *VerdaClient) MakeClient(_ context.Context, location string) (v1.CloudClient, error) { + clientCopy := *c + clientCopy.location = location + return &clientCopy, nil +} diff --git a/v1/providers/verda/client_test.go b/v1/providers/verda/client_test.go new file mode 100644 index 0000000..7488a21 --- /dev/null +++ b/v1/providers/verda/client_test.go @@ -0,0 +1,536 @@ +package verda + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/alecthomas/units" + v1 "github.com/brevdev/cloud/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "golang.org/x/crypto/ssh" +) + +func TestVerdaCredential(t *testing.T) { + credential := NewVerdaCredential("credential-ref", "client-id", "client-secret") + + assert.Equal(t, DefaultAPIURL, credential.APIURL) + assert.Equal(t, v1.CloudProviderID(CloudProviderID), credential.GetCloudProviderID()) + assert.Equal(t, v1.APITypeGlobal, credential.GetAPIType()) + assert.Equal(t, "credential-ref", credential.GetReferenceID()) + require.NoError(t, credential.Validate()) + + tenantID, err := credential.GetTenantID() + require.NoError(t, err) + assert.NotEmpty(t, tenantID) + + rotatedSecret := NewVerdaCredential("credential-ref", "client-id", "new-secret") + rotatedTenantID, err := rotatedSecret.GetTenantID() + require.NoError(t, err) + assert.Equal(t, tenantID, rotatedTenantID) + + invalid := NewVerdaCredential("credential-ref", "", "") + require.Error(t, invalid.Validate()) +} + +func TestWrapVerdaInsufficientResourcesError(t *testing.T) { + err := wrapVerdaError(&verdago.APIError{ + StatusCode: http.StatusServiceUnavailable, + Message: "Not enough resources to deploy a 1 GPU instance type 1A100.22V in FIN-01", + }) + + require.ErrorIs(t, err, v1.ErrInsufficientResources) + assert.False(t, errors.Is(err, v1.ErrServiceUnavailable)) +} + +func TestGetInstanceTypesAndLocations(t *testing.T) { //nolint:funlen // One catalog fixture exercises all shared validations. + server := newVerdaTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/instance-types": + assert.Equal(t, "usd", r.URL.Query().Get("currency")) + writeJSON(t, w, []verdago.InstanceTypeInfo{ + { + ID: "native-gb300", + InstanceType: "1GB300.32V", + Model: "GB300", + CPU: verdago.InstanceCPU{ + NumberOfCores: 32, + }, + GPU: verdago.InstanceGPU{ + NumberOfGPUs: 1, + }, + GPUMemory: verdago.InstanceMemory{ + SizeInGigabytes: 288, + }, + Memory: verdago.InstanceMemory{ + SizeInGigabytes: 225, + }, + Storage: verdago.InstanceStorage{ + Description: "dynamic", + }, + PricePerHour: 8.62, + Currency: "usd", + Manufacturer: "NVIDIA", + }, + { + ID: "native-h100", + InstanceType: "1H100.80S.22V", + Model: "H100 80GB", + CPU: verdago.InstanceCPU{ + NumberOfCores: 22, + }, + GPU: verdago.InstanceGPU{ + NumberOfGPUs: 1, + }, + GPUMemory: verdago.InstanceMemory{ + SizeInGigabytes: 80, + }, + Memory: verdago.InstanceMemory{ + SizeInGigabytes: 128, + }, + Storage: verdago.InstanceStorage{ + Description: "1TiB NVMe SSD", + }, + PricePerHour: 3.17, + SpotPrice: 2.54, + Currency: "usd", + Manufacturer: "NVIDIA", + P2P: "SXM", + }, + { + ID: "native-cpu", + InstanceType: "CPU.4V.16G", + CPU: verdago.InstanceCPU{ + NumberOfCores: 4, + }, + Memory: verdago.InstanceMemory{ + SizeInGigabytes: 16, + }, + Storage: verdago.InstanceStorage{ + Description: "100GiB SSD", + }, + PricePerHour: 0.25, + Currency: "usd", + }, + }) + case "/instance-availability": + writeJSON(t, w, []verdago.LocationAvailability{ + { + LocationCode: "FIN-03", + Availabilities: []string{ + "1H100.80S.22V", + "1GB300.32V", + }, + }, + {LocationCode: "NOR-01", Availabilities: []string{"CPU.4V.16G"}}, + }) + case "/locations": + writeJSON(t, w, []verdago.Location{ + {Code: "NOR-01", Name: "Norway 01", CountryCode: "NO"}, + {Code: "FIN-03", Name: "Finland 03", CountryCode: "FI"}, + {Code: "FIN-01", Name: "Finland 01", CountryCode: "FI"}, + }) + default: + http.NotFound(w, r) + } + }) + defer server.Close() + + client := newTestVerdaClient(t, server) + ctx := context.Background() + + instanceTypes, err := client.GetInstanceTypes(ctx, v1.GetInstanceTypeArgs{}) + require.NoError(t, err) + require.Len(t, instanceTypes, 3) + + h100 := findInstanceType(t, instanceTypes, "1H100.80S.22V") + assert.Equal(t, v1.ManufacturerNVIDIA, h100.SupportedGPUs[0].Manufacturer) + assert.Equal(t, int32(1), h100.SupportedGPUs[0].Count) + assert.Equal(t, "H100", h100.SupportedGPUs[0].Name) + assert.Equal(t, "H100 80GB", h100.SupportedGPUs[0].Type) + assert.Equal(t, "NVMe", h100.SupportedStorage[0].Type) + assert.Equal(t, v1.Tebibyte, h100.SupportedStorage[0].SizeBytes.Unit()) + assert.True(t, h100.Preemptible) + assert.True(t, h100.ElasticRootVolume) + assert.Equal(t, CloudProviderID, h100.Provider) + assert.Equal(t, []v1.Architecture{v1.ArchitectureX86_64}, h100.SupportedArchitectures) + assert.Equal(t, v1.Gigabyte, h100.MemoryBytes.Unit()) + assertLegacyBytesMatch(t, h100.Memory, h100.MemoryBytes) + assertLegacyBytesMatch(t, h100.SupportedGPUs[0].Memory, h100.SupportedGPUs[0].MemoryBytes) + assertLegacyBytesMatch(t, h100.SupportedStorage[0].Size, h100.SupportedStorage[0].SizeBytes) + + armTypes, err := client.GetInstanceTypes(ctx, v1.GetInstanceTypeArgs{ + ArchitectureFilter: &v1.ArchitectureFilter{ + IncludeArchitectures: []v1.Architecture{v1.ArchitectureARM64}, + }, + }) + require.NoError(t, err) + require.Len(t, armTypes, 1) + assert.Equal(t, "1GB300.32V", armTypes[0].Type) + assert.Equal(t, []v1.Architecture{v1.ArchitectureARM64}, armTypes[0].SupportedArchitectures) + + filtered, err := client.GetInstanceTypes(ctx, v1.GetInstanceTypeArgs{ + Locations: v1.LocationsFilter{"NOR-01"}, + InstanceTypes: []string{"CPU.4V.16G"}, + }) + require.NoError(t, err) + require.Len(t, filtered, 1) + assert.Equal(t, "CPU.4V.16G", filtered[0].Type) + assert.Empty(t, filtered[0].SupportedGPUs) + + locations, err := client.GetLocations(ctx, v1.GetLocationsArgs{}) + require.NoError(t, err) + require.Len(t, locations, 2) + assert.Equal(t, "FIN-03", locations[0].Name) + assert.Equal(t, "FIN", locations[0].Country) + assert.Equal(t, "NOR", locations[1].Country) + + allLocations, err := client.GetLocations(ctx, v1.GetLocationsArgs{IncludeUnavailable: true}) + require.NoError(t, err) + require.Len(t, allLocations, 3) + assert.False(t, allLocations[0].Available) + + require.NoError(t, v1.ValidateGetLocations(ctx, client)) + require.NoError(t, v1.ValidateGetInstanceTypes(ctx, client)) + require.NoError(t, v1.ValidateLocationalInstanceTypes(ctx, client)) + require.NoError(t, v1.ValidateStableInstanceTypeIDs(ctx, client, []v1.InstanceTypeID{ + "FIN-03-noSub-1GB300.32V", + "FIN-03-noSub-1H100.80S.22V", + "NOR-01-noSub-CPU.4V.16G", + })) +} + +func TestInstanceLifecycle(t *testing.T) { //nolint:gocyclo,funlen // One fixture shows the complete API lifecycle. + var mu sync.Mutex + var createdRequest verdago.CreateInstanceRequest + var createdInstance verdago.Instance + var actionRequest verdago.InstanceActionRequest + var createdSSHKey verdago.SSHKey + var createdScript verdago.StartupScript + deletedResources := make(map[string]bool) + + server := newVerdaTestServer(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/images": + assert.Equal(t, "1H100.80S.22V", r.URL.Query().Get("instance_type")) + writeJSON(t, w, []verdago.Image{ + { + ID: "image-id", + ImageType: "ubuntu-24.04-cuda-12.8-open-docker", + IsDefault: true, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/ssh-keys": + keys := []verdago.SSHKey{} + if createdSSHKey.ID != "" { + keys = append(keys, createdSSHKey) + } + writeJSON(t, w, keys) + case r.Method == http.MethodPost && r.URL.Path == "/ssh-keys": + var request verdago.CreateSSHKeyRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + createdSSHKey = verdago.SSHKey{ + ID: "ssh-key-1", + Name: request.Name, + PublicKey: request.PublicKey, + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, "ssh-key-1") + case r.Method == http.MethodGet && r.URL.Path == "/ssh-keys/ssh-key-1": + writeJSON(t, w, []verdago.SSHKey{createdSSHKey}) + case r.Method == http.MethodGet && r.URL.Path == "/scripts": + scripts := []verdago.StartupScript{} + if createdScript.ID != "" { + scripts = append(scripts, createdScript) + } + writeJSON(t, w, scripts) + case r.Method == http.MethodPost && r.URL.Path == "/scripts": + var request verdago.CreateStartupScriptRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + assert.Contains(t, request.Script, "ufw default deny incoming") + assert.Contains(t, request.Script, "DOCKER-USER") + createdScript = verdago.StartupScript{ + ID: "script-1", + Name: request.Name, + Script: request.Script, + } + writeJSON(t, w, createdScript) + case r.Method == http.MethodPost && r.URL.Path == "/instances": + require.NoError(t, json.NewDecoder(r.Body).Decode(&createdRequest)) + ip := "203.0.113.10" + osVolumeID := "os-volume-1" + createdInstance = verdago.Instance{ + ID: "instance-1", + IP: &ip, + Status: verdago.StatusPending, + CreatedAt: time.Now(), + Hostname: createdRequest.Hostname, + Description: createdRequest.Description, + Location: createdRequest.LocationCode, + IsSpot: createdRequest.IsSpot, + InstanceType: createdRequest.InstanceType, + Image: createdRequest.Image, + Storage: verdago.InstanceStorage{ + Description: "100GiB NVMe SSD", + }, + SSHKeyIDs: createdRequest.SSHKeyIDs, + OSVolumeID: &osVolumeID, + VolumeIDs: []string{"data-volume-1"}, + } + w.WriteHeader(http.StatusCreated) + writeJSON(t, w, createdInstance) + case r.Method == http.MethodGet && r.URL.Path == "/instances/instance-1": + writeJSON(t, w, createdInstance) + case r.Method == http.MethodPut && r.URL.Path == "/instances": + require.NoError(t, json.NewDecoder(r.Body).Decode(&actionRequest)) + w.WriteHeader(http.StatusAccepted) + writeJSON(t, w, []verdago.InstanceActionResult{{ + Action: actionRequest.Action, + InstanceID: "instance-1", + Status: "success", + }}) + case r.Method == http.MethodDelete && r.URL.Path == "/scripts/script-1": + deletedResources[r.URL.Path] = true + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/ssh-keys/ssh-key-1": + deletedResources[r.URL.Path] = true + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + }) + defer server.Close() + + client := newTestVerdaClient(t, server) + authorizedKey, _, _ := newTestPublicKeys(t) + instance, err := client.CreateInstance(context.Background(), v1.CreateInstanceAttrs{ + Name: "Brev Validation VM", + RefID: "ref-123", + Location: "FIN-03", + PublicKey: authorizedKey + " test@example.com", + InstanceType: "1H100.80S.22V", + DiskSizeBytes: v1.NewBytes(100, v1.Gibibyte), + }) + require.NoError(t, err) + assert.Equal(t, "Brev Validation VM", instance.Name) + assert.Equal(t, "Brev Validation VM", instance.Hostname) + assert.Equal(t, "ref-123", instance.RefID) + assert.Equal(t, "credential-ref", instance.CloudCredRefID) + assert.Equal(t, "ubuntu-24.04-cuda-12.8-open-docker", instance.ImageID) + assert.Equal(t, v1.LifecycleStatusPending, instance.Status.LifecycleStatus) + assert.Equal(t, v1.InstanceTypeID("FIN-03-noSub-1H100.80S.22V"), instance.InstanceTypeID) + assertLegacyBytesMatch(t, instance.DiskSize, instance.DiskSizeBytes) + + assert.Equal(t, "ref-123_credential-ref", createdRequest.Description) + assert.LessOrEqual(t, len(createdRequest.Description), maxInstanceDescriptionLength) + assert.Equal(t, "brev-key-ref-123", createdSSHKey.Name) + assert.Equal(t, authorizedKey, createdSSHKey.PublicKey) + assert.Equal(t, "brev-firewall-ref-123", createdScript.Name) + assert.Equal(t, []string{"ssh-key-1"}, createdRequest.SSHKeyIDs) + require.NotNil(t, createdRequest.OSVolume) + assert.Equal(t, 100, createdRequest.OSVolume.Size) + + require.NoError(t, client.TerminateInstance(context.Background(), instance.CloudID)) + assert.Equal(t, verdago.ActionDelete, actionRequest.Action) + assert.ElementsMatch(t, []string{"data-volume-1", "os-volume-1"}, actionRequest.VolumeIDs) + assert.True(t, actionRequest.DeletePermanently) + assert.True(t, deletedResources["/scripts/script-1"]) + assert.True(t, deletedResources["/ssh-keys/ssh-key-1"]) +} + +func TestNormalizeSSHPublicKey(t *testing.T) { + authorizedKey, pkixPEMKey, pkcs1PEMKey := newTestPublicKeys(t) + + for _, test := range []struct { + name string + publicKey string + want string + wantError bool + }{ + { + name: "OpenSSH", + publicKey: authorizedKey + " test@example.com", + want: authorizedKey, + }, + { + name: "PKIX PEM", + publicKey: pkixPEMKey, + want: authorizedKey, + }, + { + name: "PKCS1 PEM", + publicKey: pkcs1PEMKey, + want: authorizedKey, + }, + { + name: "invalid", + publicKey: "not-a-public-key", + wantError: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := normalizeSSHPublicKey(test.publicKey) + if test.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func newTestPublicKeys(t *testing.T) (authorizedKey string, pkixPEMKey string, pkcs1PEMKey string) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := &privateKey.PublicKey + sshPublicKey, err := ssh.NewPublicKey(publicKey) + require.NoError(t, err) + pkixDER, err := x509.MarshalPKIXPublicKey(publicKey) + require.NoError(t, err) + + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), + string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pkixDER})), + string(pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: x509.MarshalPKCS1PublicKey(publicKey)})) +} + +func TestBuildStartupScriptRejectsUnsafeRules(t *testing.T) { + _, err := buildStartupScript(v1.FirewallRules{ + IngressRules: []v1.FirewallRule{{ + FromPort: 9999, + ToPort: 9999, + IPRanges: []string{"not-a-cidr"}, + }}, + }) + require.Error(t, err) +} + +func TestRequestedDiskSizeGiB(t *testing.T) { + tests := []struct { + name string + attrs v1.CreateInstanceAttrs + want int + }{ + { + name: "new bytes field", + attrs: v1.CreateInstanceAttrs{DiskSizeBytes: v1.NewBytes(100, v1.Gibibyte)}, + want: 100, + }, + { + name: "new bytes field rounds up", + attrs: v1.CreateInstanceAttrs{DiskSizeBytes: v1.NewBytes(100, v1.Gigabyte)}, + want: 94, + }, + { + name: "legacy field", + attrs: v1.CreateInstanceAttrs{DiskSize: 100 * (1 << 30)}, + want: 100, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, requestedDiskSizeGiB(test.attrs)) + }) + } +} + +func TestVerdaGPUName(t *testing.T) { + tests := map[string]string{ + "A100 80GB": "A100", + "A100 22GB": "A100", + "RTX PRO 6000": "RTX PRO 6000", + "RTX PRO 6000 96GB": "RTX PRO 6000", + } + + for model, want := range tests { + t.Run(model, func(t *testing.T) { + assert.Equal(t, want, verdaGPUName(model)) + }) + } +} + +func TestInstanceDescription(t *testing.T) { + description, err := makeInstanceDescription("ref-123", "credential-ref") + require.NoError(t, err) + assert.Equal(t, "ref-123_credential-ref", description) + + refID, cloudCredRefID := parseInstanceDescription(description) + assert.Equal(t, "ref-123", refID) + assert.Equal(t, "credential-ref", cloudCredRefID) + + _, err = makeInstanceDescription(strings.Repeat("a", maxInstanceDescriptionLength), "credential-ref") + require.Error(t, err) +} + +func findInstanceType(t *testing.T, instanceTypes []v1.InstanceType, typeName string) v1.InstanceType { + t.Helper() + for _, instanceType := range instanceTypes { + if instanceType.Type == typeName { + return instanceType + } + } + require.FailNow(t, "instance type not found", typeName) + return v1.InstanceType{} +} + +func assertLegacyBytesMatch(t *testing.T, legacy units.Base2Bytes, size v1.Bytes) { + t.Helper() + assert.Equal(t, int64(legacy), size.ByteCount().Int64()) +} + +func newVerdaTestServer( + t *testing.T, + handler func(http.ResponseWriter, *http.Request), +) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth2/token" { + writeJSON(t, w, verdago.TokenResponse{ + AccessToken: "test-token", + TokenType: "Bearer", + ExpiresIn: 3600, + }) + return + } + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + handler(w, r) + })) +} + +func newTestVerdaClient(t *testing.T, server *httptest.Server) *VerdaClient { + t.Helper() + credential := NewVerdaCredential("credential-ref", "client-id", "client-secret") + credential.APIURL = server.URL + client, err := NewVerdaClient( + *credential, + "", + WithHTTPClient(server.Client()), + ) + require.NoError(t, err) + return client +} + +func writeJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(value)) +} diff --git a/v1/providers/verda/errors.go b/v1/providers/verda/errors.go new file mode 100644 index 0000000..98b7a30 --- /dev/null +++ b/v1/providers/verda/errors.go @@ -0,0 +1,36 @@ +package verda + +import ( + "errors" + "fmt" + "net/http" + "strings" + + v1 "github.com/brevdev/cloud/v1" + verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +func wrapVerdaError(err error) error { + if err == nil { + return nil + } + + var apiError *verdago.APIError + if !errors.As(err, &apiError) { + return fmt.Errorf("verda API request failed: %w", err) + } + + message := strings.ToLower(apiError.Message + " " + apiError.Details) + switch { + case apiError.StatusCode == http.StatusNotFound: + return fmt.Errorf("verda API request failed: %w", errors.Join(v1.ErrInstanceNotFound, err)) + case strings.Contains(message, "not enough resources"): + return fmt.Errorf("verda API request failed: %w", errors.Join(v1.ErrInsufficientResources, err)) + case apiError.StatusCode == http.StatusTooManyRequests || apiError.StatusCode >= http.StatusInternalServerError: + return fmt.Errorf("verda API request failed: %w", errors.Join(v1.ErrServiceUnavailable, err)) + case strings.Contains(message, "quota") || strings.Contains(message, "limit exceeded"): + return fmt.Errorf("verda API request failed: %w", errors.Join(v1.ErrOutOfQuota, err)) + default: + return fmt.Errorf("verda API request failed: %w", err) + } +} diff --git a/v1/providers/verda/instance.go b/v1/providers/verda/instance.go new file mode 100644 index 0000000..3ceccaf --- /dev/null +++ b/v1/providers/verda/instance.go @@ -0,0 +1,439 @@ +package verda + +import ( + "context" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "math/big" + "regexp" + "slices" + "strings" + "time" + + v1 "github.com/brevdev/cloud/v1" + verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "golang.org/x/crypto/ssh" +) + +const ( + maxInstanceDescriptionLength = 100 + instanceIdentitySeparator = "_" + firewallResourceNamePrefix = "brev-firewall" + sshKeyResourceNamePrefix = "brev-key" + defaultSSHUser = "root" + defaultSSHPort = 22 +) + +var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-z0-9-]+`) + +func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { + location := attrs.Location + if location == "" { + location = c.location + } + if attrs.RefID == "" { + return nil, errors.New("verda instance RefID is required") + } + if attrs.InstanceType == "" { + return nil, errors.New("verda instance type is required") + } + if location == "" { + return nil, errors.New("verda instance location is required") + } + if attrs.PublicKey == "" { + return nil, errors.New("verda instance public key is required") + } + if attrs.UserDataBase64 != "" { + return nil, errors.New("verda provider does not support instance user data") + } + + // Verda lacks tags, so we will use the description field to house a small amount of Brev metadata + description, err := makeInstanceDescription(attrs.RefID, c.refID) + if err != nil { + return nil, err + } + + // "hostname" is essentially the Brev environment name + hostname := strings.TrimSpace(attrs.Name) + if hostname == "" { + hostname = attrs.RefID + } + + // Images are necessary at creation time, but can be derived by the instance type + image, err := c.selectImage(ctx, attrs.InstanceType, attrs.ImageID) + if err != nil { + return nil, err + } + + // SSH keys are independent resources, so must be created and cleaned up separately + sshKeyID, err := c.ensureSSHKey(ctx, attrs.PublicKey, attrs.RefID) + if err != nil { + return nil, err + } + + // Startup scripts are independent resources, so must be created and cleaned up separately + startupScript, err := buildStartupScript(attrs.FirewallRules) + if err != nil { + return nil, errors.Join(err, c.cleanupManagedResources(ctx, attrs.RefID)) + } + managedScript, err := c.client.StartupScripts.AddStartupScript(ctx, &verdago.CreateStartupScriptRequest{ + Name: managedResourceName(firewallResourceNamePrefix, attrs.RefID), + Script: startupScript, + }) + if err != nil { + return nil, errors.Join(wrapVerdaError(err), c.cleanupManagedResources(ctx, attrs.RefID)) + } + + request := verdago.CreateInstanceRequest{ + InstanceType: attrs.InstanceType, + Image: image, + Hostname: hostname, + Description: description, + SSHKeyIDs: []string{sshKeyID}, + LocationCode: location, + StartupScriptID: &managedScript.ID, + IsSpot: attrs.UseSpot, + } + if diskSizeGiB := requestedDiskSizeGiB(attrs); diskSizeGiB > 0 { + request.OSVolume = &verdago.OSVolumeCreateRequest{ + Name: managedResourceName(hostname+"-os", attrs.RefID), + Size: diskSizeGiB, + } + if attrs.UseSpot { + request.OSVolume.OnSpotDiscontinue = verdago.SpotDiscontinueDeletePermanent + } + } + + verdaInstance, err := c.client.Instances.Create(ctx, request) + if err != nil { + return nil, errors.Join(wrapVerdaError(err), c.cleanupManagedResources(ctx, attrs.RefID)) + } + return c.verdaInstanceToInstance(verdaInstance), nil +} + +func (c *VerdaClient) GetInstance(ctx context.Context, id v1.CloudProviderInstanceID) (*v1.Instance, error) { + verdaInstance, err := c.client.Instances.GetByID(ctx, string(id)) + if err != nil { + return nil, wrapVerdaError(err) + } + return c.verdaInstanceToInstance(verdaInstance), nil +} + +func (c *VerdaClient) ListInstances(ctx context.Context, args v1.ListInstancesArgs) ([]v1.Instance, error) { + verdaInstances, err := c.client.Instances.Get(ctx, "") + if err != nil { + return nil, wrapVerdaError(err) + } + + instances := make([]v1.Instance, 0, len(verdaInstances)) + for i := range verdaInstances { + instance := c.verdaInstanceToInstance(&verdaInstances[i]) + if len(args.InstanceIDs) > 0 && !slices.Contains(args.InstanceIDs, instance.CloudID) { + continue + } + if len(args.Locations) > 0 && !args.Locations.IsAllowed(instance.Location) { + continue + } + instances = append(instances, *instance) + } + return instances, nil +} + +func (c *VerdaClient) TerminateInstance(ctx context.Context, id v1.CloudProviderInstanceID) error { + verdaInstance, err := c.client.Instances.GetByID(ctx, string(id)) + if err != nil { + wrappedErr := wrapVerdaError(err) + if errors.Is(wrappedErr, v1.ErrInstanceNotFound) { + return nil + } + return wrappedErr + } + + volumeIDs := verdaInstance.VolumeIDs + if verdaInstance.OSVolumeID != nil && !slices.Contains(volumeIDs, *verdaInstance.OSVolumeID) { + volumeIDs = append(volumeIDs, *verdaInstance.OSVolumeID) + } + err = c.performInstanceAction(ctx, verdago.InstanceActionRequest{ + Action: verdago.ActionDelete, + ID: []string{string(id)}, + VolumeIDs: volumeIDs, + DeletePermanently: true, + }) + if err != nil { + return wrapVerdaError(err) + } + + refID, _ := parseInstanceDescription(verdaInstance.Description) + return c.cleanupManagedResources(ctx, refID) +} + +func (c *VerdaClient) StopInstance(ctx context.Context, id v1.CloudProviderInstanceID) error { + return c.performInstanceAction(ctx, verdago.InstanceActionRequest{ + Action: verdago.ActionShutdown, + ID: []string{string(id)}, + }) +} + +func (c *VerdaClient) StartInstance(ctx context.Context, id v1.CloudProviderInstanceID) error { + return c.performInstanceAction(ctx, verdago.InstanceActionRequest{ + Action: verdago.ActionStart, + ID: []string{string(id)}, + }) +} + +func (c *VerdaClient) performInstanceAction(ctx context.Context, request verdago.InstanceActionRequest) error { + results, err := c.client.Instances.Action(ctx, request) + if err != nil { + return wrapVerdaError(err) + } + for _, result := range results { + if strings.EqualFold(result.Status, "error") { + return fmt.Errorf( + "verda instance %s action %s failed: %s", + result.InstanceID, + result.Action, + result.Error, + ) + } + } + return nil +} + +func (c *VerdaClient) selectImage(ctx context.Context, instanceType string, requestedImage string) (string, error) { + if requestedImage != "" { + return requestedImage, nil + } + images, err := c.client.Images.GetImagesByInstanceType(ctx, instanceType) + if err != nil { + return "", wrapVerdaError(err) + } + for _, image := range images { + if image.IsDefault && !image.IsCluster { + return image.ImageType, nil + } + } + for _, image := range images { + if !image.IsCluster { + return image.ImageType, nil + } + } + return "", fmt.Errorf("verda has no compatible image for instance type %s", instanceType) +} + +func (c *VerdaClient) ensureSSHKey(ctx context.Context, publicKey string, refID string) (string, error) { + publicKey, err := normalizeSSHPublicKey(publicKey) + if err != nil { + return "", err + } + + keys, err := c.client.SSHKeys.GetAllSSHKeys(ctx) + if err != nil { + return "", wrapVerdaError(err) + } + for _, key := range keys { + if strings.TrimSpace(key.PublicKey) == strings.TrimSpace(publicKey) { + return key.ID, nil + } + } + + key, err := c.client.SSHKeys.AddSSHKey(ctx, &verdago.CreateSSHKeyRequest{ + Name: managedResourceName(sshKeyResourceNamePrefix, refID), + PublicKey: publicKey, + }) + if err != nil { + return "", wrapVerdaError(err) + } + return key.ID, nil +} + +func normalizeSSHPublicKey(publicKey string) (string, error) { + publicKey = strings.TrimSpace(publicKey) + if key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKey)); err == nil { + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), nil + } + + block, _ := pem.Decode([]byte(publicKey)) + if block == nil { + return "", errors.New("verda public key must be OpenSSH or PEM encoded") + } + parsedKey, pkixErr := x509.ParsePKIXPublicKey(block.Bytes) + if pkixErr != nil { + rsaKey, pkcs1Err := x509.ParsePKCS1PublicKey(block.Bytes) + if pkcs1Err != nil { + return "", fmt.Errorf("parse verda PEM public key: %w", errors.Join(pkixErr, pkcs1Err)) + } + parsedKey = rsaKey + } + key, err := ssh.NewPublicKey(parsedKey) + if err != nil { + return "", fmt.Errorf("convert verda public key to OpenSSH: %w", err) + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), nil +} + +func (c *VerdaClient) cleanupManagedResources(ctx context.Context, refID string) error { + if refID == "" { + return nil + } + + var cleanupErrors []error + scriptName := managedResourceName(firewallResourceNamePrefix, refID) + scripts, err := c.client.StartupScripts.GetAllStartupScripts(ctx) + if err != nil { + cleanupErrors = append(cleanupErrors, wrapVerdaError(err)) + } else { + for _, script := range scripts { + if script.Name != scriptName { + continue + } + if err := c.client.StartupScripts.DeleteStartupScript(ctx, script.ID); err != nil { + cleanupErrors = append(cleanupErrors, wrapVerdaError(err)) + } + } + } + + keyName := managedResourceName(sshKeyResourceNamePrefix, refID) + keys, err := c.client.SSHKeys.GetAllSSHKeys(ctx) + if err != nil { + cleanupErrors = append(cleanupErrors, wrapVerdaError(err)) + } else { + for _, key := range keys { + if key.Name != keyName { + continue + } + if err := c.client.SSHKeys.DeleteSSHKey(ctx, key.ID); err != nil { + cleanupErrors = append(cleanupErrors, wrapVerdaError(err)) + } + } + } + return errors.Join(cleanupErrors...) +} + +func (c *VerdaClient) verdaInstanceToInstance(verdaInstance *verdago.Instance) *v1.Instance { + refID, cloudCredRefID := parseInstanceDescription(verdaInstance.Description) + if cloudCredRefID == "" { + cloudCredRefID = c.refID + } + + publicIP := "" + if verdaInstance.IP != nil { + publicIP = *verdaInstance.IP + } + + instance := &v1.Instance{ + Name: verdaInstance.Hostname, + RefID: refID, + CloudCredRefID: cloudCredRefID, + CreatedAt: verdaInstance.CreatedAt, + CloudID: v1.CloudProviderInstanceID(verdaInstance.ID), + PublicIP: publicIP, + PublicDNS: publicIP, + Hostname: verdaInstance.Hostname, + ImageID: verdaInstance.Image, + InstanceType: verdaInstance.InstanceType, + SSHUser: defaultSSHUser, + SSHPort: defaultSSHPort, + Status: v1.Status{ + LifecycleStatus: verdaStatusToLifecycleStatus(verdaInstance.Status), + }, + Location: verdaInstance.Location, + Spot: verdaInstance.IsSpot, + Stoppable: true, + Rebootable: false, + } + instance.InstanceTypeID = v1.MakeGenericInstanceTypeIDFromInstance(*instance) + + if storage := storageDescriptionToStorage(verdaInstance.Storage.Description); len(storage) > 0 { + instance.DiskSize = storage[0].Size + instance.DiskSizeBytes = storage[0].SizeBytes + instance.VolumeType = storage[0].Type + } + return instance +} + +func verdaStatusToLifecycleStatus(status string) v1.LifecycleStatus { + switch status { + case verdago.StatusRunning: + return v1.LifecycleStatusRunning + case verdago.StatusOffline: + return v1.LifecycleStatusStopped + case verdago.StatusDeleting: + return v1.LifecycleStatusTerminating + case verdago.StatusDiscontinued, verdago.StatusNotFound: + return v1.LifecycleStatusTerminated + case verdago.StatusError, verdago.StatusNoCapacity: + return v1.LifecycleStatusFailed + default: + return v1.LifecycleStatusPending + } +} + +func makeInstanceDescription(refID string, cloudCredRefID string) (string, error) { + description := refID + instanceIdentitySeparator + cloudCredRefID + if len(description) > maxInstanceDescriptionLength { + return "", fmt.Errorf( + "verda instance identity is %d characters; maximum description length is %d", + len(description), + maxInstanceDescriptionLength, + ) + } + return description, nil +} + +func parseInstanceDescription(description string) (refID, cloudCredRefID string) { + refID, cloudCredRefID, found := strings.Cut(description, instanceIdentitySeparator) + if !found { + return description, "" + } + return refID, cloudCredRefID +} + +func managedResourceName(prefix, refID string) string { + suffix := strings.ToLower(refID) + suffix = resourceNameInvalidCharacters.ReplaceAllString(suffix, "-") + suffix = strings.Trim(suffix, "-") + name := strings.Trim(strings.ToLower(prefix), "-") + if suffix != "" { + name += "-" + suffix + } + if len(name) > 63 { + name = strings.TrimRight(name[:63], "-") + } + return name +} + +func requestedDiskSizeGiB(attrs v1.CreateInstanceAttrs) int { + var byteCount *big.Int + switch { + case attrs.DiskSizeBytes.Value() > 0: + byteCount = attrs.DiskSizeBytes.ByteCount() + case attrs.DiskSize > 0: + byteCount = big.NewInt(int64(attrs.DiskSize)) + default: + return 0 + } + + bytesPerGiB := big.NewInt(1 << 30) + sizeGiB, remainder := new(big.Int).QuoRem(byteCount, bytesPerGiB, new(big.Int)) + if remainder.Sign() > 0 { + sizeGiB.Add(sizeGiB, big.NewInt(1)) + } + if !sizeGiB.IsInt64() || sizeGiB.Int64() > int64(^uint(0)>>1) { + return 0 + } + return int(sizeGiB.Int64()) +} + +func (c *VerdaClient) GetInstancePollTime() time.Duration { + return 10 * time.Second +} + +func (c *VerdaClient) MergeInstanceForUpdate(_ v1.Instance, newInstance v1.Instance) v1.Instance { + return newInstance +} + +func (c *VerdaClient) MergeInstanceTypeForUpdate(_ v1.InstanceType, newInstanceType v1.InstanceType) v1.InstanceType { + return newInstanceType +} diff --git a/v1/providers/verda/instancetype.go b/v1/providers/verda/instancetype.go new file mode 100644 index 0000000..54ea1c9 --- /dev/null +++ b/v1/providers/verda/instancetype.go @@ -0,0 +1,201 @@ +package verda + +import ( + "context" + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/alecthomas/units" + "github.com/bojanz/currency" + v1 "github.com/brevdev/cloud/v1" + verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +const defaultCurrency = "usd" + +var ( + storageSizePattern = regexp.MustCompile(`(?i)(\d+)\s*(TiB|TB|GiB|GB|MiB|MB)`) + gpuMemorySuffixPattern = regexp.MustCompile(`(?i)\s+[0-9]+GB$`) +) + +func (c *VerdaClient) GetInstanceTypes(ctx context.Context, args v1.GetInstanceTypeArgs) ([]v1.InstanceType, error) { + verdaTypes, err := c.client.InstanceTypes.Get(ctx, defaultCurrency) + if err != nil { + return nil, wrapVerdaError(err) + } + availabilities, err := c.client.InstanceAvailability.GetAllAvailabilities(ctx, false, "") + if err != nil { + return nil, wrapVerdaError(err) + } + + typeByName := make(map[string]verdago.InstanceTypeInfo, len(verdaTypes)) + for _, verdaType := range verdaTypes { + typeByName[verdaType.InstanceType] = verdaType + } + + instanceTypes := make([]v1.InstanceType, 0) + for _, availability := range availabilities { + for _, typeName := range availability.Availabilities { + if len(args.InstanceTypes) > 0 && !slices.Contains(args.InstanceTypes, typeName) { + continue + } + verdaType, ok := typeByName[typeName] + if !ok { + continue + } + instanceType, err := verdaInstanceTypeToInstanceType(verdaType, availability.LocationCode) + if err != nil { + return nil, err + } + if v1.IsSelectedByArgs(instanceType, args) { + instanceTypes = append(instanceTypes, instanceType) + } + } + } + + sort.Slice(instanceTypes, func(i, j int) bool { + return instanceTypes[i].ID < instanceTypes[j].ID + }) + return instanceTypes, nil +} + +func verdaInstanceTypeToInstanceType(verdaType verdago.InstanceTypeInfo, location string) (v1.InstanceType, error) { + basePrice, err := currency.NewAmount( + strconv.FormatFloat(verdaType.PricePerHour.Float64(), 'f', -1, 64), + currencyCode(verdaType.Currency), + ) + if err != nil { + return v1.InstanceType{}, fmt.Errorf("failed to parse price for verda instance type %s: %w", verdaType.InstanceType, err) + } + + usageClasses := []string{"on-demand"} + if verdaType.SpotPrice > 0 { + usageClasses = append(usageClasses, "spot") + } + + memory, memoryBytes := byteSizes(int64(verdaType.Memory.SizeInGigabytes), v1.Gigabyte) + instanceType := v1.InstanceType{ + Type: verdaType.InstanceType, + Location: location, + Memory: memory, + MemoryBytes: memoryBytes, + VCPU: int32(verdaType.CPU.NumberOfCores), + SupportedArchitectures: []v1.Architecture{verdaArchitecture(verdaType.Model)}, + SupportedStorage: storageDescriptionToStorage(verdaType.Storage.Description), + ElasticRootVolume: true, + SupportedUsageClasses: usageClasses, + Stoppable: true, + Rebootable: false, + Preemptible: verdaType.SpotPrice > 0, + VariablePrice: verdaType.DynamicPrice > 0, + IsAvailable: true, + BasePrice: &basePrice, + Provider: CloudProviderID, + Cloud: CloudProviderID, + } + + if verdaType.GPU.NumberOfGPUs > 0 { + gpuMemory, gpuMemoryBytes := byteSizes(int64(verdaType.GPUMemory.SizeInGigabytes), v1.Gigabyte) + gpuModel := strings.ToUpper(strings.TrimSpace(verdaType.Model)) + instanceType.SupportedGPUs = []v1.GPU{{ + Count: int32(verdaType.GPU.NumberOfGPUs), + Memory: gpuMemory, + MemoryBytes: gpuMemoryBytes, + NetworkDetails: verdaType.P2P, + Manufacturer: v1.GetManufacturer(verdaType.Manufacturer), + Name: verdaGPUName(gpuModel), + Type: gpuModel, + }} + } + + instanceType.ID = v1.MakeGenericInstanceTypeID(instanceType) + return instanceType, nil +} + +func verdaGPUName(model string) string { + return gpuMemorySuffixPattern.ReplaceAllString(model, "") +} + +func verdaArchitecture(model string) v1.Architecture { + // The Verda API does not expose architecture + if strings.EqualFold(strings.TrimSpace(model), "GB300") { + return v1.ArchitectureARM64 + } + return v1.ArchitectureX86_64 +} + +func currencyCode(code string) string { + code = strings.ToUpper(code) + if len(code) == 3 { + return code + } + return "USD" +} + +func storageDescriptionToStorage(description string) []v1.Storage { + match := storageSizePattern.FindStringSubmatch(description) + if len(match) != 3 { + return nil + } + + size, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + return nil + } + byteUnit, ok := storageByteUnit(match[2]) + if !ok { + return nil + } + legacySize, sizeBytes := byteSizes(size, byteUnit) + + storageType := strings.TrimSpace(description) + upperDescription := strings.ToUpper(description) + switch { + case strings.Contains(upperDescription, "NVME"): + storageType = "NVMe" + case strings.Contains(upperDescription, "SSD"): + storageType = "SSD" + case strings.Contains(upperDescription, "HDD"): + storageType = "HDD" + } + + return []v1.Storage{{ + Count: 1, + Size: legacySize, + SizeBytes: sizeBytes, + Type: storageType, + }} +} + +func storageByteUnit(unit string) (v1.BytesUnit, bool) { + switch strings.ToUpper(unit) { + case "MB": + return v1.Megabyte, true + case "MIB": + return v1.Mebibyte, true + case "GB": + return v1.Gigabyte, true + case "GIB": + return v1.Gibibyte, true + case "TB": + return v1.Terabyte, true + case "TIB": + return v1.Tebibyte, true + default: + return v1.BytesUnit{}, false + } +} + +func byteSizes(value int64, unit v1.BytesUnit) (units.Base2Bytes, v1.Bytes) { + size := v1.NewBytes(v1.BytesValue(value), unit) + return units.Base2Bytes(size.ByteCount().Int64()), size +} + +func (c *VerdaClient) GetInstanceTypePollTime() time.Duration { + return time.Minute +} diff --git a/v1/providers/verda/location.go b/v1/providers/verda/location.go new file mode 100644 index 0000000..08176fe --- /dev/null +++ b/v1/providers/verda/location.go @@ -0,0 +1,59 @@ +package verda + +import ( + "context" + "sort" + "strings" + + v1 "github.com/brevdev/cloud/v1" +) + +func (c *VerdaClient) GetLocations(ctx context.Context, args v1.GetLocationsArgs) ([]v1.Location, error) { + verdaLocations, err := c.client.Locations.Get(ctx) + if err != nil { + return nil, wrapVerdaError(err) + } + availabilities, err := c.client.InstanceAvailability.GetAllAvailabilities(ctx, false, "") + if err != nil { + return nil, wrapVerdaError(err) + } + + available := make(map[string]bool, len(availabilities)) + for _, availability := range availabilities { + available[availability.LocationCode] = len(availability.Availabilities) > 0 + } + + locations := make([]v1.Location, 0, len(verdaLocations)) + for _, verdaLocation := range verdaLocations { + isAvailable := available[verdaLocation.Code] + if !args.IncludeUnavailable && !isAvailable { + continue + } + locations = append(locations, v1.Location{ + Name: verdaLocation.Code, + Description: verdaLocation.Name, + Available: isAvailable, + Country: countryAlpha3(verdaLocation.CountryCode), + }) + } + sort.Slice(locations, func(i, j int) bool { + return locations[i].Name < locations[j].Name + }) + return locations, nil +} + +func countryAlpha3(alpha2 string) string { + code := strings.ToUpper(alpha2) + + // The known current Verda locations + switch code { + case "FI": + return "FIN" + case "IS": + return "ISL" + case "NO": + return "NOR" + default: + return code + } +} diff --git a/v1/providers/verda/validation_test.go b/v1/providers/verda/validation_test.go new file mode 100644 index 0000000..a8f7cb1 --- /dev/null +++ b/v1/providers/verda/validation_test.go @@ -0,0 +1,74 @@ +package verda + +import ( + "context" + "os" + "testing" + "time" + + "github.com/brevdev/cloud/internal/validation" + v1 "github.com/brevdev/cloud/v1" + "github.com/stretchr/testify/require" +) + +func TestValidationFunctions(t *testing.T) { + checkValidationCredentials(t) + credential := validationCredential() + + validation.RunValidationSuite(t, validation.ProviderConfig{ + Credential: credential, + StableIDs: getStableInstanceTypeIDs(t, credential), + }) +} + +func TestInstanceLifecycleValidation(t *testing.T) { + checkValidationCredentials(t) + + validation.RunInstanceLifecycleValidation(t, validation.ProviderConfig{ + Credential: validationCredential(), + }) +} + +func checkValidationCredentials(t *testing.T) { + t.Helper() + clientID := os.Getenv("VERDA_CLIENT_ID") + clientSecret := os.Getenv("VERDA_CLIENT_SECRET") + if clientID != "" && clientSecret != "" { + return + } + if os.Getenv("VALIDATION_TEST") != "" { + t.Fatal("VERDA_CLIENT_ID and VERDA_CLIENT_SECRET must be set when VALIDATION_TEST is set") + } + t.Skip("VERDA_CLIENT_ID or VERDA_CLIENT_SECRET not set; skipping Verda validation tests") +} + +func validationCredential() *VerdaCredential { + credential := NewVerdaCredential( + "validation-test", + os.Getenv("VERDA_CLIENT_ID"), + os.Getenv("VERDA_CLIENT_SECRET"), + ) + if apiURL := os.Getenv("VERDA_API_URL"); apiURL != "" { + credential.APIURL = apiURL + } + return credential +} + +func getStableInstanceTypeIDs(t *testing.T, credential *VerdaCredential) []v1.InstanceTypeID { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client, err := credential.MakeClient(ctx, "") + require.NoError(t, err) + + instanceTypes, err := client.GetInstanceTypes(ctx, v1.GetInstanceTypeArgs{}) + require.NoError(t, err) + require.NotEmpty(t, instanceTypes) + + stableIDs := make([]v1.InstanceTypeID, 0, len(instanceTypes)) + for _, instanceType := range instanceTypes { + stableIDs = append(stableIDs, instanceType.ID) + } + return stableIDs +} From 54c5ae2efe9470b54b5cc677787a4b1ec737277c Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Thu, 30 Jul 2026 13:20:47 -0700 Subject: [PATCH 2/2] lint --- v1/providers/verda/instance.go | 2 +- v1/providers/verda/instancetype.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/v1/providers/verda/instance.go b/v1/providers/verda/instance.go index 3ceccaf..2de21a1 100644 --- a/v1/providers/verda/instance.go +++ b/v1/providers/verda/instance.go @@ -28,7 +28,7 @@ const ( var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-z0-9-]+`) -func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { +func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { //nolint:gocyclo // complexity is acceptable for a single func location := attrs.Location if location == "" { location = c.location diff --git a/v1/providers/verda/instancetype.go b/v1/providers/verda/instancetype.go index 54ea1c9..63b70f0 100644 --- a/v1/providers/verda/instancetype.go +++ b/v1/providers/verda/instancetype.go @@ -84,7 +84,7 @@ func verdaInstanceTypeToInstanceType(verdaType verdago.InstanceTypeInfo, locatio Location: location, Memory: memory, MemoryBytes: memoryBytes, - VCPU: int32(verdaType.CPU.NumberOfCores), + VCPU: int32(verdaType.CPU.NumberOfCores), //nolint:gosec // ok SupportedArchitectures: []v1.Architecture{verdaArchitecture(verdaType.Model)}, SupportedStorage: storageDescriptionToStorage(verdaType.Storage.Description), ElasticRootVolume: true, @@ -103,7 +103,7 @@ func verdaInstanceTypeToInstanceType(verdaType verdago.InstanceTypeInfo, locatio gpuMemory, gpuMemoryBytes := byteSizes(int64(verdaType.GPUMemory.SizeInGigabytes), v1.Gigabyte) gpuModel := strings.ToUpper(strings.TrimSpace(verdaType.Model)) instanceType.SupportedGPUs = []v1.GPU{{ - Count: int32(verdaType.GPU.NumberOfGPUs), + Count: int32(verdaType.GPU.NumberOfGPUs), //nolint:gosec // ok Memory: gpuMemory, MemoryBytes: gpuMemoryBytes, NetworkDetails: verdaType.P2P,