Skip to content
59 changes: 59 additions & 0 deletions .github/workflows/validation-sfcompute.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: SFCompute Validation Tests

on:
schedule:
# Run daily at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch:
# Allow manual triggering
pull_request:
paths:
- 'v1/providers/sfcompute/**'
- 'internal/validation/**'
- 'v1/**'
branches: [ main ]

jobs:
sfcompute-validation:
name: SFCompute Provider Validation
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request'

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version-file: 'go.mod'

- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-

- name: Install dependencies
run: make deps

- name: Run SFCompute validation tests
env:
SFCOMPUTE_API_KEY: ${{ secrets.SFCOMPUTE_API_KEY }}
TEST_PRIVATE_KEY_BASE64: ${{ secrets.TEST_PRIVATE_KEY_BASE64 }}
TEST_PUBLIC_KEY_BASE64: ${{ secrets.TEST_PUBLIC_KEY_BASE64 }}
VALIDATION_TEST: true
run: |
cd v1/providers/sfcompute
go test -v -short=false -timeout=30m ./...

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: sfcompute-validation-results
path: |
v1/providers/sfcompute/coverage.out
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/jarcoal/httpmock v1.4.0
github.com/nebius/gosdk v0.0.0-20250826102719-940ad1dfb5de
github.com/pkg/errors v0.9.1
github.com/sfcompute/nodes-go v0.1.0-alpha.4
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/text v0.33.0
Expand Down Expand Up @@ -83,6 +84,10 @@ require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
Expand Down
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sfcompute/nodes-go v0.1.0-alpha.3/go.mod h1:dF3O8MCxLz3FTVYhjCa876Z9O3EAM8E8fONivDpfmkM=
github.com/sfcompute/nodes-go v0.1.0-alpha.4 h1:oFBWcMPSpqLYm/NDs5I1jTvzgx9rsXDL9Ghsm30Hc0Q=
github.com/sfcompute/nodes-go v0.1.0-alpha.4/go.mod h1:nUviHgK+Fgt2hDFcRL3M8VoyiypC8fc0dsY8C30QU8M=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
Expand All @@ -180,6 +183,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
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/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=
Expand Down
32 changes: 30 additions & 2 deletions v1/instance_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,47 @@ func ValidateCreateInstance(ctx context.Context, client CloudCreateTerminateInst
}

func ValidateListCreatedInstance(ctx context.Context, client CloudCreateTerminateInstance, i *Instance) error {
// List instances by location and search for the instance by CloudID
ins, err := client.ListInstances(ctx, ListInstancesArgs{
Locations: []string{i.Location},
})
if err != nil {
return err
}
var validationErr error
if len(ins) == 0 {
validationErr = errors.Join(validationErr, fmt.Errorf("no instances found"))
return fmt.Errorf("no instances found")
}
foundInstance := collections.Find(ins, func(inst Instance) bool {
return inst.CloudID == i.CloudID
})
err = validateInstance(i, foundInstance)
if err != nil {
return err
}

// List instances by instance ID and search for the instance by CloudID
ins, err = client.ListInstances(ctx, ListInstancesArgs{
InstanceIDs: []CloudProviderInstanceID{i.CloudID},
})
if err != nil {
return err
}
if len(ins) == 0 {
return fmt.Errorf("instance not found: %s", i.CloudID)
}

foundInstance = collections.Find(ins, func(inst Instance) bool {
return inst.CloudID == i.CloudID
})
err = validateInstance(i, foundInstance)
if err != nil {
return err
}
return nil
}

