diff --git a/.github/workflows/validation-hyperstack.yml b/.github/workflows/validation-hyperstack.yml new file mode 100644 index 0000000..f84e6cf --- /dev/null +++ b/.github/workflows/validation-hyperstack.yml @@ -0,0 +1,49 @@ +name: Hyperstack Validation Tests + +on: + workflow_dispatch: + # Run explicitly from the Actions UI, GitHub CLI, or API. + +jobs: + hyperstack-validation: + name: Hyperstack Provider Validation + runs-on: ubuntu-latest + + 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 Hyperstack validation tests + env: + HYPERSTACK_API_KEY: ${{ secrets.HYPERSTACK_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/hyperstack + go test -v -short=false -timeout=30m ./... + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: hyperstack-validation-results + path: | + v1/providers/hyperstack/coverage.out diff --git a/go.mod b/go.mod index d69043e..19ba08d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/brevdev/cloud go 1.25.10 require ( + github.com/NexGenCloud/hyperstack-sdk-go v1.55.1-alpha github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/aws/aws-sdk-go-v2 v1.39.2 github.com/aws/aws-sdk-go-v2/config v1.31.11 @@ -36,6 +37,7 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect @@ -75,6 +77,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oapi-codegen/runtime v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -96,7 +99,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/time v0.13.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index a2c56d1..d60134a 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,14 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= +github.com/NexGenCloud/hyperstack-sdk-go v1.55.1-alpha h1:UIOhyN+rqnAxBNduWFzp2V6/13t1+QW8WekaYNbYYf8= +github.com/NexGenCloud/hyperstack-sdk-go v1.55.1-alpha/go.mod h1:rnZWHN6EhpiKmxFgjQBAd9vleT+vuneXJl8P0Z9OOOE= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= @@ -40,6 +45,7 @@ github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bojanz/currency v1.3.1 h1:3BUAvy/5hU/Pzqg5nrQslVihV50QG+A2xKPoQw1RKH4= github.com/bojanz/currency v1.3.1/go.mod h1:jNoZiJyRTqoU5DFoa+n+9lputxPUDa8Fz8BdDrW06Go= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -117,6 +123,7 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4z github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -135,6 +142,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nebius/gosdk v0.2.22 h1:1TTi1NAGlPSpT6mB6WFn9ZQjkPseBiL9ZhLPCNn7+/4= github.com/nebius/gosdk v0.2.22/go.mod h1:D0dqxPszWitB2rceWKF2Ty1uNunJ8YiYD3dLEdYBmOI= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= @@ -162,6 +173,7 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -242,8 +254,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= -golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/validation/suite.go b/internal/validation/suite.go index 1d5f6be..2e9d5ae 100644 --- a/internal/validation/suite.go +++ b/internal/validation/suite.go @@ -12,9 +12,10 @@ import ( ) type ProviderConfig struct { - Location string - StableIDs []v1.InstanceTypeID - Credential v1.CloudCredential + Location string + StableIDs []v1.InstanceTypeID + Credential v1.CloudCredential + CreateInstanceAttrs v1.CreateInstanceAttrs // Tags are extra labels (e.g. the CI run ID) stamped on every instance and its // network/subnet/disk so a post-run sweep can delete this run's resources. Tags map[string]string @@ -143,7 +144,7 @@ func RunInstanceLifecycleValidation(t *testing.T, config ProviderConfig) { require.NotEmpty(t, locations, "Should have locations") t.Run("ValidateCreateInstance", func(t *testing.T) { - attrs := v1.CreateInstanceAttrs{} + attrs := config.CreateInstanceAttrs selectedType := v1.InstanceType{} for _, typ := range types { if typ.IsAvailable { @@ -331,7 +332,7 @@ func RunFirewallValidation(t *testing.T, config ProviderConfig, opts FirewallVal require.NotEmpty(t, types, "Should have instance types") // Find an available instance type - attrs := v1.CreateInstanceAttrs{} + attrs := config.CreateInstanceAttrs selectedType := v1.InstanceType{} for _, typ := range types { if typ.IsAvailable { diff --git a/v1/networking_validation.go b/v1/networking_validation.go index 8fc1b87..63de3c3 100644 --- a/v1/networking_validation.go +++ b/v1/networking_validation.go @@ -197,7 +197,7 @@ func ValidateDockerFirewallAllowsEgress(ctx context.Context, client CloudInstanc return nil } -func ValidateDockerFirewallAllowsContainerToContainerCommunication(ctx context.Context, client CloudInstanceReader, instance *Instance, privateKey string) error { +func ValidateDockerFirewallAllowsContainerToContainerCommunication(ctx context.Context, client CloudInstanceReader, instance *Instance, privateKey string) error { //nolint:funlen // test ok var err error instance, err = WaitForInstanceLifecycleStatus(ctx, client, instance, LifecycleStatusRunning, PendingToRunningTimeout) if err != nil { @@ -267,9 +267,13 @@ func ValidateDockerFirewallAllowsContainerToContainerCommunication(ctx context.C } // Start a second Docker container to connect to the first container + wgetScript := fmt.Sprintf( + "for i in $(seq 1 10); do wget -q -O- http://%s && exit 0; sleep 3; done; exit 1", + containerName, + ) cmd = fmt.Sprintf( - "%s run --network %s --rm alpine wget -q -O- http://%s", - dockerCmd, networkName, containerName, + "%s run --network %s --rm alpine sh -c '%s'", + dockerCmd, networkName, wgetScript, ) stdout, stderr, err := sshClient.RunCommand(ctx, cmd) if err != nil { diff --git a/v1/providers/hyperstack/capabilities.go b/v1/providers/hyperstack/capabilities.go new file mode 100644 index 0000000..3af0e6f --- /dev/null +++ b/v1/providers/hyperstack/capabilities.go @@ -0,0 +1,24 @@ +package hyperstack + +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 *HyperstackCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getCapabilities(), nil +} + +func (c *HyperstackClient) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return getCapabilities(), nil +} diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go new file mode 100644 index 0000000..5297f17 --- /dev/null +++ b/v1/providers/hyperstack/client.go @@ -0,0 +1,211 @@ +package hyperstack + +import ( + "context" + "fmt" + "net/http" + "strings" + + pricebook "github.com/NexGenCloud/hyperstack-sdk-go/lib/Pricebook" + "github.com/NexGenCloud/hyperstack-sdk-go/lib/environment" + "github.com/NexGenCloud/hyperstack-sdk-go/lib/flavor" + "github.com/NexGenCloud/hyperstack-sdk-go/lib/keypair" + "github.com/NexGenCloud/hyperstack-sdk-go/lib/region" + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pkg/errors" + + v1 "github.com/brevdev/cloud/v1" +) + +const ( + CloudProviderID = "hyperstack" + DefaultAPIURL = "https://infrahub-api.nexgencloud.com/v1" +) + +type HyperstackCredential struct { + RefID string + APIKey string `json:"api_key"` + APIURL string `json:"api_url"` +} + +var _ v1.CloudCredential = &HyperstackCredential{} + +func NewHyperstackCredential(refID, apiKey string) *HyperstackCredential { + credential := &HyperstackCredential{ + RefID: refID, + APIKey: apiKey, + } + credential.SetDefaults() + return credential +} + +func (c *HyperstackCredential) SetDefaults() { + if c.APIURL == "" { + c.APIURL = DefaultAPIURL + } + c.APIURL = strings.TrimRight(c.APIURL, "/") +} + +func (c *HyperstackCredential) Validate() error { + c.SetDefaults() + if err := validation.ValidateStruct( + c, + validation.Field(&c.APIKey, validation.Required), + validation.Field(&c.APIURL, validation.Required), + ); err != nil { + return errors.Wrap(err, "failed to validate hyperstack credential") + } + return nil +} + +func (c *HyperstackCredential) GetReferenceID() string { + return c.RefID +} + +func (c *HyperstackCredential) GetAPIType() v1.APIType { + return v1.APITypeGlobal +} + +func (c *HyperstackCredential) GetCloudProviderID() v1.CloudProviderID { + return CloudProviderID +} + +func (c *HyperstackCredential) GetTenantID() (string, error) { + return makeTenantID(c.APIKey) +} + +func makeTenantID(apiKey string) (string, error) { + hashedAPIKey, err := v1.HashSensitiveString(apiKey) + if err != nil { + return "", errors.Wrap(err, "failed to hash hyperstack API key") + } + return fmt.Sprintf("%s-%s", CloudProviderID, hashedAPIKey), nil +} + +func (c *HyperstackCredential) MakeClient(ctx context.Context, location string) (v1.CloudClient, error) { + return c.MakeClientWithOptions(ctx, location) +} + +func (c *HyperstackCredential) MakeClientWithOptions(_ context.Context, location string, opts ...HyperstackClientOption) (v1.CloudClient, error) { + return NewHyperstackClient(*c, location, opts...) +} + +type HyperstackClient struct { + v1.NotImplCloudClient + + refID string + apiKey string + location string + httpClient *http.Client + logger v1.Logger + + virtualMachines *virtualmachine.ClientWithResponses + environments *environment.ClientWithResponses + flavors *flavor.ClientWithResponses + keypairs *keypair.ClientWithResponses + regions *region.ClientWithResponses + pricebook *pricebook.Client +} + +var _ v1.CloudClient = &HyperstackClient{} + +type HyperstackClientOption func(*HyperstackClient) + +func WithHTTPClient(httpClient *http.Client) HyperstackClientOption { + return func(c *HyperstackClient) { + c.httpClient = httpClient + } +} + +func WithLogger(logger v1.Logger) HyperstackClientOption { + return func(c *HyperstackClient) { + c.logger = logger + } +} + +func NewHyperstackClient(credential HyperstackCredential, location string, opts ...HyperstackClientOption) (*HyperstackClient, error) { + if err := credential.Validate(); err != nil { + return nil, err + } + + client := &HyperstackClient{ + refID: credential.RefID, + apiKey: credential.APIKey, + location: location, + httpClient: http.DefaultClient, + logger: &v1.NoopLogger{}, + } + for _, opt := range opts { + opt(client) + } + if client.httpClient == nil { + return nil, errors.New("hyperstack HTTP client is required") + } + + doer := &authenticatedDoer{apiKey: credential.APIKey, client: client.httpClient} + var err error + client.virtualMachines, err = virtualmachine.NewClientWithResponses( + credential.APIURL, + virtualmachine.WithHTTPClient(doer), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack virtual-machine client") + } + client.environments, err = environment.NewClientWithResponses(credential.APIURL, environment.WithHTTPClient(doer)) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack environment client") + } + client.flavors, err = flavor.NewClientWithResponses(credential.APIURL, flavor.WithHTTPClient(doer)) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack flavor client") + } + client.keypairs, err = keypair.NewClientWithResponses(credential.APIURL, keypair.WithHTTPClient(doer)) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack keypair client") + } + client.regions, err = region.NewClientWithResponses(credential.APIURL, region.WithHTTPClient(doer)) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack region client") + } + client.pricebook, err = pricebook.NewClient(credential.APIURL, pricebook.WithHTTPClient(doer)) + if err != nil { + return nil, errors.Wrap(err, "failed to create hyperstack pricebook client") + } + + return client, nil +} + +type authenticatedDoer struct { + apiKey string + client *http.Client +} + +func (d *authenticatedDoer) Do(request *http.Request) (*http.Response, error) { + request = request.Clone(request.Context()) + request.Header.Set("api_key", d.apiKey) + request.Header.Set("Accept", "application/json") + return d.client.Do(request) +} + +func (c *HyperstackClient) GetReferenceID() string { + return c.refID +} + +func (c *HyperstackClient) GetAPIType() v1.APIType { + return v1.APITypeGlobal +} + +func (c *HyperstackClient) GetCloudProviderID() v1.CloudProviderID { + return CloudProviderID +} + +func (c *HyperstackClient) GetTenantID() (string, error) { + return makeTenantID(c.apiKey) +} + +func (c *HyperstackClient) MakeClient(_ context.Context, location string) (v1.CloudClient, error) { + clientCopy := *c + clientCopy.location = location + return &clientCopy, nil +} diff --git a/v1/providers/hyperstack/environment.go b/v1/providers/hyperstack/environment.go new file mode 100644 index 0000000..07dc64b --- /dev/null +++ b/v1/providers/hyperstack/environment.go @@ -0,0 +1,71 @@ +package hyperstack + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/NexGenCloud/hyperstack-sdk-go/lib/environment" +) + +const ( + defaultEnvironmentTag = "default-" + listEnvironmentsPageSize = 100 +) + +func (c *HyperstackClient) getDefaultEnvironment(ctx context.Context, location string) (environment.EnvironmentFields, error) { + environmentName := defaultEnvironmentTag + location + + for page := 1; ; page++ { + providerEnvironments, err := c.listEnvironmentPage(ctx, listEnvironmentPageArgs{ + Page: page, + PageSize: listEnvironmentsPageSize, + Search: environmentName, + }) + if err != nil { + return environment.EnvironmentFields{}, err + } + + for _, providerEnvironment := range providerEnvironments { + if stringValue(providerEnvironment.Name) != environmentName || stringValue(providerEnvironment.Region) != location { + continue + } + if providerEnvironment.Id == nil || *providerEnvironment.Id <= 0 { + return environment.EnvironmentFields{}, fmt.Errorf("hyperstack environment %q did not contain an ID", environmentName) + } + return providerEnvironment, nil + } + + if len(providerEnvironments) < listEnvironmentsPageSize { + return environment.EnvironmentFields{}, fmt.Errorf("hyperstack environment %q was not found in location %q", environmentName, location) + } + } +} + +type listEnvironmentPageArgs struct { + Page int + PageSize int + Search string +} + +func (c *HyperstackClient) listEnvironmentPage(ctx context.Context, args listEnvironmentPageArgs) ([]environment.EnvironmentFields, error) { + pageNumber := strconv.Itoa(args.Page) + pageSizeStr := strconv.Itoa(args.PageSize) + response, err := c.environments.ListEnvironmentsWithResponse(ctx, &environment.ListEnvironmentsParams{ + Page: &pageNumber, + PageSize: &pageSizeStr, + Search: &args.Search, + }) + if err != nil { + return nil, wrapTransportError("list environments", err) + } + if response.StatusCode() != http.StatusOK { + return nil, responseError("list environments", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Environments == nil { + return nil, errors.New("hyperstack list environments response did not contain data") + } + return *response.JSON200.Environments, nil +} diff --git a/v1/providers/hyperstack/errors.go b/v1/providers/hyperstack/errors.go new file mode 100644 index 0000000..93d0fea --- /dev/null +++ b/v1/providers/hyperstack/errors.go @@ -0,0 +1,50 @@ +package hyperstack + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + v1 "github.com/brevdev/cloud/v1" +) + +type errorResponse struct { + Message string `json:"message"` + ErrorReason string `json:"error_reason"` +} + +func wrapTransportError(operation string, err error) error { + return fmt.Errorf("hyperstack %s failed: %w", operation, err) +} + +func responseError(operation string, statusCode int, body []byte, notFound error) error { + var apiError errorResponse + _ = json.Unmarshal(body, &apiError) + + detail := strings.TrimSpace(strings.Join([]string{apiError.Message, apiError.ErrorReason}, ": ")) + detail = strings.Trim(detail, ": ") + if detail == "" { + detail = http.StatusText(statusCode) + } + + requestError := fmt.Errorf("HTTP %d: %s", statusCode, detail) + lowerDetail := strings.ToLower(detail) + + var sentinel error + switch { + case statusCode == http.StatusNotFound && notFound != nil: + sentinel = notFound + case statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError: + sentinel = v1.ErrServiceUnavailable + case strings.Contains(lowerDetail, "capacity") || strings.Contains(lowerDetail, "out of stock") || strings.Contains(lowerDetail, "stock unavailable"): + sentinel = v1.ErrInsufficientResources + case strings.Contains(lowerDetail, "quota") || strings.Contains(lowerDetail, "limit exceeded"): + sentinel = v1.ErrOutOfQuota + } + if sentinel != nil { + requestError = errors.Join(sentinel, requestError) + } + return fmt.Errorf("hyperstack %s failed: %w", operation, requestError) +} diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go new file mode 100644 index 0000000..cdec48c --- /dev/null +++ b/v1/providers/hyperstack/instance.go @@ -0,0 +1,522 @@ +package hyperstack + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "time" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + + v1 "github.com/brevdev/cloud/v1" +) + +const ( + defaultImageName = "Ubuntu Server 22.04 LTS (Jammy Jellyfish)" + defaultSSHPort = 22 + defaultSSHUser = "ubuntu" + defaultPageSize = 100 + refIDLabelPrefix = "brev-ref-" + tagLabelPrefix = "brev-tag-" + tagLabelSeparator = "_" + managedKeyIDLabelPrefix = "brev-managed-key-id-" +) + +var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) + +// Hyperstack permits at most 10 labels. These seven caller tags plus the +// canonical ref ID and optional managed-key ID use at most nine labels. +var instanceTagLabelKeys = []string{ + "dev-plane-managedBy", + "dev-plane-x-instanceId", + "dev-plane-x-environmentId", + "dev-plane-x-userId", + "dev-plane-x-launchableId", + "dev-plane-x-cloudCredId", + "dev-plane-stage", +} + +func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { //nolint:gocyclo // error checking in place + location := strings.TrimSpace(attrs.Location) + if location == "" { + location = strings.TrimSpace(c.location) + } + if err := validateCreateInstanceAttrs(attrs, location); err != nil { + return nil, err + } + + providerEnvironment, err := c.getDefaultEnvironment(ctx, location) + if err != nil { + return nil, err + } + environmentName := stringValue(providerEnvironment.Name) + keyPair, err := c.resolveKeyPair(ctx, attrs, environmentName) + if err != nil { + return nil, err + } + imageName := strings.TrimSpace(attrs.ImageID) + if imageName == "" { + imageName = defaultImageName + } + securityRules, err := makeSecurityRules(attrs.FirewallRules) + if err != nil { + return nil, err + } + labels := makeLabels(attrs.RefID, attrs.Tags) + if keyPair.managedID != 0 { + labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPair.managedID)) + } + assignFloatingIP := true + enablePortRandomization := false + enhancedMonitoringEnabled := false + userData := readinessCloudConfig + + // Hyperstack root disk sizes are fixed by flavor. Intentionally do not map + // attrs.DiskSize or attrs.DiskSizeBytes into the provider request. + response, err := c.virtualMachines.CreateVMsWithResponse(ctx, virtualmachine.CreateInstancesPayload{ + Name: managedResourceName(attrs.RefID), + EnvironmentName: environmentName, + KeyName: keyPair.name, + ImageName: &imageName, + FlavorName: attrs.InstanceType, + Count: 1, + AssignFloatingIp: &assignFloatingIP, + EnablePortRandomization: &enablePortRandomization, + EnhancedMonitoringEnabled: &enhancedMonitoringEnabled, + Labels: &labels, + SecurityRules: &securityRules, + UserData: &userData, + }) + if err != nil { + return nil, wrapTransportError("create virtual machine", err) + } + if response.StatusCode() != http.StatusOK { + return nil, responseError("create virtual machine", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Instances == nil || len(*response.JSON200.Instances) != 1 { + return nil, errors.New("hyperstack create virtual machine response did not contain exactly one instance") + } + providerInstance := (*response.JSON200.Instances)[0] + if providerInstance.Id == nil || *providerInstance.Id <= 0 { + return nil, errors.New("hyperstack create virtual machine response did not contain an instance ID") + } + + instanceID := v1.CloudProviderInstanceID(strconv.Itoa(*providerInstance.Id)) + instance, err := c.GetInstance(ctx, instanceID) + if err != nil { + return nil, errors.Join(err, c.TerminateInstance(ctx, instanceID)) + } + return instance, nil +} + +func validateCreateInstanceAttrs(attrs v1.CreateInstanceAttrs, location string) error { + switch { + case strings.TrimSpace(attrs.RefID) == "": + return errors.New("hyperstack instance RefID is required") + case strings.TrimSpace(attrs.InstanceType) == "": + return errors.New("hyperstack instance type is required") + case location == "": + return errors.New("hyperstack instance location is required") + case strings.TrimSpace(attrs.PublicKey) == "" && (attrs.KeyPairName == nil || strings.TrimSpace(*attrs.KeyPairName) == ""): + return errors.New("hyperstack instance public key or key pair name is required") + case attrs.UserDataBase64 != "": + return errors.New("hyperstack provider does not support instance user data") + case len(attrs.AdditionalDisks) > 0: + return errors.New("hyperstack provider does not support additional disks") + case attrs.UseSpot != isSpotFlavor(attrs.InstanceType, attrs.InstanceType): + return errors.New("hyperstack spot selection must match a -spot instance type") + default: + return nil + } +} + +func (c *HyperstackClient) GetInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) (*v1.Instance, error) { + numericID, err := parseInstanceID(instanceID) + if err != nil { + return nil, err + } + providerInstance, err := c.getProviderInstance(ctx, numericID) + if err != nil { + return nil, err + } + instance, err := c.convertProviderInstance(ctx, providerInstance) + if err != nil { + return nil, err + } + return &instance, nil +} + +func (c *HyperstackClient) ListInstances(ctx context.Context, args v1.ListInstancesArgs) ([]v1.Instance, error) { + instances := make([]v1.Instance, 0) + for page := 1; ; page++ { + pageSize := defaultPageSize + response, err := c.virtualMachines.ListVMsWithResponse(ctx, &virtualmachine.ListVMsParams{ + Page: &page, + PageSize: &pageSize, + }) + if err != nil { + return nil, wrapTransportError("list virtual machines", err) + } + if response.StatusCode() != http.StatusOK { + return nil, responseError("list virtual machines", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Instances == nil { + return nil, errors.New("hyperstack list virtual machines response did not contain data") + } + + providerInstances := *response.JSON200.Instances + for _, providerInstance := range providerInstances { + instance, err := c.convertProviderInstance(ctx, providerInstance) + if err != nil { + return nil, err + } + if matchesListArgs(instance, args) { + instances = append(instances, instance) + } + } + if len(providerInstances) < pageSize { + break + } + } + return instances, nil +} + +func (c *HyperstackClient) TerminateInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) error { + numericID, err := parseInstanceID(instanceID) + if err != nil { + return err + } + providerInstance, err := c.getProviderInstance(ctx, numericID) + if errors.Is(err, v1.ErrInstanceNotFound) { + return nil + } + if err != nil { + return err + } + managedKeyPairID, err := managedKeyPairID(providerInstance.Labels) + if err != nil { + return err + } + response, err := c.virtualMachines.DeleteVMWithResponse(ctx, numericID) + if err != nil { + return wrapTransportError("delete virtual machine", err) + } + if response.StatusCode() != http.StatusOK && response.StatusCode() != http.StatusNotFound { + return responseError("delete virtual machine", response.StatusCode(), response.Body, nil) + } + if managedKeyPairID == 0 { + return nil + } + return c.deleteManagedKeyPair(ctx, managedKeyPairID) +} + +func (c *HyperstackClient) StopInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) error { + numericID, err := parseInstanceID(instanceID) + if err != nil { + return err + } + response, err := c.virtualMachines.StopVMWithResponse(ctx, numericID) + if err != nil { + return wrapTransportError("stop virtual machine", err) + } + if response.StatusCode() != http.StatusOK { + return responseError("stop virtual machine", response.StatusCode(), response.Body, v1.ErrInstanceNotFound) + } + return nil +} + +func (c *HyperstackClient) StartInstance(ctx context.Context, instanceID v1.CloudProviderInstanceID) error { + numericID, err := parseInstanceID(instanceID) + if err != nil { + return err + } + providerInstance, err := c.getProviderInstance(ctx, numericID) + if err != nil { + return err + } + switch strings.ToLower(strings.TrimSpace(stringValue(providerInstance.Status))) { + case "active", "running", "starting", "powering-on": + return nil + } + response, err := c.virtualMachines.StartVMWithResponse(ctx, numericID) + if err != nil { + return wrapTransportError("start virtual machine", err) + } + if response.StatusCode() != http.StatusOK { + return responseError("start virtual machine", response.StatusCode(), response.Body, v1.ErrInstanceNotFound) + } + return nil +} + +func (c *HyperstackClient) getProviderInstance(ctx context.Context, instanceID int) (virtualmachine.InstanceFields, error) { + response, err := c.virtualMachines.GetVMWithResponse(ctx, instanceID) + if err != nil { + return virtualmachine.InstanceFields{}, wrapTransportError("get virtual machine", err) + } + if response.StatusCode() != http.StatusOK { + return virtualmachine.InstanceFields{}, responseError("get virtual machine", response.StatusCode(), response.Body, v1.ErrInstanceNotFound) + } + if response.JSON200 == nil || response.JSON200.Instance == nil { + return virtualmachine.InstanceFields{}, errors.New("hyperstack get virtual machine response did not contain data") + } + return *response.JSON200.Instance, nil +} + +func parseInstanceID(instanceID v1.CloudProviderInstanceID) (int, error) { + numericID, err := strconv.Atoi(string(instanceID)) + if err != nil || numericID <= 0 { + return 0, fmt.Errorf("invalid hyperstack instance ID %q", instanceID) + } + return numericID, nil +} + +func (c *HyperstackClient) convertProviderInstance(ctx context.Context, providerInstance virtualmachine.InstanceFields) (v1.Instance, error) { + consoleReady := false + if hyperstackLifecycleStatus(stringValue(providerInstance.Status)) == v1.LifecycleStatusRunning && + hyperstackAPIReady(providerInstance, strings.TrimSpace(stringValue(providerInstance.FloatingIp))) { + var err error + consoleReady, err = c.vmOperatingSystemReportsReady(ctx, providerInstance) + if err != nil { + return v1.Instance{}, err + } + } + return c.convertInstance(providerInstance, consoleReady), nil +} + +func (c *HyperstackClient) convertInstance( + providerInstance virtualmachine.InstanceFields, + consoleReady bool, +) v1.Instance { + cloudID := strconv.Itoa(intValue(providerInstance.Id)) + name := strings.TrimSpace(stringValue(providerInstance.Name)) + refID, tags := parseLabels(providerInstance.Labels) + if refID == "" { + refID = name + } + + location := "" + if providerInstance.Environment != nil { + location = stringValue(providerInstance.Environment.Region) + } + instanceType := "" + diskSize, diskSizeBytes := byteSizes(0, v1.Gigabyte) + if providerInstance.Flavor != nil { + instanceType = stringValue(providerInstance.Flavor.Name) + diskSize, diskSizeBytes = byteSizes(int64(intValue(providerInstance.Flavor.Disk)), v1.Gigabyte) + } + imageName := "" + if providerInstance.Image != nil { + imageName = stringValue(providerInstance.Image.Name) + } + publicIP := strings.TrimSpace(stringValue(providerInstance.FloatingIp)) + lifecycleStatus := hyperstackLifecycleStatus(stringValue(providerInstance.Status)) + if lifecycleStatus == v1.LifecycleStatusRunning && (!hyperstackAPIReady(providerInstance, publicIP) || !consoleReady) { + lifecycleStatus = v1.LifecycleStatusPending + } + + instance := v1.Instance{ + Name: name, + RefID: refID, + CloudCredRefID: c.refID, + CloudID: v1.CloudProviderInstanceID(cloudID), + PublicIP: publicIP, + PublicDNS: publicIP, + PrivateIP: stringValue(providerInstance.FixedIp), + Hostname: name, + ImageID: imageName, + InstanceType: instanceType, + DiskSize: diskSize, + DiskSizeBytes: diskSizeBytes, + VolumeType: "ssd", + SSHUser: sshUser(imageName), + SSHPort: defaultSSHPort, + Status: v1.Status{ + LifecycleStatus: lifecycleStatus, + }, + FirewallRules: providerFirewallRules(providerInstance.SecurityRules), + Location: location, + Tags: tags, + Spot: isSpotFlavor(instanceType, instanceType), + Stoppable: true, + } + if providerInstance.CreatedAt != nil { + instance.CreatedAt = providerInstance.CreatedAt.Time + } + instance.InstanceTypeID = v1.MakeGenericInstanceTypeID(v1.InstanceType{ + Type: instance.InstanceType, + Location: instance.Location, + }) + return instance +} + +func hyperstackAPIReady(providerInstance virtualmachine.InstanceFields, publicIP string) bool { + if publicIP == "" { + return false + } + return readinessFieldComplete(providerInstance.FloatingIpStatus, "active", "attached") && + readinessFieldComplete(providerInstance.VmState, "active", "running") && + readinessFieldComplete(providerInstance.PowerState, "active", "running", "on") +} + +func readinessFieldComplete(value *string, readyValues ...string) bool { + status := strings.ToLower(strings.TrimSpace(stringValue(value))) + return status == "" || slices.Contains(readyValues, status) +} + +func hyperstackLifecycleStatus(status string) v1.LifecycleStatus { + switch strings.ToLower(strings.TrimSpace(status)) { + case "creating", "build", "rebuilding", "initializing": + return v1.LifecycleStatusPending + case "active", "running": + return v1.LifecycleStatusRunning + case "stopping", "powering-off": + return v1.LifecycleStatusStopping + case "stopped", "shutoff", "powered-off": + return v1.LifecycleStatusStopped + case "hibernating", "suspending": + return v1.LifecycleStatusSuspending + case "hibernated", "suspended": + return v1.LifecycleStatusSuspended + case "deleting", "terminating": + return v1.LifecycleStatusTerminating + case "deleted", "terminated": + return v1.LifecycleStatusTerminated + case "error", "failed": + return v1.LifecycleStatusFailed + default: + return v1.LifecycleStatusPending + } +} + +func providerFirewallRules(providerRules *[]virtualmachine.SecurityRulesFieldsForInstance) v1.FirewallRules { + if providerRules == nil { + return v1.FirewallRules{} + } + ingressRules := make([]v1.FirewallRule, 0) + for _, providerRule := range *providerRules { + if !strings.EqualFold(stringValue(providerRule.Direction), "ingress") { + continue + } + ingressRules = append(ingressRules, v1.FirewallRule{ + ID: strconv.Itoa(intValue(providerRule.Id)), + FromPort: int32(intValue(providerRule.PortRangeMin)), //nolint:gosec // ok + ToPort: int32(intValue(providerRule.PortRangeMax)), //nolint:gosec // ok + IPRanges: []string{stringValue(providerRule.RemoteIpPrefix)}, + }) + } + return v1.FirewallRules{IngressRules: ingressRules} +} + +func matchesListArgs(instance v1.Instance, args v1.ListInstancesArgs) bool { + if len(args.InstanceIDs) > 0 && !slices.Contains(args.InstanceIDs, instance.CloudID) { + return false + } + if len(args.Locations) > 0 && !args.Locations.IsAllowed(instance.Location) { + return false + } + for key, values := range args.TagFilters { + value, found := instance.Tags[key] + if !found || len(values) > 0 && !slices.Contains(values, value) { + return false + } + } + return true +} + +func makeLabels(refID string, tags v1.Tags) []string { + labels := []string{refIDLabelPrefix + refID} + for _, key := range instanceTagLabelKeys { + if value, ok := tags[key]; ok { + labels = append(labels, providerTagLabelPrefix(key)+value) + } + } + return labels +} + +func parseLabels(providerLabels *[]string) (string, v1.Tags) { + tags := make(v1.Tags) + if providerLabels == nil { + return "", tags + } + var refID string + for _, label := range *providerLabels { + if key, value, ok := parseTagLabel(label); ok { + tags[key] = value + continue + } + switch { + case strings.HasPrefix(label, refIDLabelPrefix): + refID = strings.TrimPrefix(label, refIDLabelPrefix) + case strings.HasPrefix(label, managedKeyIDLabelPrefix): + continue + default: + tags[label] = "" + } + } + return refID, tags +} + +func providerTagLabelPrefix(key string) string { + return tagLabelPrefix + strings.ToLower(key) + tagLabelSeparator +} + +func parseTagLabel(label string) (string, string, bool) { + for _, key := range instanceTagLabelKeys { + prefix := providerTagLabelPrefix(key) + if strings.HasPrefix(label, prefix) { + return key, strings.TrimPrefix(label, prefix), true + } + } + return "", "", false +} + +func managedKeyPairID(providerLabels *[]string) (int, error) { + if providerLabels == nil { + return 0, nil + } + for _, label := range *providerLabels { + if !strings.HasPrefix(label, managedKeyIDLabelPrefix) { + continue + } + keyPairID, err := strconv.Atoi(strings.TrimPrefix(label, managedKeyIDLabelPrefix)) + if err != nil || keyPairID <= 0 { + return 0, fmt.Errorf("invalid hyperstack managed keypair label %q", label) + } + return keyPairID, nil + } + return 0, nil +} + +func managedResourceName(name string) string { + name = strings.TrimSpace(name) + name = resourceNameInvalidCharacters.ReplaceAllString(name, "-") + name = strings.Trim(name, "-") + if len(name) > 63 { + name = strings.TrimRight(name[:63], "-") + } + if name == "" { + return "brev-instance" + } + return name +} + +func sshUser(imageName string) string { + lowerName := strings.ToLower(imageName) + switch { + case strings.Contains(lowerName, "debian"): + return "debian" + case strings.Contains(lowerName, "alma"): + return "almalinux" + default: + return defaultSSHUser + } +} + +func (c *HyperstackClient) GetInstancePollTime() time.Duration { + return 10 * time.Second +} diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go new file mode 100644 index 0000000..a4ca642 --- /dev/null +++ b/v1/providers/hyperstack/instance_test.go @@ -0,0 +1,322 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/brevdev/cloud/v1" +) + +const ( + testSSHPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBdptDTzJ2cOmdyryG1B7yb1YssiCQs6SWu4HlbZXGE" + statusActive = "ACTIVE" + statusShuttingOff = "SHUTOFF" + statusAttached = "ATTACHED" + statusAttaching = "ATTACHING" +) + +func TestCreateInstance(t *testing.T) { + var payload virtualmachine.CreateInstancesPayload + id := 42 + keyPairID := 7 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/v1/core/environments" && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{"status": true, "environments": []map[string]any{{ + "id": 5, "name": "default-CANADA-1", "region": "CANADA-1", + }}}) + case request.URL.Path == "/v1/core/keypairs" && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{"status": true, "keypairs": []any{}}) + case request.URL.Path == "/v1/core/keypairs" && request.Method == http.MethodPost: + writeJSON(t, writer, map[string]any{"status": true, "keypair": map[string]any{"id": keyPairID, "name": "ref-123"}}) + case request.URL.Path == "/v1/core/virtual-machines" && request.Method == http.MethodPost: + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + writeJSON(t, writer, map[string]any{"status": true, "instances": []map[string]any{{"id": 42}}}) + case request.URL.Path == fmt.Sprintf("/v1/core/virtual-machines/%d", id) && request.Method == http.MethodGet: + labels := append(*payload.Labels, readinessLabel) + writeJSON(t, writer, map[string]any{ + "status": true, + "instance": map[string]any{ + "id": id, + "name": "ref-123", + "status": statusActive, + "floating_ip": "203.0.113.42", + "environment": map[string]any{"name": "default-CANADA-1", "region": "CANADA-1"}, + "image": map[string]any{"name": defaultImageName}, + "flavor": map[string]any{"name": "n3-H100x1"}, + "labels": labels, + }, + }) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + instance, err := client.CreateInstance(context.Background(), v1.CreateInstanceAttrs{ + Location: "CANADA-1", + RefID: "ref-123", + PublicKey: testSSHPublicKey, + InstanceType: "n3-H100x1", + Tags: v1.Tags{"dev-plane-x-instanceId": "instance-123"}, + }) + require.NoError(t, err) + + assert.Equal(t, "ref-123", payload.Name) + assert.Equal(t, "default-CANADA-1", payload.EnvironmentName) + assert.Equal(t, "n3-H100x1", payload.FlavorName) + require.NotNil(t, payload.AssignFloatingIp) + assert.True(t, *payload.AssignFloatingIp) + require.NotNil(t, payload.UserData) + assert.Contains(t, *payload.UserData, readinessMarker) + + assert.Equal(t, v1.CloudProviderInstanceID("42"), instance.CloudID) + assert.Equal(t, "ref-123", instance.RefID) + assert.Equal(t, "instance-123", instance.Tags["dev-plane-x-instanceId"]) + assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) + assert.Equal(t, "203.0.113.42", instance.PublicIP) +} + +func TestStartAndStopInstance(t *testing.T) { + status := statusActive + startCalls := 0 + stopCalls := 0 + id := 42 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case fmt.Sprintf("/v1/core/virtual-machines/%d", id): + writeJSON(t, writer, map[string]any{"status": true, "instance": map[string]any{"id": id, "status": status}}) + case fmt.Sprintf("/v1/core/virtual-machines/%d/stop", id): + stopCalls++ + status = statusShuttingOff + writeJSON(t, writer, map[string]any{"status": true}) + case fmt.Sprintf("/v1/core/virtual-machines/%d/start", id): + startCalls++ + status = statusActive + writeJSON(t, writer, map[string]any{"status": true}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + require.NoError(t, client.StopInstance(context.Background(), v1.CloudProviderInstanceID(strconv.Itoa(id)))) + require.NoError(t, client.StartInstance(context.Background(), v1.CloudProviderInstanceID(strconv.Itoa(id)))) + require.NoError(t, client.StartInstance(context.Background(), v1.CloudProviderInstanceID(strconv.Itoa(id)))) + assert.Equal(t, 1, stopCalls) + assert.Equal(t, 1, startCalls) +} + +func TestTerminateInstanceDeletesManagedKeyPair(t *testing.T) { + vmDeletes := 0 + keyPairDeletes := 0 + id := 42 + keyPairID := 7 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == fmt.Sprintf("/v1/core/virtual-machines/%d", id) && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{"status": true, "instance": map[string]any{ + "id": id, "labels": []string{managedKeyIDLabelPrefix + strconv.Itoa(keyPairID)}, + }}) + case request.URL.Path == fmt.Sprintf("/v1/core/virtual-machines/%d", id) && request.Method == http.MethodDelete: + vmDeletes++ + writeJSON(t, writer, map[string]any{"status": true}) + case request.URL.Path == fmt.Sprintf("/v1/core/keypair/%d", keyPairID) && request.Method == http.MethodDelete: + keyPairDeletes++ + writeJSON(t, writer, map[string]any{"status": true}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + require.NoError(t, client.TerminateInstance(context.Background(), "42")) + assert.Equal(t, 1, vmDeletes) + assert.Equal(t, 1, keyPairDeletes) +} + +func TestResolveKeyPairSearchesEveryPage(t *testing.T) { + listCalls := 0 + importCalls := 0 + keyPairID := 7 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + require.Equal(t, "/v1/core/keypairs", request.URL.Path) + switch request.Method { + case http.MethodGet: + listCalls++ + assert.Equal(t, "100", request.URL.Query().Get("pageSize")) + assert.Equal(t, "ref", request.URL.Query().Get("search")) + if request.URL.Query().Get("page") == "1" { + keys := make([]map[string]any, defaultPageSize) + for index := range keys { + keys[index] = map[string]any{"name": "unrelated-key"} + } + writeJSON(t, writer, map[string]any{"status": true, "keypairs": keys}) + return + } + writeJSON(t, writer, map[string]any{"status": true, "keypairs": []map[string]any{{ + "id": keyPairID, "name": "ref", "public_key": testSSHPublicKey, + "environment": map[string]any{"name": "default-CANADA-1"}, + }}}) + case http.MethodPost: + importCalls++ + writeJSON(t, writer, map[string]any{"status": true}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + keyPair, err := client.resolveKeyPair(context.Background(), v1.CreateInstanceAttrs{ + RefID: "ref", + PublicKey: testSSHPublicKey, + }, "default-CANADA-1") + require.NoError(t, err) + assert.Equal(t, "ref", keyPair.name) + assert.Equal(t, keyPairID, keyPair.managedID) + assert.Equal(t, 2, listCalls) + assert.Zero(t, importCalls) +} + +func TestLabelsRoundTrip(t *testing.T) { + const refID = "environment-123" + keyPairID := 7 + tags := v1.Tags{ + "dev-plane-managedBy": "dev-plane", + "dev-plane-x-instanceId": "instance-123", + } + labels := makeLabels(refID, tags) + labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPairID)) + + parsedRefID, parsedTags := parseLabels(&labels) + assert.Equal(t, refID, parsedRefID) + assert.Equal(t, tags, parsedTags) +} + +func TestCallerKeyPairIsNotManaged(t *testing.T) { + keyName := "customer-key" + keyPair, err := (&HyperstackClient{}).resolveKeyPair(context.Background(), v1.CreateInstanceAttrs{ + KeyPairName: &keyName, + }, "default-CANADA-1") + require.NoError(t, err) + assert.Equal(t, keyName, keyPair.name) + assert.Zero(t, keyPair.managedID) +} + +func TestActiveInstanceWaitsForFloatingIP(t *testing.T) { + status := statusActive + client := &HyperstackClient{} + + withoutIP := client.convertInstance(virtualmachine.InstanceFields{Status: &status}, false) + assert.Equal(t, v1.LifecycleStatusPending, withoutIP.Status.LifecycleStatus) + + publicIP := "203.0.113.42" + attaching := statusAttaching + withAttachingIP := client.convertInstance(virtualmachine.InstanceFields{ + Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attaching, + }, false) + assert.Equal(t, v1.LifecycleStatusPending, withAttachingIP.Status.LifecycleStatus) +} + +func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { + client := &HyperstackClient{} + status := statusActive + publicIP := "203.0.113.42" + attached := statusAttached + running := statusActive + providerInstance := virtualmachine.InstanceFields{ + Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, + VmState: &status, PowerState: &running, + } + + booting := client.convertInstance(providerInstance, false) + assert.Equal(t, v1.LifecycleStatusPending, booting.Status.LifecycleStatus) + + ready := client.convertInstance(providerInstance, true) + assert.Equal(t, v1.LifecycleStatusRunning, ready.Status.LifecycleStatus) +} + +func TestConsoleReadyPollsUntilLogsAreAvailable(t *testing.T) { + requestCalls := 0 + getCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodPost: + requestCalls++ + writer.WriteHeader(http.StatusAccepted) + require.NoError(t, json.NewEncoder(writer).Encode(map[string]any{"request_id": 17})) + case http.MethodGet: + getCalls++ + assert.Equal(t, "17", request.URL.Query().Get("request_id")) + if getCalls == 1 { + writeJSON(t, writer, map[string]any{ + "status": true, "message": "request is still processing", + }) + return + } + writeJSON(t, writer, map[string]any{"logs": readinessMarker}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL) + id := 42 + ready, err := client.vmOperatingSystemReportsReady(context.Background(), virtualmachine.InstanceFields{Id: &id}) + require.NoError(t, err) + assert.True(t, ready) + assert.Equal(t, 1, requestCalls) + assert.Equal(t, 2, getCalls) +} + +func assertSecurityRule(t *testing.T, rule virtualmachine.CreateSecurityRulePayload, cidr string, fromPort, toPort int) { + t.Helper() + assert.Equal(t, "ingress", rule.Direction) + assert.Equal(t, "IPv4", rule.Ethertype) + assert.Equal(t, virtualmachine.Tcp, rule.Protocol) + assert.Equal(t, cidr, rule.RemoteIpPrefix) + require.NotNil(t, rule.PortRangeMin) + require.NotNil(t, rule.PortRangeMax) + assert.Equal(t, fromPort, *rule.PortRangeMin) + assert.Equal(t, toPort, *rule.PortRangeMax) +} + +func TestMakeSecurityRules(t *testing.T) { + rules, err := makeSecurityRules(v1.FirewallRules{IngressRules: []v1.FirewallRule{{ + FromPort: defaultSSHPort, + ToPort: defaultSSHPort, + IPRanges: []string{"52.9.0.116/32"}, + }}}) + require.NoError(t, err) + require.Len(t, rules, 1) + assertSecurityRule(t, rules[0], "52.9.0.116/32", defaultSSHPort, defaultSSHPort) +} + +func TestValidateCreateInstanceAttrs(t *testing.T) { + valid := v1.CreateInstanceAttrs{ + RefID: "ref", + InstanceType: "n3-H100x1", + PublicKey: testSSHPublicKey, + } + require.NoError(t, validateCreateInstanceAttrs(valid, "CANADA-1")) + + spot := valid + spot.InstanceType = "n3-H100x1-spot" + require.Error(t, validateCreateInstanceAttrs(spot, "CANADA-1")) + spot.UseSpot = true + require.NoError(t, validateCreateInstanceAttrs(spot, "CANADA-1")) +} diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go new file mode 100644 index 0000000..204c82c --- /dev/null +++ b/v1/providers/hyperstack/instancetype.go @@ -0,0 +1,330 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/NexGenCloud/hyperstack-sdk-go/lib/flavor" + "github.com/alecthomas/units" + "github.com/bojanz/currency" + + v1 "github.com/brevdev/cloud/v1" +) + +var gpuMemoryPattern = regexp.MustCompile(`(?i)-(\d+)(?:G|GB)(?:-|$)`) + +func (c *HyperstackClient) GetInstanceTypes(ctx context.Context, args v1.GetInstanceTypeArgs) ([]v1.InstanceType, error) { + flavorGroups, err := c.listFlavors(ctx) + if err != nil { + return nil, err + } + rates, err := c.getPricebook(ctx) + if err != nil { + return nil, err + } + + instanceTypes := make([]v1.InstanceType, 0) + for _, group := range flavorGroups { + if group.Flavors == nil { + continue + } + for _, providerType := range *group.Flavors { + instanceType, err := hyperstackInstanceType(providerType, stringValue(group.RegionName), rates) + if err != nil { + return nil, err + } + if instanceType.Type != "" && 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 (c *HyperstackClient) listFlavors(ctx context.Context) ([]flavor.FlavorItemGetResponse, error) { + response, err := c.flavors.ListFlavorsWithResponse(ctx, nil) + if err != nil { + return nil, wrapTransportError("list flavors", err) + } + if response.StatusCode() != 200 { + return nil, responseError("list flavors", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Data == nil { + return nil, fmt.Errorf("hyperstack list flavors response did not contain data") + } + return *response.JSON200.Data, nil +} + +type pricebookEntry struct { + Name string `json:"name"` + Value json.RawMessage `json:"value"` +} + +func (c *HyperstackClient) getPricebook(ctx context.Context) (map[string]string, error) { + response, err := c.pricebook.GetPricebook(ctx) + if err != nil { + return nil, wrapTransportError("get pricebook", err) + } + defer func() { _ = response.Body.Close() }() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, wrapTransportError("read pricebook", err) + } + if response.StatusCode != 200 { + return nil, responseError("get pricebook", response.StatusCode, body, nil) + } + + var entries []pricebookEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("decode hyperstack pricebook: %w", err) + } + rates := make(map[string]string, len(entries)) + for _, entry := range entries { + name := strings.ToLower(strings.TrimSpace(entry.Name)) + value := strings.Trim(strings.TrimSpace(string(entry.Value)), `"`) + if name != "" && value != "" && value != "null" { + rates[name] = value + } + } + return rates, nil +} + +func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation string, rates map[string]string) (v1.InstanceType, error) { + typeName := strings.TrimSpace(stringValue(providerType.Name)) + location := strings.TrimSpace(stringValue(providerType.RegionName)) + if location == "" { + location = fallbackLocation + } + if typeName == "" || location == "" { + return v1.InstanceType{}, nil + } + + memoryGB := int64(math.Round(float64(float32Value(providerType.Ram)))) + memory, memoryBytes := byteSizes(memoryGB, v1.Gigabyte) + + storageGB := int64(intValue(providerType.Disk)) + storage, storageBytes := byteSizes(storageGB, v1.Gigabyte) + + gpuType := strings.TrimSpace(stringValue(providerType.Gpu)) + gpuCount := intValue(providerType.GpuCount) + + basePrice, err := flavorPrice(providerType, rates) + if err != nil { + return v1.InstanceType{}, fmt.Errorf("price hyperstack instance type %s in %s: %w", typeName, location, err) + } + + usageClass := "on-demand" + preemptible := isSpotFlavor(typeName, gpuType) + if preemptible { + usageClass = "spot" + } + instanceType := v1.InstanceType{ + Type: typeName, + Location: location, + Memory: memory, + MemoryBytes: memoryBytes, + VCPU: int32(intValue(providerType.Cpu)), //nolint:gosec // ok + SupportedArchitectures: []v1.Architecture{hyperstackArchitecture(gpuType)}, + SupportedUsageClasses: []string{usageClass}, + Preemptible: preemptible, + Stoppable: true, + IsAvailable: providerType.StockAvailable == nil || *providerType.StockAvailable, + BasePrice: basePrice, + Provider: CloudProviderID, + } + if storageGB > 0 { + instanceType.SupportedStorage = []v1.Storage{{ + Type: "ssd", + Count: 1, + Size: storage, + SizeBytes: storageBytes, + }} + } + if ephemeralGB := int64(intValue(providerType.Ephemeral)); ephemeralGB > 0 { + ephemeral, ephemeralBytes := byteSizes(ephemeralGB, v1.Gigabyte) + instanceType.SupportedStorage = append(instanceType.SupportedStorage, v1.Storage{ + Type: "ephemeral", + Count: 1, + Size: ephemeral, + SizeBytes: ephemeralBytes, + IsEphemeral: true, + IsAdditionalDisk: true, + RequiresVolumeMountPath: true, + }) + } + if gpuCount > 0 && gpuType != "" { + instanceType.SupportedGPUs = []v1.GPU{hyperstackGPU(gpuType, gpuCount)} + } + instanceType.ID = v1.MakeGenericInstanceTypeID(instanceType) + return instanceType, nil +} + +func hyperstackArchitecture(gpuType string) v1.Architecture { + gpuType = strings.ToUpper(strings.TrimSpace(gpuType)) + if strings.HasPrefix(gpuType, "GH") || strings.HasPrefix(gpuType, "GB") { + return v1.ArchitectureARM64 + } + return v1.ArchitectureX86_64 +} + +func flavorPrice(providerType flavor.FlavorFields, rates map[string]string) (*currency.Amount, error) { + gpuCount := intValue(providerType.GpuCount) + gpuType := strings.TrimSpace(stringValue(providerType.Gpu)) + if gpuCount > 0 && gpuType != "" { + price, err := rateCost(rates, gpuType, gpuCount) + return &price, err + } + + zero, err := currency.NewAmount("0", "USD") + if err != nil { + return nil, err + } + total := zero + resources := []struct { + name string + count int + }{ + {name: "vCPU (cpu-only-flavors)", count: intValue(providerType.Cpu)}, + {name: "RAM (cpu-only-flavors)", count: int(math.Round(float64(float32Value(providerType.Ram))))}, + {name: "hypervisor-local-storage (cpu-only-flavors)", count: intValue(providerType.Disk) + intValue(providerType.Ephemeral)}, + } + for _, resource := range resources { + cost, err := rateCost(rates, resource.name, resource.count) + if err != nil { + return nil, err + } + total, err = total.Add(cost) + if err != nil { + return nil, err + } + } + return &total, nil +} + +func rateCost(rates map[string]string, resource string, count int) (currency.Amount, error) { + rate, ok := rates[strings.ToLower(resource)] + if !ok { + return currency.Amount{}, fmt.Errorf("pricebook has no rate for %q", resource) + } + amount, err := currency.NewAmount(rate, "USD") + if err != nil { + return currency.Amount{}, fmt.Errorf("parse %q rate %q: %w", resource, rate, err) + } + return amount.Mul(strconv.Itoa(count)) +} + +func hyperstackGPU(providerGPU string, count int) v1.GPU { + gpuType := strings.TrimSuffix(providerGPU, "-spot") + memoryGB := gpuMemoryGB(gpuType) + memory, memoryBytes := byteSizes(memoryGB, v1.Gigabyte) + return v1.GPU{ + Count: int32(count), //nolint:gosec // ok + Memory: memory, + MemoryBytes: memoryBytes, + NetworkDetails: gpuNetworkDetails(gpuType), + Manufacturer: v1.ManufacturerNVIDIA, + Name: gpuName(gpuType), + Type: gpuType, + } +} + +func gpuMemoryGB(gpuType string) int64 { + if matches := gpuMemoryPattern.FindStringSubmatch(gpuType); len(matches) == 2 { + memoryGB, _ := strconv.ParseInt(matches[1], 10, 64) + return memoryGB + } + knownMemory := map[string]int64{ + "B200": 180, + "B300": 288, + "L40": 48, + "L40S": 48, + "RTX-A4000": 16, + "RTX-A6000": 48, + "RTX-PRO6000-SE": 96, + } + upperType := strings.ToUpper(gpuType) + for model, memoryGB := range knownMemory { + if strings.Contains(upperType, model) { + return memoryGB + } + } + return 0 +} + +func gpuName(gpuType string) string { + knownNames := []string{"RTX-PRO6000-SE", "RTX-A6000", "RTX-A4000", "B300", "B200", "H200", "H100", "A100", "L40S", "L40"} + upperType := strings.ToUpper(gpuType) + for _, name := range knownNames { + if strings.Contains(upperType, name) { + return name + } + } + return gpuType +} + +func gpuNetworkDetails(gpuType string) string { + upperType := strings.ToUpper(gpuType) + switch { + case strings.Contains(upperType, "NVLINK"): + return "NVLink" + case strings.Contains(upperType, "PCIE"): + return "PCIe" + case strings.Contains(upperType, "SXM6"): + return "SXM6" + case strings.Contains(upperType, "SXM5"): + return "SXM5" + case strings.Contains(upperType, "SXM4"): + return "SXM4" + case strings.Contains(upperType, "SXM"): + return "SXM" + default: + return "" + } +} + +func isSpotFlavor(typeName, gpuType string) bool { + return strings.HasSuffix(strings.ToLower(typeName), "-spot") || strings.HasSuffix(strings.ToLower(gpuType), "-spot") +} + +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 stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func float32Value(value *float32) float32 { + if value == nil { + return 0 + } + return *value +} + +func (c *HyperstackClient) GetInstanceTypePollTime() time.Duration { + return time.Minute +} diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go new file mode 100644 index 0000000..4e1a843 --- /dev/null +++ b/v1/providers/hyperstack/instancetype_test.go @@ -0,0 +1,126 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/brevdev/cloud/v1" +) + +func TestGetInstanceTypesMapsFlavor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + assert.Equal(t, "api-key", request.Header.Get("api_key")) + switch request.URL.Path { + case "/v1/core/flavors": + writeJSON(t, w, map[string]any{ + "status": true, + "data": []map[string]any{{ + "region_name": "CANADA-1", + "flavors": []map[string]any{{ + "name": "n3-H100x2", + "cpu": 56, + "ram": 360, + "disk": 100, + "ephemeral": 1500, + "gpu": "H100-80G-PCIe", + "gpu_count": 2, + "stock_available": true, + }}, + }}, + }) + case "/v1/pricebook": + writeJSON(t, w, []map[string]any{{"name": "H100-80G-PCIe", "value": "2.5"}}) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + instanceTypes, err := client.GetInstanceTypes(context.Background(), v1.GetInstanceTypeArgs{}) + require.NoError(t, err) + require.Len(t, instanceTypes, 1) + + instanceType := instanceTypes[0] + assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x2"), instanceType.ID) + assert.Equal(t, "5.0", instanceType.BasePrice.Number()) + require.Len(t, instanceType.SupportedGPUs, 1) + assert.Equal(t, "H100", instanceType.SupportedGPUs[0].Name) + assert.Equal(t, int32(2), instanceType.SupportedGPUs[0].Count) + require.Len(t, instanceType.SupportedStorage, 2) + assert.Equal(t, "ssd", instanceType.SupportedStorage[0].Type) + assert.Equal(t, "ephemeral", instanceType.SupportedStorage[1].Type) +} + +func TestHyperstackArchitecture(t *testing.T) { + tests := []struct { + gpuType string + want v1.Architecture + }{ + {gpuType: "GH200", want: v1.ArchitectureARM64}, + {gpuType: "GB200", want: v1.ArchitectureARM64}, + {gpuType: "H100", want: v1.ArchitectureX86_64}, + } + + for _, tt := range tests { + t.Run(tt.gpuType, func(t *testing.T) { + assert.Equal(t, tt.want, hyperstackArchitecture(tt.gpuType)) + }) + } +} + +func TestGetLocationsMapsRegions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v1/core/regions": + writeJSON(t, w, map[string]any{ + "status": true, + "regions": []map[string]any{ + {"name": "CANADA-1", "country": "CA", "features": map[string]any{"floating_ip": true}}, + {"name": "NORWAY-1", "country": "NO", "features": map[string]any{"floating_ip": true}}, + }, + }) + case "/v1/core/flavors": + writeJSON(t, w, map[string]any{ + "status": true, + "data": []map[string]any{{ + "region_name": "CANADA-1", + "flavors": []map[string]any{{"name": "n1-cpu-small", "stock_available": true}}, + }}, + }) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/v1") + locations, err := client.GetLocations(context.Background(), v1.GetLocationsArgs{IncludeUnavailable: true}) + require.NoError(t, err) + require.Len(t, locations, 2) + assert.Equal(t, "CANADA-1", locations[0].Name) + assert.True(t, locations[0].Available) + assert.Equal(t, "CAN", locations[0].Country) + assert.False(t, locations[1].Available) +} + +func newTestClient(t *testing.T, apiURL string) *HyperstackClient { + t.Helper() + credential := NewHyperstackCredential("credential-ref", "api-key") + credential.APIURL = apiURL + client, err := NewHyperstackClient(*credential, "") + require.NoError(t, err) + return client +} + +func writeJSON(t *testing.T, writer http.ResponseWriter, value any) { + t.Helper() + writer.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(writer).Encode(value)) +} diff --git a/v1/providers/hyperstack/keypair.go b/v1/providers/hyperstack/keypair.go new file mode 100644 index 0000000..4f027c6 --- /dev/null +++ b/v1/providers/hyperstack/keypair.go @@ -0,0 +1,151 @@ +package hyperstack + +import ( + "context" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/NexGenCloud/hyperstack-sdk-go/lib/keypair" + "golang.org/x/crypto/ssh" + + v1 "github.com/brevdev/cloud/v1" +) + +type resolvedKeyPair struct { + name string + managedID int +} + +func (c *HyperstackClient) resolveKeyPair(ctx context.Context, attrs v1.CreateInstanceAttrs, environmentName string) (resolvedKeyPair, error) { + keyPairName := attrs.RefID + if attrs.KeyPairName != nil { + keyPairName = *attrs.KeyPairName + } + keyPairName = strings.TrimSpace(keyPairName) + if strings.TrimSpace(attrs.PublicKey) == "" { + return resolvedKeyPair{name: keyPairName}, nil + } + + publicKey, err := normalizeSSHPublicKey(attrs.PublicKey) + if err != nil { + return resolvedKeyPair{}, err + } + keyID, err := c.findKeyPair(ctx, keyPairName, environmentName, publicKey) + if err != nil { + return resolvedKeyPair{}, err + } + if keyID != 0 { + resolved := resolvedKeyPair{name: keyPairName} + if attrs.KeyPairName == nil { + resolved.managedID = keyID + } + return resolved, nil + } + + response, err := c.keypairs.ImportKeyPairWithResponse(ctx, keypair.ImportKeypairPayload{ + EnvironmentName: environmentName, + Name: keyPairName, + PublicKey: publicKey, + }) + if err != nil { + return resolvedKeyPair{}, wrapTransportError("import key pair", err) + } + if response.StatusCode() != http.StatusOK { + return resolvedKeyPair{}, responseError("import key pair", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Keypair == nil || response.JSON200.Keypair.Id == nil || *response.JSON200.Keypair.Id <= 0 { + return resolvedKeyPair{}, errors.New("hyperstack import key pair response did not contain a keypair ID") + } + return resolvedKeyPair{name: keyPairName, managedID: *response.JSON200.Keypair.Id}, nil +} + +func (c *HyperstackClient) findKeyPair(ctx context.Context, keyName, environmentName, publicKey string) (int, error) { + pageSize := strconv.Itoa(defaultPageSize) + for page := 1; ; page++ { + pageNumber := strconv.Itoa(page) + response, err := c.keypairs.ListKeyPairsWithResponse(ctx, &keypair.ListKeyPairsParams{ + Page: &pageNumber, + PageSize: &pageSize, + Search: &keyName, + }) + if err != nil { + return 0, wrapTransportError("list key pairs", err) + } + if response.StatusCode() != http.StatusOK { + return 0, responseError("list key pairs", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Keypairs == nil { + return 0, errors.New("hyperstack list key pairs response did not contain data") + } + + providerKeys := *response.JSON200.Keypairs + keyID, matchErr := matchingKeyPair(providerKeys, keyName, environmentName, publicKey) + if matchErr != nil || keyID != 0 { + return keyID, matchErr + } + if len(providerKeys) < defaultPageSize { + return 0, nil + } + } +} + +func matchingKeyPair(providerKeys []keypair.KeypairFields, keyName, environmentName, publicKey string) (int, error) { + for _, providerKey := range providerKeys { + if stringValue(providerKey.Name) != keyName || providerKey.Environment == nil || stringValue(providerKey.Environment.Name) != environmentName { + continue + } + existingKey, err := normalizeSSHPublicKey(stringValue(providerKey.PublicKey)) + if err != nil || existingKey != publicKey { + return 0, fmt.Errorf("hyperstack key pair %q already exists with a different public key", keyName) + } + if providerKey.Id == nil || *providerKey.Id <= 0 { + return 0, fmt.Errorf("hyperstack key pair %q did not contain an ID", keyName) + } + return *providerKey.Id, nil + } + return 0, nil +} + +func (c *HyperstackClient) deleteManagedKeyPair(ctx context.Context, keyPairID int) error { + response, err := c.keypairs.DeleteKeyPairWithResponse(ctx, keyPairID) + if err != nil { + return wrapTransportError("delete managed key pair", err) + } + if response.StatusCode() == http.StatusNotFound { + return nil + } + if response.StatusCode() != http.StatusOK { + return responseError("delete managed key pair", response.StatusCode(), response.Body, nil) + } + return 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("hyperstack 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 hyperstack PEM public key: %w", errors.Join(pkixErr, pkcs1Err)) + } + parsedKey = rsaKey + } + key, err := ssh.NewPublicKey(parsedKey) + if err != nil { + return "", fmt.Errorf("convert hyperstack public key to OpenSSH: %w", err) + } + return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), nil +} diff --git a/v1/providers/hyperstack/location.go b/v1/providers/hyperstack/location.go new file mode 100644 index 0000000..5ec4622 --- /dev/null +++ b/v1/providers/hyperstack/location.go @@ -0,0 +1,112 @@ +package hyperstack + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/NexGenCloud/hyperstack-sdk-go/lib/flavor" + "github.com/NexGenCloud/hyperstack-sdk-go/lib/region" + + v1 "github.com/brevdev/cloud/v1" +) + +func (c *HyperstackClient) GetLocations(ctx context.Context, args v1.GetLocationsArgs) ([]v1.Location, error) { + response, err := c.regions.ListRegionsWithResponse(ctx) + if err != nil { + return nil, wrapTransportError("list regions", err) + } + if response.StatusCode() != 200 { + return nil, responseError("list regions", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Regions == nil { + return nil, fmt.Errorf("hyperstack list regions response did not contain data") + } + regions := *response.JSON200.Regions + + allFlavors, err := c.listFlavors(ctx) + if err != nil { + return nil, err + } + regionsWithAvailability := availableFlavorLocations(allFlavors) + + locations := make([]v1.Location, 0, len(regions)) + for _, providerRegion := range regions { + location, ok := hyperstackLocation(providerRegion, regionsWithAvailability) + if !ok { + continue + } + if !args.IncludeUnavailable && !location.Available { + continue + } + locations = append(locations, location) + } + sort.Slice(locations, func(i, j int) bool { + return locations[i].Name < locations[j].Name + }) + return locations, nil +} + +func availableFlavorLocations(groups []flavor.FlavorItemGetResponse) map[string]bool { + available := make(map[string]bool) + for _, group := range groups { + if group.Flavors == nil { + continue + } + for _, providerType := range *group.Flavors { + location := stringValue(group.RegionName) + if location == "" { + location = stringValue(providerType.RegionName) + } + if providerType.StockAvailable == nil || *providerType.StockAvailable { + available[location] = true + } + } + } + return available +} + +func hyperstackLocation(providerRegion region.RegionFields, regionsWithAvailability map[string]bool) (v1.Location, bool) { + regionName := strings.TrimSpace(stringValue(providerRegion.Name)) + if regionName == "" { + return v1.Location{}, false + } + description := strings.TrimSpace(stringValue(providerRegion.Description)) + if description == "" { + description = regionName + } + return v1.Location{ + Name: regionName, + Description: description, + Available: regionsWithAvailability[regionName] && supportsFloatingIP(providerRegion.Features), + Country: countryAlpha3(stringValue(providerRegion.Country)), + }, true +} + +// SupportsFloatingIP effectively checks if the region supports public IP addresses +// See: https://docs.hyperstack.cloud/docs/api-reference/floating-ip/ +func supportsFloatingIP(features *map[string]interface{}) bool { + if features == nil { + return true + } + value, found := (*features)["floating_ip"] + if !found { + return true + } + supported, ok := value.(bool) + return ok && supported +} + +func countryAlpha3(alpha2 string) string { + switch strings.ToUpper(strings.TrimSpace(alpha2)) { + case "CA": + return "CAN" + case "NO": + return "NOR" + case "US": + return "USA" + default: + return strings.ToUpper(strings.TrimSpace(alpha2)) + } +} diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go new file mode 100644 index 0000000..96303bd --- /dev/null +++ b/v1/providers/hyperstack/readiness.go @@ -0,0 +1,160 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + v1 "github.com/brevdev/cloud/v1" +) + +const ( + readinessMarker = "BREV_CLOUD_READY_V1" + consoleLogLineCount = 200 + consoleLogPolls = 5 + consoleLogPollPeriod = 500 * time.Millisecond + readinessLabel = "brev-cloud-ready" + readinessCloudConfig = `#cloud-config +write_files: + - path: /etc/systemd/system/brev-cloud-ready.service + permissions: '0644' + content: | + [Unit] + Description=Brev cloud instance readiness signal + Wants=network-online.target + After=network-online.target + + [Service] + Type=oneshot + ExecStart=/bin/sh -c 'printf "BREV_CLOUD_READY_V1\n" > /dev/ttyS0' + + [Install] + WantedBy=multi-user.target +runcmd: + - [systemctl, daemon-reload] + - [systemctl, enable, brev-cloud-ready.service] + - [systemctl, start, --no-block, brev-cloud-ready.service] +` +) + +// Hyperstack is somewhat silly with its statuses. The VM instance itself can achieve "readiness," with the API indicating as such, +// but the operating system itself may not have fully booted yet. In order to get around this, at VM provision time we create a oneshot +// systemd service that write a "BREV_CLOUD_READY_V1" marker to the serial console. We then poll the console for this marker, and if it's found, +// we consider the VM ready. The 'vmOperatingSystemReportsReady' therefore returns true if the marker is found in the console logs. In order +// to speed up this check, this function has a side-effect of adding the readiness label to the VM if the marker is found in the console logs. +// See: https://docs.hyperstack.cloud/docs/virtual-machines/virtual-machine-features/#managing-virtual-machines +func (c *HyperstackClient) vmOperatingSystemReportsReady(ctx context.Context, providerInstance virtualmachine.InstanceFields) (bool, error) { + // If the VM has the readiness label, we consider it ready. + if readinessLabelExists(providerInstance) { + return true, nil + } + + instanceID := intValue(providerInstance.Id) + requestID, err := c.requestConsoleLogs(ctx, instanceID) + if err != nil || requestID == 0 { + return false, err + } + + for poll := 0; poll < consoleLogPolls; poll++ { + // Fetch the logs from the console. + response, err := c.virtualMachines.GetVMLogsWithResponse(ctx, instanceID, &virtualmachine.GetVMLogsParams{ + RequestId: requestID, + }) + if err != nil { + if ctx.Err() != nil { + return false, ctx.Err() + } + return false, nil + } + + switch response.StatusCode() { + case http.StatusOK: + if response.JSON200 != nil && response.JSON200.Logs != nil { + readinessMarkerFound := strings.Contains(*response.JSON200.Logs, readinessMarker) + c.updateReadinessLabel(ctx, providerInstance, readinessMarkerFound) + return readinessMarkerFound, nil + } + case http.StatusAccepted, http.StatusBadRequest: + // Hyperstack returns either status while the asynchronous request is processing. + case http.StatusUnauthorized, http.StatusForbidden: + return false, responseError("get virtual machine console logs", response.StatusCode(), response.Body, nil) + default: + return false, nil + } + + if poll < consoleLogPolls-1 { + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(consoleLogPollPeriod): + } + } + } + return false, nil +} + +func (c *HyperstackClient) requestConsoleLogs(ctx context.Context, instanceID int) (int, error) { + lineCount := consoleLogLineCount + response, err := c.virtualMachines.RequestVMLogsWithResponse(ctx, instanceID, virtualmachine.RequestInstanceLogsPayload{ + Length: &lineCount, + }) + if err != nil { + if ctx.Err() != nil { + return 0, ctx.Err() + } + return 0, nil + } + if response.StatusCode() == http.StatusUnauthorized || response.StatusCode() == http.StatusForbidden { + return 0, responseError("request virtual machine console logs", response.StatusCode(), response.Body, nil) + } + if response.StatusCode() != http.StatusOK && response.StatusCode() != http.StatusAccepted { + return 0, nil + } + + var payload struct { + RequestID int `json:"request_id"` + } + if err := json.Unmarshal(response.Body, &payload); err != nil { + return 0, nil + } + if payload.RequestID <= 0 { + return 0, nil + } + return payload.RequestID, nil +} + +func readinessLabelExists(providerInstance virtualmachine.InstanceFields) bool { + if providerInstance.Labels == nil { + return false + } + return slices.Contains(*providerInstance.Labels, readinessLabel) +} + +func (c *HyperstackClient) updateReadinessLabel(ctx context.Context, providerInstance virtualmachine.InstanceFields, readinessMarkerFound bool) { + if !readinessMarkerFound { + return + } + + labels := []string{} + if providerInstance.Labels != nil { + labels = *providerInstance.Labels + } + labels = append(labels, readinessLabel) + + response, err := c.virtualMachines.AddVMLabelWithResponse(ctx, *providerInstance.Id, virtualmachine.AddVMLabelJSONRequestBody{ + Labels: &labels, + }) + if err != nil { + c.logger.Warn(ctx, fmt.Sprintf("error adding virtual machine label: %v", err), v1.LogField("instance_id", *providerInstance.Id)) + return + } + + if response.StatusCode() != http.StatusOK { + c.logger.Warn(ctx, fmt.Sprintf("error adding virtual machine label: %v", response.StatusCode()), v1.LogField("instance_id", *providerInstance.Id)) + } +} diff --git a/v1/providers/hyperstack/security_rules.go b/v1/providers/hyperstack/security_rules.go new file mode 100644 index 0000000..df3c238 --- /dev/null +++ b/v1/providers/hyperstack/security_rules.go @@ -0,0 +1,51 @@ +package hyperstack + +import ( + "fmt" + "net" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + + v1 "github.com/brevdev/cloud/v1" +) + +func makeSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSecurityRulePayload, error) { + securityRules := make([]virtualmachine.CreateSecurityRulePayload, 0, len(rules.IngressRules)) + seen := make(map[string]bool) + + addRule := func(ipRange, etherType string, fromPort, toPort int) { + key := fmt.Sprintf("%s|%s|%d|%d", etherType, ipRange, fromPort, toPort) + if seen[key] { + return + } + seen[key] = true + securityRules = append(securityRules, virtualmachine.CreateSecurityRulePayload{ + Direction: "ingress", + Ethertype: etherType, + Protocol: virtualmachine.Tcp, + RemoteIpPrefix: ipRange, + PortRangeMin: &fromPort, + PortRangeMax: &toPort, + }) + } + + for _, rule := range rules.IngressRules { + fromPort := int(rule.FromPort) + toPort := int(rule.ToPort) + if fromPort < 1 || toPort < fromPort || toPort > 65535 { + return nil, fmt.Errorf("invalid hyperstack ingress port range %d-%d", fromPort, toPort) + } + for _, ipRange := range rule.IPRanges { + ip, _, err := net.ParseCIDR(ipRange) + if err != nil { + return nil, fmt.Errorf("parse hyperstack ingress CIDR %q: %w", ipRange, err) + } + etherType := "IPv4" + if ip.To4() == nil { + etherType = "IPv6" + } + addRule(ipRange, etherType, fromPort, toPort) + } + } + return securityRules, nil +} diff --git a/v1/providers/hyperstack/validation_test.go b/v1/providers/hyperstack/validation_test.go new file mode 100644 index 0000000..12e7360 --- /dev/null +++ b/v1/providers/hyperstack/validation_test.go @@ -0,0 +1,103 @@ +package hyperstack + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/brevdev/cloud/internal/validation" + v1 "github.com/brevdev/cloud/v1" +) + +func TestValidationFunctions(t *testing.T) { + checkValidationCredential(t) + credential := validationCredential() + + validation.RunValidationSuite(t, validation.ProviderConfig{ + Credential: credential, + StableIDs: getStableInstanceTypeIDs(t, credential), + }) +} + +func TestInstanceLifecycleValidation(t *testing.T) { + checkValidationCredential(t) + credential := validationCredential() + + validation.RunInstanceLifecycleValidation(t, validation.ProviderConfig{ + Credential: credential, + StableIDs: getStableInstanceTypeIDs(t, credential), + CreateInstanceAttrs: v1.CreateInstanceAttrs{FirewallRules: v1.FirewallRules{ + IngressRules: []v1.FirewallRule{{ + FromPort: 22, + ToPort: 22, + IPRanges: []string{"0.0.0.0/0"}, + }}, + }}, + }) +} + +func TestGetLocations(t *testing.T) { + checkValidationCredential(t) + credential := validationCredential() + + client, err := credential.MakeClient(context.Background(), "") + require.NoError(t, err) + locations, err := client.GetLocations(context.Background(), v1.GetLocationsArgs{}) + require.NoError(t, err) + require.NotEmpty(t, locations) + for _, location := range locations { + fmt.Println(location.Name) + } +} + +func TestGetInstanceTypes(t *testing.T) { + checkValidationCredential(t) + credential := validationCredential() + + client, err := credential.MakeClient(context.Background(), "") + require.NoError(t, err) + instanceTypes, err := client.GetInstanceTypes(context.Background(), v1.GetInstanceTypeArgs{}) + require.NoError(t, err) + require.NotEmpty(t, instanceTypes) +} + +func checkValidationCredential(t *testing.T) { + t.Helper() + if os.Getenv("HYPERSTACK_API_KEY") != "" { + return + } + if os.Getenv("VALIDATION_TEST") != "" { + t.Fatal("HYPERSTACK_API_KEY must be set when VALIDATION_TEST is set") + } + t.Skip("HYPERSTACK_API_KEY not set; skipping Hyperstack validation tests") +} + +func validationCredential() *HyperstackCredential { + credential := NewHyperstackCredential("validation-test", os.Getenv("HYPERSTACK_API_KEY")) + if apiURL := os.Getenv("HYPERSTACK_API_URL"); apiURL != "" { + credential.APIURL = apiURL + } + return credential +} + +func getStableInstanceTypeIDs(t *testing.T, credential *HyperstackCredential) []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 +}