diff --git a/v1/providers/verda/bootstrap.go b/v1/providers/verda/bootstrap.go index a3d91fb..42c468c 100644 --- a/v1/providers/verda/bootstrap.go +++ b/v1/providers/verda/bootstrap.go @@ -13,7 +13,7 @@ const ( dockerFirewallDropInPath = "/etc/systemd/system/docker.service.d/10-brev-firewall.conf" ) -func buildStartupScript(rules v1.FirewallRules) (string, error) { +func buildStartupScript(rules v1.FirewallRules, publicKey string) (string, error) { ufwRules, dockerRules, err := firewallRuleCommands(rules.IngressRules) if err != nil { return "", err @@ -23,6 +23,20 @@ func buildStartupScript(rules v1.FirewallRules) (string, error) { script.WriteString(`#!/bin/bash set -u +if ! id brev >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash brev +fi +install -d -m 0700 -o brev -g brev /home/brev/.ssh +cat > /home/brev/.ssh/authorized_keys <<'BREV_AUTHORIZED_KEYS' +`) + script.WriteString(publicKey) + script.WriteString(` +BREV_AUTHORIZED_KEYS +chown brev:brev /home/brev/.ssh/authorized_keys +chmod 0600 /home/brev/.ssh/authorized_keys +echo 'brev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/brev +chmod 0440 /etc/sudoers.d/brev + if ! command -v ufw >/dev/null 2>&1; then apt-get update -y DEBIAN_FRONTEND=noninteractive apt-get install -y ufw iptables diff --git a/v1/providers/verda/client_test.go b/v1/providers/verda/client_test.go index 7488a21..0405a07 100644 --- a/v1/providers/verda/client_test.go +++ b/v1/providers/verda/client_test.go @@ -161,9 +161,8 @@ func TestGetInstanceTypesAndLocations(t *testing.T) { //nolint:funlen // One cat 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.Equal(t, "nvme", h100.SupportedStorage[0].Type) + assert.Equal(t, v1.Tebibyte, h100.SupportedStorage[0].MaxSizeBytes.Unit()) assert.True(t, h100.ElasticRootVolume) assert.Equal(t, CloudProviderID, h100.Provider) assert.Equal(t, []v1.Architecture{v1.ArchitectureX86_64}, h100.SupportedArchitectures) @@ -297,6 +296,13 @@ func TestInstanceLifecycle(t *testing.T) { //nolint:gocyclo,funlen // One fixtur writeJSON(t, w, createdInstance) case r.Method == http.MethodGet && r.URL.Path == "/instances/instance-1": writeJSON(t, w, createdInstance) + case r.Method == http.MethodGet && r.URL.Path == "/volumes/os-volume-1": + writeJSON(t, w, verdago.Volume{ + ID: "os-volume-1", + Size: 100, + Type: verdago.VolumeTypeNVMe, + IsOSVolume: true, + }) case r.Method == http.MethodPut && r.URL.Path == "/instances": require.NoError(t, json.NewDecoder(r.Body).Decode(&actionRequest)) w.WriteHeader(http.StatusAccepted) @@ -333,8 +339,11 @@ func TestInstanceLifecycle(t *testing.T) { //nolint:gocyclo,funlen // One fixtur 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, "brev", instance.SSHUser) assert.Equal(t, v1.LifecycleStatusPending, instance.Status.LifecycleStatus) assert.Equal(t, v1.InstanceTypeID("FIN-03-noSub-1H100.80S.22V"), instance.InstanceTypeID) + assert.Equal(t, "nvme", instance.VolumeType) + assert.Equal(t, v1.NewBytes(100, v1.Gibibyte), instance.DiskSizeBytes) assertLegacyBytesMatch(t, instance.DiskSize, instance.DiskSizeBytes) assert.Equal(t, "ref-123_credential-ref", createdRequest.Description) @@ -342,6 +351,9 @@ func TestInstanceLifecycle(t *testing.T) { //nolint:gocyclo,funlen // One fixtur 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.Contains(t, createdScript.Script, "useradd --create-home --shell /bin/bash brev") + assert.Contains(t, createdScript.Script, authorizedKey) + assert.Contains(t, createdScript.Script, "brev ALL=(ALL) NOPASSWD:ALL") assert.Equal(t, []string{"ssh-key-1"}, createdRequest.SSHKeyIDs) require.NotNil(t, createdRequest.OSVolume) assert.Equal(t, 100, createdRequest.OSVolume.Size) @@ -419,7 +431,7 @@ func TestBuildStartupScriptRejectsUnsafeRules(t *testing.T) { ToPort: 9999, IPRanges: []string{"not-a-cidr"}, }}, - }) + }, "public-key") require.Error(t, err) } diff --git a/v1/providers/verda/instance.go b/v1/providers/verda/instance.go index 2de21a1..3370426 100644 --- a/v1/providers/verda/instance.go +++ b/v1/providers/verda/instance.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/alecthomas/units" v1 "github.com/brevdev/cloud/v1" verdago "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" "golang.org/x/crypto/ssh" @@ -22,13 +23,13 @@ const ( instanceIdentitySeparator = "_" firewallResourceNamePrefix = "brev-firewall" sshKeyResourceNamePrefix = "brev-key" - defaultSSHUser = "root" + defaultSSHUser = "brev" defaultSSHPort = 22 ) var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-z0-9-]+`) -func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { //nolint:gocyclo // complexity is acceptable for a single func +func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { //nolint:gocyclo,funlen // complexity is acceptable for a single func location := attrs.Location if location == "" { location = c.location @@ -68,13 +69,17 @@ func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanc } // SSH keys are independent resources, so must be created and cleaned up separately - sshKeyID, err := c.ensureSSHKey(ctx, attrs.PublicKey, attrs.RefID) + publicKey, err := normalizeSSHPublicKey(attrs.PublicKey) + if err != nil { + return nil, err + } + sshKeyID, err := c.ensureSSHKey(ctx, 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) + startupScript, err := buildStartupScript(attrs.FirewallRules, publicKey) if err != nil { return nil, errors.Join(err, c.cleanupManagedResources(ctx, attrs.RefID)) } @@ -110,7 +115,11 @@ func (c *VerdaClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanc if err != nil { return nil, errors.Join(wrapVerdaError(err), c.cleanupManagedResources(ctx, attrs.RefID)) } - return c.verdaInstanceToInstance(verdaInstance), nil + instance, err := c.verdaInstanceToInstance(ctx, verdaInstance) + if err != nil { + return nil, errors.Join(err, c.cleanupManagedResources(ctx, attrs.RefID)) + } + return instance, nil } func (c *VerdaClient) GetInstance(ctx context.Context, id v1.CloudProviderInstanceID) (*v1.Instance, error) { @@ -118,7 +127,11 @@ func (c *VerdaClient) GetInstance(ctx context.Context, id v1.CloudProviderInstan if err != nil { return nil, wrapVerdaError(err) } - return c.verdaInstanceToInstance(verdaInstance), nil + instance, err := c.verdaInstanceToInstance(ctx, verdaInstance) + if err != nil { + return nil, err + } + return instance, nil } func (c *VerdaClient) ListInstances(ctx context.Context, args v1.ListInstancesArgs) ([]v1.Instance, error) { @@ -129,7 +142,10 @@ func (c *VerdaClient) ListInstances(ctx context.Context, args v1.ListInstancesAr instances := make([]v1.Instance, 0, len(verdaInstances)) for i := range verdaInstances { - instance := c.verdaInstanceToInstance(&verdaInstances[i]) + instance, err := c.verdaInstanceToInstance(ctx, &verdaInstances[i]) + if err != nil { + return nil, err + } if len(args.InstanceIDs) > 0 && !slices.Contains(args.InstanceIDs, instance.CloudID) { continue } @@ -223,11 +239,6 @@ func (c *VerdaClient) selectImage(ctx context.Context, instanceType string, requ } 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) @@ -311,7 +322,7 @@ func (c *VerdaClient) cleanupManagedResources(ctx context.Context, refID string) return errors.Join(cleanupErrors...) } -func (c *VerdaClient) verdaInstanceToInstance(verdaInstance *verdago.Instance) *v1.Instance { +func (c *VerdaClient) verdaInstanceToInstance(ctx context.Context, verdaInstance *verdago.Instance) (*v1.Instance, error) { refID, cloudCredRefID := parseInstanceDescription(verdaInstance.Description) if cloudCredRefID == "" { cloudCredRefID = c.refID @@ -345,12 +356,17 @@ func (c *VerdaClient) verdaInstanceToInstance(verdaInstance *verdago.Instance) * } 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 + if verdaInstance.OSVolumeID != nil { + volume, err := c.client.Volumes.GetVolume(ctx, *verdaInstance.OSVolumeID) + if err != nil { + return nil, wrapVerdaError(err) + } + instance.VolumeType = "nvme" + instance.DiskSize = units.Base2Bytes(volume.Size) * units.GiB + instance.DiskSizeBytes = v1.NewBytes(v1.BytesValue(volume.Size), v1.Gibibyte) } - return instance + + return instance, nil } func verdaStatusToLifecycleStatus(status string) v1.LifecycleStatus { diff --git a/v1/providers/verda/instancetype.go b/v1/providers/verda/instancetype.go index 63b70f0..e912b63 100644 --- a/v1/providers/verda/instancetype.go +++ b/v1/providers/verda/instancetype.go @@ -18,10 +18,7 @@ import ( const defaultCurrency = "usd" -var ( - storageSizePattern = regexp.MustCompile(`(?i)(\d+)\s*(TiB|TB|GiB|GB|MiB|MB)`) - gpuMemorySuffixPattern = regexp.MustCompile(`(?i)\s+[0-9]+GB$`) -) +var 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) @@ -86,17 +83,14 @@ func verdaInstanceTypeToInstanceType(verdaType verdago.InstanceTypeInfo, locatio MemoryBytes: memoryBytes, VCPU: int32(verdaType.CPU.NumberOfCores), //nolint:gosec // ok SupportedArchitectures: []v1.Architecture{verdaArchitecture(verdaType.Model)}, - SupportedStorage: storageDescriptionToStorage(verdaType.Storage.Description), + SupportedStorage: buildSupportedStorage(), 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 { @@ -117,6 +111,28 @@ func verdaInstanceTypeToInstanceType(verdaType verdago.InstanceTypeInfo, locatio return instanceType, nil } +func buildSupportedStorage() []v1.Storage { + minSize := 50 * units.GiB + minSizeBytes := v1.NewBytes(50, v1.Gibibyte) + maxSize := 10 * units.TiB + maxSizeBytes := v1.NewBytes(10, v1.Tebibyte) + + pricePerGBHr, _ := currency.NewAmount("0.0002740", "USD") + + return []v1.Storage{ + { + Type: "nvme", + Count: 1, + MinSize: &minSize, + MaxSize: &maxSize, + MinSizeBytes: &minSizeBytes, + MaxSizeBytes: &maxSizeBytes, + IsElastic: true, + PricePerGBHr: &pricePerGBHr, + }, + } +} + func verdaGPUName(model string) string { return gpuMemorySuffixPattern.ReplaceAllString(model, "") } @@ -137,60 +153,6 @@ func currencyCode(code string) string { 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 diff --git a/v1/providers/verda/validation_test.go b/v1/providers/verda/validation_test.go index a8f7cb1..29588f1 100644 --- a/v1/providers/verda/validation_test.go +++ b/v1/providers/verda/validation_test.go @@ -21,6 +21,18 @@ func TestValidationFunctions(t *testing.T) { }) } +func TestGetInstance(t *testing.T) { + checkValidationCredentials(t) + credential := validationCredential() + + client, err := credential.MakeClient(context.Background(), "") + require.NoError(t, err) + + instance, err := client.GetInstance(context.Background(), v1.CloudProviderInstanceID("b395e9e7-9a21-4ff1-a4b5-fb06d9652942")) + require.NoError(t, err) + require.NotNil(t, instance) +} + func TestInstanceLifecycleValidation(t *testing.T) { checkValidationCredentials(t)