func validateInstance(i *Instance, foundInstance *Instance) error {
var validationErr error
if foundInstance == nil {
validationErr = errors.Join(validationErr, fmt.Errorf("instance not found: %s", i.CloudID))
return validationErr
Expand Down
32 changes: 32 additions & 0 deletions v1/instancetype.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,35 @@ func ValidateStableInstanceTypeIDs(ctx context.Context, client CloudInstanceType

return nil
}

func IsSelectedByArgs(instanceType InstanceType, args GetInstanceTypeArgs) bool {
if args.Locations != nil {
if !args.Locations.IsAllowed(instanceType.Location) {
return false
}
}

if args.GPUManufactererFilter != nil {
for _, supportedGPU := range instanceType.SupportedGPUs {
if !args.GPUManufactererFilter.IsAllowed(supportedGPU.Manufacturer) {
return false
}
}
}

if args.CloudFilter != nil {
if !args.CloudFilter.IsAllowed(instanceType.Cloud) {
return false
}
}

if args.ArchitectureFilter != nil {
for _, architecture := range instanceType.SupportedArchitectures {
if !args.ArchitectureFilter.IsAllowed(architecture) {
return false
}
}
}

return true
}
36 changes: 1 addition & 35 deletions v1/providers/launchpad/instancetype.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func (c *LaunchpadClient) GetInstanceTypes(ctx context.Context, args v1.GetInsta
}

// Collect the instance type if it is selected by the args
if isSelectedByArgs(*instanceType, args) {
if v1.IsSelectedByArgs(*instanceType, args) {
instanceTypes = append(instanceTypes, *instanceType)
} else {
continue
Expand All @@ -55,40 +55,6 @@ func (c *LaunchpadClient) GetInstanceTypes(ctx context.Context, args v1.GetInsta
return instanceTypes, nil
}

func isSelectedByArgs(instanceType v1.InstanceType, args v1.GetInstanceTypeArgs) bool {
if args.Locations != nil {
for _, location := range instanceType.Location {
if !args.Locations.IsAllowed(string(location)) {
return false
}
}
}

if args.GPUManufactererFilter != nil {
for _, supportedGPU := range instanceType.SupportedGPUs {
if !args.GPUManufactererFilter.IsAllowed(supportedGPU.Manufacturer) {
return false
}
}
}

if args.CloudFilter != nil {
if !args.CloudFilter.IsAllowed(instanceType.Cloud) {
return false
}
}

if args.ArchitectureFilter != nil {
for _, architecture := range instanceType.SupportedArchitectures {
if !args.ArchitectureFilter.IsAllowed(architecture) {
return false
}
}
}

return true
}

func (c *LaunchpadClient) paginateInstanceTypes(ctx context.Context, pageSize int32) ([]openapi.InstanceType, error) {
instanceTypes := make([]openapi.InstanceType, 0, pageSize)
var page int32 = 1
Expand Down
23 changes: 23 additions & 0 deletions v1/providers/sfcompute/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package v1

import (
"context"

v1 "github.com/brevdev/cloud/v1"
)

func getSFCCapabilities() v1.Capabilities {
return v1.Capabilities{
v1.CapabilityCreateInstance,
v1.CapabilityTerminateInstance,
v1.CapabilityCreateTerminateInstance,
}
}

func (c *SFCClient) GetCapabilities(_ context.Context) (v1.Capabilities, error) {
return getSFCCapabilities(), nil
}

func (c *SFCCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) {
return getSFCCapabilities(), nil
}
104 changes: 104 additions & 0 deletions v1/providers/sfcompute/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package v1

import (
"context"

v1 "github.com/brevdev/cloud/v1"
"github.com/sfcompute/nodes-go/option"

sfcnodes "github.com/sfcompute/nodes-go"
)

const CloudProviderID = "sfcompute"

type SFCCredential struct {
RefID string
APIKey string `json:"api_key"`
}

var _ v1.CloudCredential = &SFCCredential{}

func NewSFCCredential(refID string, apiKey string) *SFCCredential {
return &SFCCredential{
RefID: refID,
APIKey: apiKey,
}
}

func (c *SFCCredential) GetReferenceID() string {
return c.RefID
}

func (c *SFCCredential) GetAPIType() v1.APIType {
return v1.APITypeGlobal
}

func (c *SFCCredential) GetCloudProviderID() v1.CloudProviderID {
return CloudProviderID
}

func (c *SFCCredential) GetTenantID() (string, error) {
// sfc does not have a tenant system, return empty string
return "", nil
}

type SFCClient struct {
v1.NotImplCloudClient
refID string
location string
apiKey string
client sfcnodes.Client
logger v1.Logger
}

var _ v1.CloudClient = &SFCClient{}

type SFCClientOption func(c *SFCClient)

func WithLogger(logger v1.Logger) SFCClientOption {
return func(c *SFCClient) {
c.logger = logger
}
}

func (c *SFCCredential) MakeClientWithOptions(_ context.Context, location string, opts ...SFCClientOption) (v1.CloudClient, error) {
sfcClient := &SFCClient{
refID: c.RefID,
apiKey: c.APIKey,
client: sfcnodes.NewClient(option.WithBearerToken(c.APIKey)),
location: location,
logger: &v1.NoopLogger{},
}

for _, opt := range opts {
opt(sfcClient)
}

return sfcClient, nil
}

func (c *SFCCredential) MakeClient(ctx context.Context, location string) (v1.CloudClient, error) {
return c.MakeClientWithOptions(ctx, location)
}

func (c *SFCClient) GetAPIType() v1.APIType {
return v1.APITypeGlobal
}

func (c *SFCClient) GetCloudProviderID() v1.CloudProviderID {
return CloudProviderID
}

func (c *SFCClient) GetReferenceID() string {
return c.refID
}

func (c *SFCClient) GetTenantID() (string, error) {
// sfc does not have a tenant system, return empty string
return "", nil
}

func (c *SFCClient) MakeClient(_ context.Context, location string) (v1.CloudClient, error) {
c.location = location
return c, nil
}
Loading
Loading