From a18703a8e84d29575409c9b30586484682c7ec63 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 11 Sep 2026 19:09:46 -0700 Subject: [PATCH 01/23] save --- go.mod | 5 ++++- go.sum | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) 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= From 525dcdf22cb4bc074c87abaa8ccec62b53cc4ed7 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 15:28:52 -0700 Subject: [PATCH 02/23] initial implementation --- .github/workflows/validation-hyperstack.yml | 49 ++ v1/networking_validation.go | 8 +- v1/providers/hyperstack/README.md | 17 + v1/providers/hyperstack/SECURITY.md | 9 + v1/providers/hyperstack/capabilities.go | 23 + v1/providers/hyperstack/client.go | 210 ++++++++ v1/providers/hyperstack/client_test.go | 33 ++ v1/providers/hyperstack/environment.go | 47 ++ v1/providers/hyperstack/errors.go | 48 ++ v1/providers/hyperstack/instance.go | 496 +++++++++++++++++++ v1/providers/hyperstack/instance_test.go | 361 ++++++++++++++ v1/providers/hyperstack/instancetype.go | 309 ++++++++++++ v1/providers/hyperstack/instancetype_test.go | 131 +++++ v1/providers/hyperstack/keypair.go | 151 ++++++ v1/providers/hyperstack/location.go | 109 ++++ v1/providers/hyperstack/readiness.go | 156 ++++++ v1/providers/hyperstack/security_rules.go | 52 ++ v1/providers/hyperstack/validation_test.go | 96 ++++ 18 files changed, 2303 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/validation-hyperstack.yml create mode 100644 v1/providers/hyperstack/README.md create mode 100644 v1/providers/hyperstack/SECURITY.md create mode 100644 v1/providers/hyperstack/capabilities.go create mode 100644 v1/providers/hyperstack/client.go create mode 100644 v1/providers/hyperstack/client_test.go create mode 100644 v1/providers/hyperstack/environment.go create mode 100644 v1/providers/hyperstack/errors.go create mode 100644 v1/providers/hyperstack/instance.go create mode 100644 v1/providers/hyperstack/instance_test.go create mode 100644 v1/providers/hyperstack/instancetype.go create mode 100644 v1/providers/hyperstack/instancetype_test.go create mode 100644 v1/providers/hyperstack/keypair.go create mode 100644 v1/providers/hyperstack/location.go create mode 100644 v1/providers/hyperstack/readiness.go create mode 100644 v1/providers/hyperstack/security_rules.go create mode 100644 v1/providers/hyperstack/validation_test.go 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/v1/networking_validation.go b/v1/networking_validation.go index 8fc1b87..4564815 100644 --- a/v1/networking_validation.go +++ b/v1/networking_validation.go @@ -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/README.md b/v1/providers/hyperstack/README.md new file mode 100644 index 0000000..b9bc6a9 --- /dev/null +++ b/v1/providers/hyperstack/README.md @@ -0,0 +1,17 @@ +# Hyperstack Provider + +This package implements the minimal Brev Cloud v1 compute surface for Hyperstack using the official Go SDK. + +Supported capabilities are instance creation and termination. Locations map to Hyperstack regions, instance types map to flavors, and creation resolves the region's `default-` environment through the API. `CreateInstanceAttrs.ImageID` is treated as a Hyperstack image name; when omitted, the provider uses Ubuntu Server 22.04 LTS (Jammy Jellyfish). + +VMs are created with a direct inbound TCP/22 security rule for bootstrap SSH access. Caller-provided ingress ports are included as additional direct per-VM rules. Outbound behavior is derived by Hyperstack. + +## Read-only validation + +Set `HYPERSTACK_API_KEY` and optionally `HYPERSTACK_API_URL`, then run: + +```sh +go test -run TestReadOnlyValidation ./v1/providers/hyperstack +``` + +This validation only lists regions, flavors, prices, and existing virtual machines. It never creates, updates, or deletes a Hyperstack resource. diff --git a/v1/providers/hyperstack/SECURITY.md b/v1/providers/hyperstack/SECURITY.md new file mode 100644 index 0000000..7105b41 --- /dev/null +++ b/v1/providers/hyperstack/SECURITY.md @@ -0,0 +1,9 @@ +# Hyperstack Security Notes + +Hyperstack VM creation receives the requested SSH public key through an environment-scoped keypair. Key lookup follows all result pages before importing a new key. Port randomization is disabled so Brev can consistently use SSH port 22. + +The provider creates each VM with a direct security rule allowing public IPv4 SSH ingress on port 22. Caller-provided TCP ingress ranges are added as direct per-VM rules. Outbound behavior is derived by Hyperstack and is not represented by explicit rules. All other inbound traffic remains denied. + +Enhanced monitoring is explicitly disabled when creating VMs. This prevents opting the VM into the guest-installed Hyperstack VM Agent and its metrics ingestion gateway. Hyperstack's platform-level VM metrics are managed by the provider and do not expose a documented per-VM opt-out. + +API authentication uses the `api_key` header over Hyperstack's HTTPS endpoint. API keys are never returned from provider methods; tenant identity is derived from a one-way hash of the key. diff --git a/v1/providers/hyperstack/capabilities.go b/v1/providers/hyperstack/capabilities.go new file mode 100644 index 0000000..36f02d5 --- /dev/null +++ b/v1/providers/hyperstack/capabilities.go @@ -0,0 +1,23 @@ +package hyperstack + +import ( + "context" + + v1 "github.com/brevdev/cloud/v1" +) + +func getCapabilities() v1.Capabilities { + return v1.Capabilities{ + v1.CapabilityCreateInstance, + v1.CapabilityTerminateInstance, + v1.CapabilityCreateTerminateInstance, + } +} + +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..98a4568 --- /dev/null +++ b/v1/providers/hyperstack/client.go @@ -0,0 +1,210 @@ +package hyperstack + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + + 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 + virtualMachines *virtualmachine.ClientWithResponses + environments *environment.ClientWithResponses + flavors *flavor.ClientWithResponses + keypairs *keypair.ClientWithResponses + regions *region.ClientWithResponses + pricebook *pricebook.Client + readinessMu *sync.Mutex + logRequests map[int]int + readyInstances map[int]struct{} +} + +var _ v1.CloudClient = &HyperstackClient{} + +type HyperstackClientOption func(*HyperstackClient) + +func WithHTTPClient(httpClient *http.Client) HyperstackClientOption { + return func(c *HyperstackClient) { + c.httpClient = httpClient + } +} + +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, + readinessMu: &sync.Mutex{}, + logRequests: make(map[int]int), + readyInstances: make(map[int]struct{}), + } + 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("User-Agent", "brev-cloud") + 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/client_test.go b/v1/providers/hyperstack/client_test.go new file mode 100644 index 0000000..3af7324 --- /dev/null +++ b/v1/providers/hyperstack/client_test.go @@ -0,0 +1,33 @@ +package hyperstack + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/brevdev/cloud/v1" +) + +func TestHyperstackCredential(t *testing.T) { + credential := NewHyperstackCredential("credential-ref", "api-key") + + assert.Equal(t, DefaultAPIURL, credential.APIURL) + assert.Equal(t, v1.CloudProviderID(CloudProviderID), credential.GetCloudProviderID()) + assert.Equal(t, v1.APITypeGlobal, credential.GetAPIType()) + assert.Equal(t, "credential-ref", credential.GetReferenceID()) + require.NoError(t, credential.Validate()) + + tenantID, err := credential.GetTenantID() + require.NoError(t, err) + assert.NotEmpty(t, tenantID) + assert.NotContains(t, tenantID, credential.APIKey) + + capabilities, err := credential.GetCapabilities(context.Background()) + require.NoError(t, err) + assert.Equal(t, getCapabilities(), capabilities) + + invalid := NewHyperstackCredential("credential-ref", "") + require.Error(t, invalid.Validate()) +} diff --git a/v1/providers/hyperstack/environment.go b/v1/providers/hyperstack/environment.go new file mode 100644 index 0000000..7ef5fc2 --- /dev/null +++ b/v1/providers/hyperstack/environment.go @@ -0,0 +1,47 @@ +package hyperstack + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/NexGenCloud/hyperstack-sdk-go/lib/environment" +) + +func (c *HyperstackClient) getDefaultEnvironment(ctx context.Context, location string) (environment.EnvironmentFields, error) { + environmentName := defaultEnvironmentTag + location + pageSize := strconv.Itoa(defaultPageSize) + for page := 1; ; page++ { + pageNumber := strconv.Itoa(page) + response, err := c.environments.ListEnvironmentsWithResponse(ctx, &environment.ListEnvironmentsParams{ + Page: &pageNumber, + PageSize: &pageSize, + Search: &environmentName, + }) + if err != nil { + return environment.EnvironmentFields{}, wrapTransportError("list environments", err) + } + if response.StatusCode() != http.StatusOK { + return environment.EnvironmentFields{}, responseError("list environments", response.StatusCode(), response.Body, nil) + } + if response.JSON200 == nil || response.JSON200.Environments == nil { + return environment.EnvironmentFields{}, errors.New("hyperstack list environments response did not contain data") + } + + providerEnvironments := *response.JSON200.Environments + 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) < defaultPageSize { + return environment.EnvironmentFields{}, fmt.Errorf("hyperstack environment %q was not found in location %q", environmentName, location) + } + } +} diff --git a/v1/providers/hyperstack/errors.go b/v1/providers/hyperstack/errors.go new file mode 100644 index 0000000..5286138 --- /dev/null +++ b/v1/providers/hyperstack/errors.go @@ -0,0 +1,48 @@ +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..908890b --- /dev/null +++ b/v1/providers/hyperstack/instance.go @@ -0,0 +1,496 @@ +package hyperstack + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "slices" + "sort" + "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-" + cloudRefLabelPrefix = "brev-cloud-ref-" + tagLabelPrefix = "brev-tag-" + managedKeyIDLabelPrefix = "brev-managed-key-id-" + defaultEnvironmentTag = "default-" +) + +var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) + +func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { + 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 := makeDirectSecurityRules(attrs.FirewallRules) + if err != nil { + return nil, err + } + labels := makeLabels(attrs.RefID, c.refID, attrs.Tags) + if keyPair.managedID != 0 { + labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPair.managedID)) + } + labels = append(labels, readinessLabel) + assignFloatingIP := true + enablePortRandomization := false + enhancedMonitoringEnabled := false + userData := readinessCloudConfig + + response, err := c.virtualMachines.CreateVMsWithResponse(ctx, virtualmachine.CreateInstancesPayload{ + Name: managedResourceName(attrs.Name, 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 attrs.DiskSize != 0 || attrs.DiskSizeBytes.Value() != 0: + return errors.New("hyperstack provider does not support custom root disk sizes") + 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) 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 requiresConsoleReadiness(providerInstance.Labels) && + hyperstackLifecycleStatus(stringValue(providerInstance.Status)) == v1.LifecycleStatusRunning && + hyperstackAPIReady(providerInstance, strings.TrimSpace(stringValue(providerInstance.FloatingIp))) { + instanceID := intValue(providerInstance.Id) + if instanceID <= 0 { + return v1.Instance{}, errors.New("hyperstack virtual machine response did not contain an instance ID") + } + var err error + consoleReady, err = c.consoleReady(ctx, instanceID) + 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, cloudCredRefID, tags := parseLabels(providerInstance.Labels) + if refID == "" { + refID = name + } + if cloudCredRefID == "" { + cloudCredRefID = c.refID + } + + 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 && !hyperstackInstanceReady(providerInstance, publicIP, consoleReady) { + lifecycleStatus = v1.LifecycleStatusPending + } + + instance := v1.Instance{ + Name: name, + RefID: refID, + CloudCredRefID: cloudCredRefID, + 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), + } + if providerInstance.CreatedAt != nil { + instance.CreatedAt = providerInstance.CreatedAt.Time + } + instance.InstanceTypeID = v1.MakeGenericInstanceTypeID(v1.InstanceType{ + Type: instance.InstanceType, + Location: instance.Location, + }) + return instance +} + +func hyperstackInstanceReady( + providerInstance virtualmachine.InstanceFields, + publicIP string, + consoleReady bool, +) bool { + if !hyperstackAPIReady(providerInstance, publicIP) { + return false + } + return !requiresConsoleReadiness(providerInstance.Labels) || consoleReady +} + +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)), + ToPort: int32(intValue(providerRule.PortRangeMax)), + 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, cloudCredRefID string, tags v1.Tags) []string { + labels := []string{ + refIDLabelPrefix + refID, + cloudRefLabelPrefix + cloudCredRefID, + } + keys := make([]string, 0, len(tags)) + for key := range tags { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + labels = append(labels, tagLabelPrefix+key+"="+tags[key]) + } + return labels +} + +func parseLabels(providerLabels *[]string) (string, string, v1.Tags) { + tags := make(v1.Tags) + if providerLabels == nil { + return "", "", tags + } + var refID string + var cloudCredRefID string + for _, label := range *providerLabels { + switch { + case strings.HasPrefix(label, refIDLabelPrefix): + refID = strings.TrimPrefix(label, refIDLabelPrefix) + case strings.HasPrefix(label, cloudRefLabelPrefix): + cloudCredRefID = strings.TrimPrefix(label, cloudRefLabelPrefix) + case strings.HasPrefix(label, tagLabelPrefix): + parts := strings.SplitN(strings.TrimPrefix(label, tagLabelPrefix), "=", 2) + if len(parts) != 2 { + continue + } + tags[parts[0]] = parts[1] + case strings.HasPrefix(label, managedKeyIDLabelPrefix), label == readinessLabel: + continue + default: + tags[label] = "" + } + } + return refID, cloudCredRefID, tags +} + +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, fallback string) string { + name = strings.TrimSpace(name) + if name == "" { + name = fallback + } + 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..1619e06 --- /dev/null +++ b/v1/providers/hyperstack/instance_test.go @@ -0,0 +1,361 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "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" + +func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one stateful server makes the lifecycle easy to verify + var labels []string + deleted := false + keyPairDeleteCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, "api-key", request.Header.Get("api_key")) + switch { + case request.URL.Path == "/v1/core/environments" && request.Method == http.MethodGet: + assert.Equal(t, "default-CANADA-1", request.URL.Query().Get("search")) + 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: + var payload map[string]any + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "default-CANADA-1", payload["environment_name"]) + assert.Equal(t, "ref-123", payload["name"]) + assert.Equal(t, testSSHPublicKey, payload["public_key"]) + writeJSON(t, writer, map[string]any{"status": true, "keypair": map[string]any{"id": 7, "name": payload["name"]}}) + case request.URL.Path == "/v1/core/keypair/7" && request.Method == http.MethodDelete: + keyPairDeleteCount++ + writeJSON(t, writer, map[string]any{"status": true}) + case request.URL.Path == "/v1/core/virtual-machines" && request.Method == http.MethodPost: + var payload virtualmachine.CreateInstancesPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + assert.Equal(t, "test-vm", payload.Name) + assert.Equal(t, "default-CANADA-1", payload.EnvironmentName) + assert.Equal(t, "n3-H100x1", payload.FlavorName) + assert.Equal(t, defaultImageName, stringValue(payload.ImageName)) + require.NotNil(t, payload.AssignFloatingIp) + assert.True(t, *payload.AssignFloatingIp) + require.NotNil(t, payload.EnablePortRandomization) + assert.False(t, *payload.EnablePortRandomization) + require.NotNil(t, payload.EnhancedMonitoringEnabled) + assert.False(t, *payload.EnhancedMonitoringEnabled) + require.NotNil(t, payload.SecurityRules) + require.Len(t, *payload.SecurityRules, 2) + assertSecurityRule(t, (*payload.SecurityRules)[0], "0.0.0.0/0", 22, 22) + assertSecurityRule(t, (*payload.SecurityRules)[1], "10.0.0.0/8", 8080, 8080) + require.NotNil(t, payload.Labels) + labels = *payload.Labels + assert.Contains(t, labels, readinessLabel) + require.NotNil(t, payload.UserData) + assert.Contains(t, *payload.UserData, readinessMarker) + writeJSON(t, writer, map[string]any{"status": true, "instances": []map[string]any{{"id": 42}}}) + case request.URL.Path == "/v1/core/virtual-machines/42/logs" && request.Method == http.MethodPost: + var payload virtualmachine.RequestInstanceLogsPayload + require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) + require.NotNil(t, payload.Length) + assert.Equal(t, consoleLogLineCount, *payload.Length) + writeJSON(t, writer, map[string]any{"request_id": 99}) + case request.URL.Path == "/v1/core/virtual-machines/42/logs" && request.Method == http.MethodGet: + assert.Equal(t, "99", request.URL.Query().Get("request_id")) + writeJSON(t, writer, map[string]any{"logs": "boot output\n" + readinessMarker + "\n"}) + case request.URL.Path == "/v1/core/virtual-machines/42" && request.Method == http.MethodGet: + if deleted { + writer.WriteHeader(http.StatusNotFound) + writeJSON(t, writer, map[string]any{"status": false, "message": "not found"}) + return + } + writeJSON(t, writer, map[string]any{ + "status": true, + "instance": map[string]any{ + "id": 42, + "name": "test-vm", + "status": "ACTIVE", + "created_at": "2026-09-04T12:00:00", + "floating_ip": "203.0.113.42", + "fixed_ip": "10.0.0.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", "disk": 100}, + "labels": labels, + "security_rules": []map[string]any{{ + "id": 8, "direction": "ingress", "protocol": "tcp", "port_range_min": 8080, + "port_range_max": 8080, "remote_ip_prefix": "10.0.0.0/8", + }}, + }, + }) + case request.URL.Path == "/v1/core/virtual-machines" && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{ + "status": true, + "instances": []map[string]any{{ + "id": 42, "name": "test-vm", "status": "ACTIVE", "created_at": "2026-09-04T12:00:00", + "environment": map[string]any{"region": "CANADA-1"}, + "flavor": map[string]any{"name": "n3-H100x1", "disk": 100}, "image": map[string]any{"name": defaultImageName}, + "labels": labels, + }}, + }) + case request.URL.Path == "/v1/core/virtual-machines/42" && request.Method == http.MethodDelete: + if deleted { + writer.WriteHeader(http.StatusNotFound) + writeJSON(t, writer, map[string]any{"status": false, "message": "not found"}) + return + } + deleted = true + writeJSON(t, writer, map[string]any{"status": true}) + 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", + Name: "test-vm", + RefID: "ref-123", + PublicKey: testSSHPublicKey, + InstanceType: "n3-H100x1", + Tags: v1.Tags{"team": "compute"}, + FirewallRules: v1.FirewallRules{IngressRules: []v1.FirewallRule{{ + FromPort: 8080, + ToPort: 8080, + IPRanges: []string{"10.0.0.0/8"}, + }}}, + }) + require.NoError(t, err) + assert.Equal(t, v1.CloudProviderInstanceID("42"), instance.CloudID) + assert.Equal(t, "ref-123", instance.RefID) + assert.Equal(t, "credential-ref", instance.CloudCredRefID) + assert.Equal(t, "compute", instance.Tags["team"]) + assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) + assert.Equal(t, "203.0.113.42", instance.PublicIP) + assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x1"), instance.InstanceTypeID) + + instances, err := client.ListInstances(context.Background(), v1.ListInstancesArgs{ + InstanceIDs: []v1.CloudProviderInstanceID{"42"}, + Locations: v1.LocationsFilter{"CANADA-1"}, + TagFilters: map[string][]string{"team": {"compute"}}, + }) + require.NoError(t, err) + require.Len(t, instances, 1) + assert.Equal(t, instance.RefID, instances[0].RefID) + + require.NoError(t, client.TerminateInstance(context.Background(), "42")) + require.NoError(t, client.TerminateInstance(context.Background(), "42")) + assert.Equal(t, 1, keyPairDeleteCount) + _, err = client.GetInstance(context.Background(), "42") + require.Error(t, err) + assert.True(t, errors.Is(err, v1.ErrInstanceNotFound)) +} + +func TestResolveKeyPairSearchesEveryPage(t *testing.T) { + listCalls := 0 + importCalls := 0 + 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": 7, "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, 7, keyPair.managedID) + assert.Equal(t, 2, listCalls) + assert.Zero(t, importCalls) +} + +func TestLabelsRoundTripPlainValues(t *testing.T) { + const refID = "82d299a7-dfd9-40e6-8707-3c477374b2a6" + labels := makeLabels(refID, "credential-ref", v1.Tags{"team": "gpu-workers"}) + labels = append(labels, managedKeyIDLabelPrefix+"7") + labels = append(labels, readinessLabel) + assert.Contains(t, labels, refIDLabelPrefix+refID) + assert.Contains(t, labels, cloudRefLabelPrefix+"credential-ref") + + parsedRefID, cloudRefID, tags := parseLabels(&labels) + assert.Equal(t, refID, parsedRefID) + assert.Equal(t, "credential-ref", cloudRefID) + assert.Equal(t, "gpu-workers", tags["team"]) +} + +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 := "ACTIVE" + client := &HyperstackClient{} + + withoutIP := client.convertInstance(virtualmachine.InstanceFields{Status: &status}, false) + assert.Equal(t, v1.LifecycleStatusPending, withoutIP.Status.LifecycleStatus) + + publicIP := "203.0.113.42" + attaching := "ATTACHING" + withAttachingIP := client.convertInstance(virtualmachine.InstanceFields{ + Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attaching, + }, false) + assert.Equal(t, v1.LifecycleStatusPending, withAttachingIP.Status.LifecycleStatus) + + attached := "ATTACHED" + running := "RUNNING" + ready := client.convertInstance(virtualmachine.InstanceFields{ + Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, + VmState: &status, PowerState: &running, + }, false) + assert.Equal(t, v1.LifecycleStatusRunning, ready.Status.LifecycleStatus) +} + +func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { + status := "ACTIVE" + publicIP := "203.0.113.42" + attached := "ATTACHED" + running := "RUNNING" + labels := []string{readinessLabel} + providerInstance := virtualmachine.InstanceFields{ + Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, + VmState: &status, PowerState: &running, Labels: &labels, + } + + assert.False(t, hyperstackInstanceReady(providerInstance, publicIP, false)) + assert.True(t, hyperstackInstanceReady(providerInstance, publicIP, true)) +} + +func TestConsoleReadyPollsAsyncLogRequest(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.Header().Set("Content-Type", "application/json") + 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 { + writer.WriteHeader(http.StatusBadRequest) + return + } + writeJSON(t, writer, map[string]any{"logs": readinessMarker}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL) + ready, err := client.consoleReady(context.Background(), 42) + require.NoError(t, err) + assert.False(t, ready) + ready, err = client.consoleReady(context.Background(), 42) + require.NoError(t, err) + assert.True(t, ready) + assert.Equal(t, 1, requestCalls) + assert.Equal(t, 2, getCalls) +} + +func TestConsoleReadyRetriesWhenLogsAreUnavailable(t *testing.T) { + requestCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodPost: + requestCalls++ + writeJSON(t, writer, map[string]any{"request_id": requestCalls}) + case http.MethodGet: + if requestCalls == 1 { + writeJSON(t, writer, map[string]any{ + "status": true, "message": "console logs are not available yet", + }) + return + } + writeJSON(t, writer, map[string]any{"logs": readinessMarker}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + client := newTestClient(t, server.URL) + ready, err := client.consoleReady(context.Background(), 42) + require.NoError(t, err) + assert.False(t, ready) + ready, err = client.consoleReady(context.Background(), 42) + require.NoError(t, err) + assert.True(t, ready) + assert.Equal(t, 2, requestCalls) +} + +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 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..4ec901b --- /dev/null +++ b/v1/providers/hyperstack/instancetype.go @@ -0,0 +1,309 @@ +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)), + SupportedArchitectures: []v1.Architecture{v1.ArchitectureX86_64}, + SupportedUsageClasses: []string{usageClass}, + Preemptible: preemptible, + 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 gpuCount > 0 && gpuType != "" { + instanceType.SupportedGPUs = []v1.GPU{hyperstackGPU(gpuType, gpuCount)} + } + instanceType.ID = v1.MakeGenericInstanceTypeID(instanceType) + return instanceType, nil +} + +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), + 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..877b49b --- /dev/null +++ b/v1/providers/hyperstack/instancetype_test.go @@ -0,0 +1,131 @@ +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 TestGetInstanceTypesAndLocations(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")) + assert.Equal(t, "brev-cloud", request.Header.Get("User-Agent")) + switch request.URL.Path { + case "/v1/core/flavors": + writeJSON(t, w, map[string]any{ + "status": true, + "data": []map[string]any{ + { + "region_name": "CANADA-1", + "gpu": "H100-80G-PCIe", + "flavors": []map[string]any{{ + "id": 1, + "name": "n3-H100x2", + "region_name": "CANADA-1", + "cpu": 56, + "ram": 360, + "disk": 100, + "ephemeral": 1500, + "gpu": "H100-80G-PCIe", + "gpu_count": 2, + "stock_available": true, + }}, + }, + { + "region_name": "NORWAY-1", + "gpu": "", + "flavors": []map[string]any{{ + "id": 2, + "name": "n1-cpu-small", + "region_name": "NORWAY-1", + "cpu": 4, + "ram": 4, + "disk": 100, + "ephemeral": 0, + "gpu": "", + "gpu_count": 0, + "stock_available": false, + }}, + }, + }, + }) + case "/v1/pricebook": + writeJSON(t, w, []map[string]any{ + {"name": "H100-80G-PCIe", "value": "2.5"}, + {"name": "vCPU (cpu-only-flavors)", "value": "0.01"}, + {"name": "RAM (cpu-only-flavors)", "value": "0.02"}, + {"name": "hypervisor-local-storage (cpu-only-flavors)", "value": "0.001"}, + }) + case "/v1/core/regions": + writeJSON(t, w, map[string]any{ + "status": true, + "regions": []map[string]any{ + {"id": 1, "name": "CANADA-1", "country": "CA", "features": map[string]any{"floating_ip": true}}, + {"id": 2, "name": "NORWAY-1", "country": "NO", "features": map[string]any{"floating_ip": true}}, + {"id": 3, "name": "CANADA-2", "country": "CA", "features": map[string]any{"floating_ip": false}}, + }, + }) + 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, 2) + + gpuType := instanceTypes[0] + assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x2"), gpuType.ID) + assert.Equal(t, v1.NewBytes(360, v1.Gigabyte), gpuType.MemoryBytes) + assert.Equal(t, "5.0", gpuType.BasePrice.Number()) + require.Len(t, gpuType.SupportedGPUs, 1) + assert.Equal(t, int32(2), gpuType.SupportedGPUs[0].Count) + assert.Equal(t, v1.NewBytes(80, v1.Gigabyte), gpuType.SupportedGPUs[0].MemoryBytes) + assert.Equal(t, "H100", gpuType.SupportedGPUs[0].Name) + assert.Equal(t, "PCIe", gpuType.SupportedGPUs[0].NetworkDetails) + + cpuType := instanceTypes[1] + assert.Equal(t, "0.220", cpuType.BasePrice.Number()) + assert.False(t, cpuType.IsAvailable) + + filteredTypes, err := client.GetInstanceTypes(context.Background(), v1.GetInstanceTypeArgs{ + Locations: v1.LocationsFilter{"NORWAY-1"}, + InstanceTypes: []string{"n1-cpu-small"}, + }) + require.NoError(t, err) + require.Len(t, filteredTypes, 1) + assert.Equal(t, "NORWAY-1", filteredTypes[0].Location) + + locations, err := client.GetLocations(context.Background(), v1.GetLocationsArgs{IncludeUnavailable: true}) + require.NoError(t, err) + require.Len(t, locations, 3) + 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) + assert.False(t, locations[2].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..355ca2c --- /dev/null +++ b/v1/providers/hyperstack/location.go @@ -0,0 +1,109 @@ +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") + } + + flavorGroups, err := c.listFlavors(ctx) + if err != nil { + return nil, err + } + available := availableFlavorLocations(flavorGroups) + + locations := make([]v1.Location, 0, len(*response.JSON200.Regions)) + for _, providerRegion := range *response.JSON200.Regions { + location, ok := hyperstackLocation(providerRegion, available) + 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, available map[string]bool) (v1.Location, bool) { + name := strings.TrimSpace(stringValue(providerRegion.Name)) + if name == "" { + return v1.Location{}, false + } + description := strings.TrimSpace(stringValue(providerRegion.Description)) + if description == "" { + description = name + } + return v1.Location{ + Name: name, + Description: description, + Available: available[name] && supportsFloatingIP(providerRegion.Features), + Country: countryAlpha3(stringValue(providerRegion.Country)), + }, true +} + +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..448f0a1 --- /dev/null +++ b/v1/providers/hyperstack/readiness.go @@ -0,0 +1,156 @@ +package hyperstack + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" +) + +const ( + readinessLabel = "brev-readiness-v1" + readinessMarker = "BREV_CLOUD_READY_V1" + consoleLogLineCount = 200 + readinessCloudConfig = `#cloud-config +write_files: + - path: /etc/systemd/system/brev-cloud-ready.service + permissions: '0644' + content: | + [Unit] + Description=Brev cloud instance readiness signal + After=cloud-final.service + + [Service] + Type=oneshot + ExecStart=/bin/sh -c 'while systemctl list-jobs --no-legend --no-pager | grep -v brev-cloud-ready.service | grep -q .; do sleep 2; done; printf "BREV_CLOUD_READY_V1\n" > /dev/ttyS0' +runcmd: + - [systemctl, daemon-reload] + - [systemctl, start, --no-block, brev-cloud-ready.service] +` +) + +func requiresConsoleReadiness(labels *[]string) bool { + return labels != nil && containsString(*labels, readinessLabel) +} + +func containsString(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bool, error) { + requestID, ready := c.readinessState(instanceID) + if ready { + return true, nil + } + if requestID == 0 { + var err error + requestID, err = c.requestConsoleLogs(ctx, instanceID) + if err != nil { + return false, err + } + if requestID == 0 { + return false, nil + } + c.setLogRequest(instanceID, requestID) + } + + 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.StatusAccepted, http.StatusBadRequest: + // The request endpoint is asynchronous. The retrieval endpoint documents + // 400, rather than 202, while a valid request is not ready to read yet. + return false, nil + case http.StatusOK: + c.clearLogRequest(instanceID) + if response.JSON200 == nil || response.JSON200.Logs == nil { + return false, nil + } + if !strings.Contains(*response.JSON200.Logs, readinessMarker) { + return false, nil + } + c.setReady(instanceID) + return true, nil + case http.StatusUnauthorized, http.StatusForbidden: + return false, responseError("get virtual machine console logs", response.StatusCode(), response.Body, nil) + default: + c.clearLogRequest(instanceID) + 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 (c *HyperstackClient) readinessState(instanceID int) (requestID int, ready bool) { + c.readinessMu.Lock() + defer c.readinessMu.Unlock() + _, ready = c.readyInstances[instanceID] + return c.logRequests[instanceID], ready +} + +func (c *HyperstackClient) setLogRequest(instanceID, requestID int) { + c.readinessMu.Lock() + defer c.readinessMu.Unlock() + if c.logRequests == nil { + c.logRequests = make(map[int]int) + } + c.logRequests[instanceID] = requestID +} + +func (c *HyperstackClient) clearLogRequest(instanceID int) { + c.readinessMu.Lock() + defer c.readinessMu.Unlock() + delete(c.logRequests, instanceID) +} + +func (c *HyperstackClient) setReady(instanceID int) { + c.readinessMu.Lock() + defer c.readinessMu.Unlock() + if c.readyInstances == nil { + c.readyInstances = make(map[int]struct{}) + } + delete(c.logRequests, instanceID) + c.readyInstances[instanceID] = struct{}{} +} diff --git a/v1/providers/hyperstack/security_rules.go b/v1/providers/hyperstack/security_rules.go new file mode 100644 index 0000000..85cc278 --- /dev/null +++ b/v1/providers/hyperstack/security_rules.go @@ -0,0 +1,52 @@ +package hyperstack + +import ( + "fmt" + "net" + + virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + + v1 "github.com/brevdev/cloud/v1" +) + +func makeDirectSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSecurityRulePayload, error) { + securityRules := make([]virtualmachine.CreateSecurityRulePayload, 0, len(rules.IngressRules)+1) + 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, + }) + } + + addRule("0.0.0.0/0", "IPv4", defaultSSHPort, defaultSSHPort) + 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..c3366a2 --- /dev/null +++ b/v1/providers/hyperstack/validation_test.go @@ -0,0 +1,96 @@ +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), + }) +} + +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 +} From ea75ebe6fde3a61c01eb112ce7e85c072431c7e9 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 15:29:17 -0700 Subject: [PATCH 03/23] remove doc --- v1/providers/hyperstack/README.md | 17 ----------------- v1/providers/hyperstack/SECURITY.md | 9 --------- 2 files changed, 26 deletions(-) delete mode 100644 v1/providers/hyperstack/README.md delete mode 100644 v1/providers/hyperstack/SECURITY.md diff --git a/v1/providers/hyperstack/README.md b/v1/providers/hyperstack/README.md deleted file mode 100644 index b9bc6a9..0000000 --- a/v1/providers/hyperstack/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Hyperstack Provider - -This package implements the minimal Brev Cloud v1 compute surface for Hyperstack using the official Go SDK. - -Supported capabilities are instance creation and termination. Locations map to Hyperstack regions, instance types map to flavors, and creation resolves the region's `default-` environment through the API. `CreateInstanceAttrs.ImageID` is treated as a Hyperstack image name; when omitted, the provider uses Ubuntu Server 22.04 LTS (Jammy Jellyfish). - -VMs are created with a direct inbound TCP/22 security rule for bootstrap SSH access. Caller-provided ingress ports are included as additional direct per-VM rules. Outbound behavior is derived by Hyperstack. - -## Read-only validation - -Set `HYPERSTACK_API_KEY` and optionally `HYPERSTACK_API_URL`, then run: - -```sh -go test -run TestReadOnlyValidation ./v1/providers/hyperstack -``` - -This validation only lists regions, flavors, prices, and existing virtual machines. It never creates, updates, or deletes a Hyperstack resource. diff --git a/v1/providers/hyperstack/SECURITY.md b/v1/providers/hyperstack/SECURITY.md deleted file mode 100644 index 7105b41..0000000 --- a/v1/providers/hyperstack/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Hyperstack Security Notes - -Hyperstack VM creation receives the requested SSH public key through an environment-scoped keypair. Key lookup follows all result pages before importing a new key. Port randomization is disabled so Brev can consistently use SSH port 22. - -The provider creates each VM with a direct security rule allowing public IPv4 SSH ingress on port 22. Caller-provided TCP ingress ranges are added as direct per-VM rules. Outbound behavior is derived by Hyperstack and is not represented by explicit rules. All other inbound traffic remains denied. - -Enhanced monitoring is explicitly disabled when creating VMs. This prevents opting the VM into the guest-installed Hyperstack VM Agent and its metrics ingestion gateway. Hyperstack's platform-level VM metrics are managed by the provider and do not expose a documented per-VM opt-out. - -API authentication uses the `api_key` header over Hyperstack's HTTPS endpoint. API keys are never returned from provider methods; tenant identity is derived from a one-way hash of the key. From d6d0e154e41edcecff2a67dbf50e9298f2ae0f85 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 16:29:37 -0700 Subject: [PATCH 04/23] disks --- v1/providers/hyperstack/instance.go | 4 ++-- v1/providers/hyperstack/instance_test.go | 15 +++++++++------ v1/providers/hyperstack/instancetype.go | 19 +++++++++++++++---- v1/providers/hyperstack/instancetype_test.go | 8 ++++++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 908890b..8f742d7 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -67,6 +67,8 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn 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.Name, attrs.RefID), EnvironmentName: environmentName, @@ -115,8 +117,6 @@ func validateCreateInstanceAttrs(attrs v1.CreateInstanceAttrs, location string) 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 attrs.DiskSize != 0 || attrs.DiskSizeBytes.Value() != 0: - return errors.New("hyperstack provider does not support custom root disk sizes") case len(attrs.AdditionalDisks) > 0: return errors.New("hyperstack provider does not support additional disks") case attrs.UseSpot != isSpotFlavor(attrs.InstanceType, attrs.InstanceType): diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 1619e06..dd9dee7 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -9,6 +9,7 @@ import ( "testing" virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" + "github.com/alecthomas/units" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,12 +125,14 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef client := newTestClient(t, server.URL+"/v1") instance, err := client.CreateInstance(context.Background(), v1.CreateInstanceAttrs{ - Location: "CANADA-1", - Name: "test-vm", - RefID: "ref-123", - PublicKey: testSSHPublicKey, - InstanceType: "n3-H100x1", - Tags: v1.Tags{"team": "compute"}, + Location: "CANADA-1", + Name: "test-vm", + RefID: "ref-123", + PublicKey: testSSHPublicKey, + InstanceType: "n3-H100x1", + DiskSize: 256 * units.Gibibyte, + DiskSizeBytes: v1.NewBytes(256, v1.Gibibyte), + Tags: v1.Tags{"team": "compute"}, FirewallRules: v1.FirewallRules{IngressRules: []v1.FirewallRule{{ FromPort: 8080, ToPort: 8080, diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index 4ec901b..e2719ac 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -116,6 +116,16 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s memory, memoryBytes := byteSizes(memoryGB, v1.Gigabyte) storageGB := int64(intValue(providerType.Disk)) + storageType := "ssd" + isEphemeral := false + if ephemeralGB := int64(intValue(providerType.Ephemeral)); ephemeralGB > 0 { + // Hyperstack exposes flavor-local ephemeral storage as a separate disk + // from the root disk. Present that capacity when available so consumers + // do not mistake the fixed root disk for workload storage. + storageGB = ephemeralGB + storageType = "ephemeral" + isEphemeral = true + } storage, storageBytes := byteSizes(storageGB, v1.Gigabyte) gpuType := strings.TrimSpace(stringValue(providerType.Gpu)) @@ -146,10 +156,11 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s } if storageGB > 0 { instanceType.SupportedStorage = []v1.Storage{{ - Type: "ssd", - Count: 1, - Size: storage, - SizeBytes: storageBytes, + Type: storageType, + Count: 1, + Size: storage, + SizeBytes: storageBytes, + IsEphemeral: isEphemeral, }} } if gpuCount > 0 && gpuType != "" { diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go index 877b49b..62de23b 100644 --- a/v1/providers/hyperstack/instancetype_test.go +++ b/v1/providers/hyperstack/instancetype_test.go @@ -92,10 +92,18 @@ func TestGetInstanceTypesAndLocations(t *testing.T) { assert.Equal(t, v1.NewBytes(80, v1.Gigabyte), gpuType.SupportedGPUs[0].MemoryBytes) assert.Equal(t, "H100", gpuType.SupportedGPUs[0].Name) assert.Equal(t, "PCIe", gpuType.SupportedGPUs[0].NetworkDetails) + require.Len(t, gpuType.SupportedStorage, 1) + assert.Equal(t, "ephemeral", gpuType.SupportedStorage[0].Type) + assert.Equal(t, v1.NewBytes(1500, v1.Gigabyte), gpuType.SupportedStorage[0].SizeBytes) + assert.True(t, gpuType.SupportedStorage[0].IsEphemeral) cpuType := instanceTypes[1] assert.Equal(t, "0.220", cpuType.BasePrice.Number()) assert.False(t, cpuType.IsAvailable) + require.Len(t, cpuType.SupportedStorage, 1) + assert.Equal(t, "ssd", cpuType.SupportedStorage[0].Type) + assert.Equal(t, v1.NewBytes(100, v1.Gigabyte), cpuType.SupportedStorage[0].SizeBytes) + assert.False(t, cpuType.SupportedStorage[0].IsEphemeral) filteredTypes, err := client.GetInstanceTypes(context.Background(), v1.GetInstanceTypeArgs{ Locations: v1.LocationsFilter{"NORWAY-1"}, From 7bd00bc99ad0c605749fe20848396ec263c96c0f Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 16:43:42 -0700 Subject: [PATCH 05/23] labels --- v1/providers/hyperstack/instance.go | 22 ++++++++++------- v1/providers/hyperstack/instance_test.go | 30 +++++++++++++++++++----- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 8f742d7..ed947a2 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -7,7 +7,6 @@ import ( "net/http" "regexp" "slices" - "sort" "strconv" "strings" "time" @@ -31,6 +30,16 @@ const ( var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) +var instanceTagLabelKeys = []string{ + "dev-plane-x-instanceId", + "dev-plane-x-environmentId", + "dev-plane-x-userId", + "dev-plane-x-launchableId", + "dev-plane-x-cloudCredId", + "dev-plane-stage", + "cloudCredRefID", +} + func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { location := strings.TrimSpace(attrs.Location) if location == "" { @@ -407,13 +416,10 @@ func makeLabels(refID, cloudCredRefID string, tags v1.Tags) []string { refIDLabelPrefix + refID, cloudRefLabelPrefix + cloudCredRefID, } - keys := make([]string, 0, len(tags)) - for key := range tags { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - labels = append(labels, tagLabelPrefix+key+"="+tags[key]) + for _, key := range instanceTagLabelKeys { + if value, ok := tags[key]; ok { + labels = append(labels, tagLabelPrefix+key+"="+value) + } } return labels } diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index dd9dee7..01ba918 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -132,7 +132,10 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef InstanceType: "n3-H100x1", DiskSize: 256 * units.Gibibyte, DiskSizeBytes: v1.NewBytes(256, v1.Gibibyte), - Tags: v1.Tags{"team": "compute"}, + Tags: v1.Tags{ + "dev-plane-x-instanceId": "instance-123", + "team": "compute", + }, FirewallRules: v1.FirewallRules{IngressRules: []v1.FirewallRule{{ FromPort: 8080, ToPort: 8080, @@ -143,7 +146,8 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef assert.Equal(t, v1.CloudProviderInstanceID("42"), instance.CloudID) assert.Equal(t, "ref-123", instance.RefID) assert.Equal(t, "credential-ref", instance.CloudCredRefID) - assert.Equal(t, "compute", instance.Tags["team"]) + assert.Equal(t, "instance-123", instance.Tags["dev-plane-x-instanceId"]) + assert.NotContains(t, instance.Tags, "team") assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) assert.Equal(t, "203.0.113.42", instance.PublicIP) assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x1"), instance.InstanceTypeID) @@ -151,7 +155,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef instances, err := client.ListInstances(context.Background(), v1.ListInstancesArgs{ InstanceIDs: []v1.CloudProviderInstanceID{"42"}, Locations: v1.LocationsFilter{"CANADA-1"}, - TagFilters: map[string][]string{"team": {"compute"}}, + TagFilters: map[string][]string{"dev-plane-x-instanceId": {"instance-123"}}, }) require.NoError(t, err) require.Len(t, instances, 1) @@ -210,16 +214,30 @@ func TestResolveKeyPairSearchesEveryPage(t *testing.T) { func TestLabelsRoundTripPlainValues(t *testing.T) { const refID = "82d299a7-dfd9-40e6-8707-3c477374b2a6" - labels := makeLabels(refID, "credential-ref", v1.Tags{"team": "gpu-workers"}) + tags := v1.Tags{ + "dev-plane-x-instanceId": "instance-id", + "dev-plane-x-environmentId": "environment-id", + "dev-plane-x-userId": "user-id", + "dev-plane-x-launchableId": "launchable-id", + "dev-plane-x-cloudCredId": "cloud-cred-id", + "dev-plane-stage": "dev", + "team": "gpu-workers", + } + labels := makeLabels(refID, "credential-ref", tags) labels = append(labels, managedKeyIDLabelPrefix+"7") labels = append(labels, readinessLabel) + require.Len(t, labels, 10) assert.Contains(t, labels, refIDLabelPrefix+refID) assert.Contains(t, labels, cloudRefLabelPrefix+"credential-ref") + assert.NotContains(t, labels, tagLabelPrefix+"team=gpu-workers") - parsedRefID, cloudRefID, tags := parseLabels(&labels) + parsedRefID, cloudRefID, parsedTags := parseLabels(&labels) assert.Equal(t, refID, parsedRefID) assert.Equal(t, "credential-ref", cloudRefID) - assert.Equal(t, "gpu-workers", tags["team"]) + for _, key := range instanceTagLabelKeys { + assert.Equal(t, tags[key], parsedTags[key]) + } + assert.NotContains(t, parsedTags, "team") } func TestCallerKeyPairIsNotManaged(t *testing.T) { From da5e0f21dde55767a2172af481677ff82560f21c Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 19:33:30 -0700 Subject: [PATCH 06/23] more labels --- v1/providers/hyperstack/instance.go | 15 ++++++------- v1/providers/hyperstack/instance_test.go | 28 +++++++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index ed947a2..d9f6c87 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -30,14 +30,16 @@ const ( var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) +// Hyperstack permits at most 10 labels. These seven caller tags plus the +// canonical ref ID, readiness marker, and managed-key ID fill that budget. 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", - "cloudCredRefID", } func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { @@ -66,7 +68,7 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn if err != nil { return nil, err } - labels := makeLabels(attrs.RefID, c.refID, attrs.Tags) + labels := makeLabels(attrs.RefID, attrs.Tags) if keyPair.managedID != 0 { labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPair.managedID)) } @@ -79,7 +81,7 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn // 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.Name, attrs.RefID), + Name: managedResourceName(attrs.RefID, ""), EnvironmentName: environmentName, KeyName: keyPair.name, ImageName: &imageName, @@ -411,11 +413,8 @@ func matchesListArgs(instance v1.Instance, args v1.ListInstancesArgs) bool { return true } -func makeLabels(refID, cloudCredRefID string, tags v1.Tags) []string { - labels := []string{ - refIDLabelPrefix + refID, - cloudRefLabelPrefix + cloudCredRefID, - } +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, tagLabelPrefix+key+"="+value) diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 01ba918..b3fce0c 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -45,7 +45,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef case request.URL.Path == "/v1/core/virtual-machines" && request.Method == http.MethodPost: var payload virtualmachine.CreateInstancesPayload require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) - assert.Equal(t, "test-vm", payload.Name) + assert.Equal(t, "ref-123", payload.Name) assert.Equal(t, "default-CANADA-1", payload.EnvironmentName) assert.Equal(t, "n3-H100x1", payload.FlavorName) assert.Equal(t, defaultImageName, stringValue(payload.ImageName)) @@ -84,7 +84,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef "status": true, "instance": map[string]any{ "id": 42, - "name": "test-vm", + "name": "ref-123", "status": "ACTIVE", "created_at": "2026-09-04T12:00:00", "floating_ip": "203.0.113.42", @@ -103,7 +103,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef writeJSON(t, writer, map[string]any{ "status": true, "instances": []map[string]any{{ - "id": 42, "name": "test-vm", "status": "ACTIVE", "created_at": "2026-09-04T12:00:00", + "id": 42, "name": "ref-123", "status": "ACTIVE", "created_at": "2026-09-04T12:00:00", "environment": map[string]any{"region": "CANADA-1"}, "flavor": map[string]any{"name": "n3-H100x1", "disk": 100}, "image": map[string]any{"name": defaultImageName}, "labels": labels, @@ -126,13 +126,14 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef client := newTestClient(t, server.URL+"/v1") instance, err := client.CreateInstance(context.Background(), v1.CreateInstanceAttrs{ Location: "CANADA-1", - Name: "test-vm", + Name: "display-name", RefID: "ref-123", PublicKey: testSSHPublicKey, InstanceType: "n3-H100x1", DiskSize: 256 * units.Gibibyte, DiskSizeBytes: v1.NewBytes(256, v1.Gibibyte), Tags: v1.Tags{ + "dev-plane-managedBy": "dev-plane", "dev-plane-x-instanceId": "instance-123", "team": "compute", }, @@ -144,8 +145,10 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef }) require.NoError(t, err) assert.Equal(t, v1.CloudProviderInstanceID("42"), instance.CloudID) + assert.Equal(t, "ref-123", instance.Name) assert.Equal(t, "ref-123", instance.RefID) assert.Equal(t, "credential-ref", instance.CloudCredRefID) + assert.Equal(t, "dev-plane", instance.Tags["dev-plane-managedBy"]) assert.Equal(t, "instance-123", instance.Tags["dev-plane-x-instanceId"]) assert.NotContains(t, instance.Tags, "team") assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) @@ -155,7 +158,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef instances, err := client.ListInstances(context.Background(), v1.ListInstancesArgs{ InstanceIDs: []v1.CloudProviderInstanceID{"42"}, Locations: v1.LocationsFilter{"CANADA-1"}, - TagFilters: map[string][]string{"dev-plane-x-instanceId": {"instance-123"}}, + TagFilters: map[string][]string{"dev-plane-managedBy": {"dev-plane"}}, }) require.NoError(t, err) require.Len(t, instances, 1) @@ -215,6 +218,7 @@ func TestResolveKeyPairSearchesEveryPage(t *testing.T) { func TestLabelsRoundTripPlainValues(t *testing.T) { const refID = "82d299a7-dfd9-40e6-8707-3c477374b2a6" tags := v1.Tags{ + "dev-plane-managedBy": "dev-plane", "dev-plane-x-instanceId": "instance-id", "dev-plane-x-environmentId": "environment-id", "dev-plane-x-userId": "user-id", @@ -223,23 +227,31 @@ func TestLabelsRoundTripPlainValues(t *testing.T) { "dev-plane-stage": "dev", "team": "gpu-workers", } - labels := makeLabels(refID, "credential-ref", tags) + labels := makeLabels(refID, tags) labels = append(labels, managedKeyIDLabelPrefix+"7") labels = append(labels, readinessLabel) require.Len(t, labels, 10) assert.Contains(t, labels, refIDLabelPrefix+refID) - assert.Contains(t, labels, cloudRefLabelPrefix+"credential-ref") + assert.NotContains(t, labels, cloudRefLabelPrefix+"credential-ref") assert.NotContains(t, labels, tagLabelPrefix+"team=gpu-workers") parsedRefID, cloudRefID, parsedTags := parseLabels(&labels) assert.Equal(t, refID, parsedRefID) - assert.Equal(t, "credential-ref", cloudRefID) + assert.Empty(t, cloudRefID) for _, key := range instanceTagLabelKeys { assert.Equal(t, tags[key], parsedTags[key]) } assert.NotContains(t, parsedTags, "team") } +func TestParseLabelsSupportsLegacyCloudRefLabel(t *testing.T) { + labels := []string{cloudRefLabelPrefix + "credential-ref"} + + _, cloudRefID, _ := parseLabels(&labels) + + assert.Equal(t, "credential-ref", cloudRefID) +} + func TestCallerKeyPairIsNotManaged(t *testing.T) { keyName := "customer-key" keyPair, err := (&HyperstackClient{}).resolveKeyPair(context.Background(), v1.CreateInstanceAttrs{ From 661763e953269c551b84f6a29a5145e8e008efcd Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 20:01:25 -0700 Subject: [PATCH 07/23] labels --- v1/providers/hyperstack/instance.go | 27 ++++++++++++++++++------ v1/providers/hyperstack/instance_test.go | 15 +++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index d9f6c87..d8e8ba7 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -24,6 +24,7 @@ const ( refIDLabelPrefix = "brev-ref-" cloudRefLabelPrefix = "brev-cloud-ref-" tagLabelPrefix = "brev-tag-" + tagLabelSeparator = "_" managedKeyIDLabelPrefix = "brev-managed-key-id-" defaultEnvironmentTag = "default-" ) @@ -417,7 +418,7 @@ 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, tagLabelPrefix+key+"="+value) + labels = append(labels, providerTagLabelPrefix(key)+value) } } return labels @@ -431,17 +432,15 @@ func parseLabels(providerLabels *[]string) (string, string, v1.Tags) { var refID string var cloudCredRefID 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, cloudRefLabelPrefix): cloudCredRefID = strings.TrimPrefix(label, cloudRefLabelPrefix) - case strings.HasPrefix(label, tagLabelPrefix): - parts := strings.SplitN(strings.TrimPrefix(label, tagLabelPrefix), "=", 2) - if len(parts) != 2 { - continue - } - tags[parts[0]] = parts[1] case strings.HasPrefix(label, managedKeyIDLabelPrefix), label == readinessLabel: continue default: @@ -451,6 +450,20 @@ func parseLabels(providerLabels *[]string) (string, string, v1.Tags) { return refID, cloudCredRefID, 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 diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index b3fce0c..8bb6297 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -232,6 +232,8 @@ func TestLabelsRoundTripPlainValues(t *testing.T) { labels = append(labels, readinessLabel) require.Len(t, labels, 10) assert.Contains(t, labels, refIDLabelPrefix+refID) + assert.Contains(t, labels, "brev-tag-dev-plane-managedby_dev-plane") + assert.Contains(t, labels, "brev-tag-dev-plane-x-instanceid_instance-id") assert.NotContains(t, labels, cloudRefLabelPrefix+"credential-ref") assert.NotContains(t, labels, tagLabelPrefix+"team=gpu-workers") @@ -252,6 +254,19 @@ func TestParseLabelsSupportsLegacyCloudRefLabel(t *testing.T) { assert.Equal(t, "credential-ref", cloudRefID) } +func TestParseLabelsOnlyDecodesKnownTagPrefixes(t *testing.T) { + labels := []string{ + "brev-tag-team_gpu-workers", + "brev-tag-dev-plane-x-instanceid_instance-123", + } + + _, _, tags := parseLabels(&labels) + + assert.Equal(t, "instance-123", tags["dev-plane-x-instanceId"]) + assert.Equal(t, "", tags["brev-tag-team_gpu-workers"]) + assert.NotContains(t, tags, "team") +} + func TestCallerKeyPairIsNotManaged(t *testing.T) { keyName := "customer-key" keyPair, err := (&HyperstackClient{}).resolveKeyPair(context.Background(), v1.CreateInstanceAttrs{ From e55868a9771af3e4a27146c9e763b1ff373b35fc Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 20:30:00 -0700 Subject: [PATCH 08/23] fix logs --- v1/providers/hyperstack/instance_test.go | 14 +++++++++----- v1/providers/hyperstack/readiness.go | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 8bb6297..143cbf2 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -350,17 +350,20 @@ func TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { assert.Equal(t, 2, getCalls) } -func TestConsoleReadyRetriesWhenLogsAreUnavailable(t *testing.T) { +func TestConsoleReadyRetainsRequestWhileLogsAreProcessing(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++ - writeJSON(t, writer, map[string]any{"request_id": requestCalls}) + writeJSON(t, writer, map[string]any{"request_id": 17}) case http.MethodGet: - if requestCalls == 1 { + getCalls++ + assert.Equal(t, "17", request.URL.Query().Get("request_id")) + if getCalls == 1 { writeJSON(t, writer, map[string]any{ - "status": true, "message": "console logs are not available yet", + "status": true, "message": "request is still processing", }) return } @@ -378,7 +381,8 @@ func TestConsoleReadyRetriesWhenLogsAreUnavailable(t *testing.T) { ready, err = client.consoleReady(context.Background(), 42) require.NoError(t, err) assert.True(t, ready) - assert.Equal(t, 2, requestCalls) + assert.Equal(t, 1, requestCalls) + assert.Equal(t, 2, getCalls) } func assertSecurityRule(t *testing.T, rule virtualmachine.CreateSecurityRulePayload, cidr string, fromPort, toPort int) { diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 448f0a1..2f96a75 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -76,10 +76,13 @@ func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bo // 400, rather than 202, while a valid request is not ready to read yet. return false, nil case http.StatusOK: - c.clearLogRequest(instanceID) if response.JSON200 == nil || response.JSON200.Logs == nil { + // Hyperstack returns 200 while the asynchronous log request is still + // processing. Keep its request ID so the next poll retrieves the same + // request instead of starting over indefinitely. return false, nil } + c.clearLogRequest(instanceID) if !strings.Contains(*response.JSON200.Logs, readinessMarker) { return false, nil } From 04631d5a6efc8c6eb6169475782f474355b5a029 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Fri, 18 Sep 2026 20:59:14 -0700 Subject: [PATCH 09/23] stop start --- internal/validation/suite.go | 11 ++++---- v1/providers/hyperstack/capabilities.go | 1 + v1/providers/hyperstack/client_test.go | 1 + v1/providers/hyperstack/instance.go | 31 ++++++++++++++++++++ v1/providers/hyperstack/instance_test.go | 33 ++++++++++++++++++++-- v1/providers/hyperstack/instancetype.go | 1 + v1/providers/hyperstack/security_rules.go | 3 +- v1/providers/hyperstack/validation_test.go | 7 +++++ 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/internal/validation/suite.go b/internal/validation/suite.go index 048e899..6b4bde9 100644 --- a/internal/validation/suite.go +++ b/internal/validation/suite.go @@ -11,9 +11,10 @@ import ( ) type ProviderConfig struct { - Location string - StableIDs []v1.InstanceTypeID - Credential v1.CloudCredential + Location string + StableIDs []v1.InstanceTypeID + Credential v1.CloudCredential + CreateInstanceAttrs v1.CreateInstanceAttrs } func RunValidationSuite(t *testing.T, config ProviderConfig) { @@ -112,7 +113,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 { @@ -298,7 +299,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/providers/hyperstack/capabilities.go b/v1/providers/hyperstack/capabilities.go index 36f02d5..3af0e6f 100644 --- a/v1/providers/hyperstack/capabilities.go +++ b/v1/providers/hyperstack/capabilities.go @@ -11,6 +11,7 @@ func getCapabilities() v1.Capabilities { v1.CapabilityCreateInstance, v1.CapabilityTerminateInstance, v1.CapabilityCreateTerminateInstance, + v1.CapabilityStopStartInstance, } } diff --git a/v1/providers/hyperstack/client_test.go b/v1/providers/hyperstack/client_test.go index 3af7324..c424f16 100644 --- a/v1/providers/hyperstack/client_test.go +++ b/v1/providers/hyperstack/client_test.go @@ -27,6 +27,7 @@ func TestHyperstackCredential(t *testing.T) { capabilities, err := credential.GetCapabilities(context.Background()) require.NoError(t, err) assert.Equal(t, getCapabilities(), capabilities) + assert.True(t, capabilities.IsCapable(v1.CapabilityStopStartInstance)) invalid := NewHyperstackCredential("credential-ref", "") require.Error(t, invalid.Validate()) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index d8e8ba7..5f47bc0 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -218,6 +218,36 @@ func (c *HyperstackClient) TerminateInstance(ctx context.Context, instanceID v1. 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 + } + 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 { @@ -318,6 +348,7 @@ func (c *HyperstackClient) convertInstance( Location: location, Tags: tags, Spot: isSpotFlavor(instanceType, instanceType), + Stoppable: true, } if providerInstance.CreatedAt != nil { instance.CreatedAt = providerInstance.CreatedAt.Time diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 143cbf2..eb51d17 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -56,9 +56,8 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef require.NotNil(t, payload.EnhancedMonitoringEnabled) assert.False(t, *payload.EnhancedMonitoringEnabled) require.NotNil(t, payload.SecurityRules) - require.Len(t, *payload.SecurityRules, 2) - assertSecurityRule(t, (*payload.SecurityRules)[0], "0.0.0.0/0", 22, 22) - assertSecurityRule(t, (*payload.SecurityRules)[1], "10.0.0.0/8", 8080, 8080) + require.Len(t, *payload.SecurityRules, 1) + assertSecurityRule(t, (*payload.SecurityRules)[0], "10.0.0.0/8", 8080, 8080) require.NotNil(t, payload.Labels) labels = *payload.Labels assert.Contains(t, labels, readinessLabel) @@ -117,6 +116,10 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef } deleted = true writeJSON(t, writer, map[string]any{"status": true}) + case request.URL.Path == "/v1/core/virtual-machines/42/stop" && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{"status": true}) + case request.URL.Path == "/v1/core/virtual-machines/42/start" && request.Method == http.MethodGet: + writeJSON(t, writer, map[string]any{"status": true}) default: http.NotFound(writer, request) } @@ -154,6 +157,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) assert.Equal(t, "203.0.113.42", instance.PublicIP) assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x1"), instance.InstanceTypeID) + assert.True(t, instance.Stoppable) instances, err := client.ListInstances(context.Background(), v1.ListInstancesArgs{ InstanceIDs: []v1.CloudProviderInstanceID{"42"}, @@ -163,6 +167,8 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef require.NoError(t, err) require.Len(t, instances, 1) assert.Equal(t, instance.RefID, instances[0].RefID) + require.NoError(t, client.StopInstance(context.Background(), "42")) + require.NoError(t, client.StartInstance(context.Background(), "42")) require.NoError(t, client.TerminateInstance(context.Background(), "42")) require.NoError(t, client.TerminateInstance(context.Background(), "42")) @@ -397,6 +403,27 @@ func assertSecurityRule(t *testing.T, rule virtualmachine.CreateSecurityRulePayl assert.Equal(t, toPort, *rule.PortRangeMax) } +func TestMakeDirectSecurityRulesDoesNotWidenCallerSSHIngress(t *testing.T) { + rules, err := makeDirectSecurityRules(v1.FirewallRules{IngressRules: []v1.FirewallRule{{ + FromPort: defaultSSHPort, + ToPort: defaultSSHPort, + IPRanges: []string{"52.9.0.116/32", "52.52.248.36/32"}, + }}}) + require.NoError(t, err) + require.Len(t, rules, 2) + assertSecurityRule(t, rules[0], "52.9.0.116/32", defaultSSHPort, defaultSSHPort) + assertSecurityRule(t, rules[1], "52.52.248.36/32", defaultSSHPort, defaultSSHPort) + for _, rule := range rules { + assert.NotEqual(t, "0.0.0.0/0", rule.RemoteIpPrefix) + } +} + +func TestMakeDirectSecurityRulesDoesNotInventSSHIngress(t *testing.T) { + rules, err := makeDirectSecurityRules(v1.FirewallRules{}) + require.NoError(t, err) + assert.Empty(t, rules) +} + func TestValidateCreateInstanceAttrs(t *testing.T) { valid := v1.CreateInstanceAttrs{ RefID: "ref", diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index e2719ac..103cf86 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -150,6 +150,7 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s SupportedArchitectures: []v1.Architecture{v1.ArchitectureX86_64}, SupportedUsageClasses: []string{usageClass}, Preemptible: preemptible, + Stoppable: true, IsAvailable: providerType.StockAvailable == nil || *providerType.StockAvailable, BasePrice: basePrice, Provider: CloudProviderID, diff --git a/v1/providers/hyperstack/security_rules.go b/v1/providers/hyperstack/security_rules.go index 85cc278..4568931 100644 --- a/v1/providers/hyperstack/security_rules.go +++ b/v1/providers/hyperstack/security_rules.go @@ -10,7 +10,7 @@ import ( ) func makeDirectSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSecurityRulePayload, error) { - securityRules := make([]virtualmachine.CreateSecurityRulePayload, 0, len(rules.IngressRules)+1) + securityRules := make([]virtualmachine.CreateSecurityRulePayload, 0, len(rules.IngressRules)) seen := make(map[string]bool) addRule := func(ipRange, etherType string, fromPort, toPort int) { @@ -29,7 +29,6 @@ func makeDirectSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSec }) } - addRule("0.0.0.0/0", "IPv4", defaultSSHPort, defaultSSHPort) for _, rule := range rules.IngressRules { fromPort := int(rule.FromPort) toPort := int(rule.ToPort) diff --git a/v1/providers/hyperstack/validation_test.go b/v1/providers/hyperstack/validation_test.go index c3366a2..12e7360 100644 --- a/v1/providers/hyperstack/validation_test.go +++ b/v1/providers/hyperstack/validation_test.go @@ -30,6 +30,13 @@ func TestInstanceLifecycleValidation(t *testing.T) { 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"}, + }}, + }}, }) } From 8ef9dd6f0f92d1f6d46d378a1e9e1f7907407e46 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sat, 19 Sep 2026 06:51:32 -0700 Subject: [PATCH 10/23] fix start marker, simplify --- v1/providers/hyperstack/client.go | 3 - v1/providers/hyperstack/instance.go | 29 ++++------ v1/providers/hyperstack/instance_test.go | 36 ++++-------- v1/providers/hyperstack/instancetype.go | 31 +++++----- v1/providers/hyperstack/instancetype_test.go | 13 +++-- v1/providers/hyperstack/readiness.go | 61 ++++---------------- 6 files changed, 59 insertions(+), 114 deletions(-) diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go index 98a4568..dcdc65e 100644 --- a/v1/providers/hyperstack/client.go +++ b/v1/providers/hyperstack/client.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "strings" - "sync" pricebook "github.com/NexGenCloud/hyperstack-sdk-go/lib/Pricebook" "github.com/NexGenCloud/hyperstack-sdk-go/lib/environment" @@ -105,7 +104,6 @@ type HyperstackClient struct { keypairs *keypair.ClientWithResponses regions *region.ClientWithResponses pricebook *pricebook.Client - readinessMu *sync.Mutex logRequests map[int]int readyInstances map[int]struct{} } @@ -130,7 +128,6 @@ func NewHyperstackClient(credential HyperstackCredential, location string, opts apiKey: credential.APIKey, location: location, httpClient: http.DefaultClient, - readinessMu: &sync.Mutex{}, logRequests: make(map[int]int), readyInstances: make(map[int]struct{}), } diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 5f47bc0..05b3e26 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -22,7 +22,6 @@ const ( defaultSSHUser = "ubuntu" defaultPageSize = 100 refIDLabelPrefix = "brev-ref-" - cloudRefLabelPrefix = "brev-cloud-ref-" tagLabelPrefix = "brev-tag-" tagLabelSeparator = "_" managedKeyIDLabelPrefix = "brev-managed-key-id-" @@ -32,7 +31,7 @@ const ( var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) // Hyperstack permits at most 10 labels. These seven caller tags plus the -// canonical ref ID, readiness marker, and managed-key ID fill that budget. +// canonical ref ID and optional managed-key ID use at most nine labels. var instanceTagLabelKeys = []string{ "dev-plane-managedBy", "dev-plane-x-instanceId", @@ -73,7 +72,6 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn if keyPair.managedID != 0 { labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPair.managedID)) } - labels = append(labels, readinessLabel) assignFloatingIP := true enablePortRandomization := false enhancedMonitoringEnabled := false @@ -230,6 +228,7 @@ func (c *HyperstackClient) StopInstance(ctx context.Context, instanceID v1.Cloud if response.StatusCode() != http.StatusOK { return responseError("stop virtual machine", response.StatusCode(), response.Body, v1.ErrInstanceNotFound) } + c.resetReadiness(numericID) return nil } @@ -238,6 +237,7 @@ func (c *HyperstackClient) StartInstance(ctx context.Context, instanceID v1.Clou if err != nil { return err } + c.resetReadiness(numericID) response, err := c.virtualMachines.StartVMWithResponse(ctx, numericID) if err != nil { return wrapTransportError("start virtual machine", err) @@ -275,8 +275,7 @@ func (c *HyperstackClient) convertProviderInstance( providerInstance virtualmachine.InstanceFields, ) (v1.Instance, error) { consoleReady := false - if requiresConsoleReadiness(providerInstance.Labels) && - hyperstackLifecycleStatus(stringValue(providerInstance.Status)) == v1.LifecycleStatusRunning && + if hyperstackLifecycleStatus(stringValue(providerInstance.Status)) == v1.LifecycleStatusRunning && hyperstackAPIReady(providerInstance, strings.TrimSpace(stringValue(providerInstance.FloatingIp))) { instanceID := intValue(providerInstance.Id) if instanceID <= 0 { @@ -297,13 +296,10 @@ func (c *HyperstackClient) convertInstance( ) v1.Instance { cloudID := strconv.Itoa(intValue(providerInstance.Id)) name := strings.TrimSpace(stringValue(providerInstance.Name)) - refID, cloudCredRefID, tags := parseLabels(providerInstance.Labels) + refID, tags := parseLabels(providerInstance.Labels) if refID == "" { refID = name } - if cloudCredRefID == "" { - cloudCredRefID = c.refID - } location := "" if providerInstance.Environment != nil { @@ -328,7 +324,7 @@ func (c *HyperstackClient) convertInstance( instance := v1.Instance{ Name: name, RefID: refID, - CloudCredRefID: cloudCredRefID, + CloudCredRefID: c.refID, CloudID: v1.CloudProviderInstanceID(cloudID), PublicIP: publicIP, PublicDNS: publicIP, @@ -368,7 +364,7 @@ func hyperstackInstanceReady( if !hyperstackAPIReady(providerInstance, publicIP) { return false } - return !requiresConsoleReadiness(providerInstance.Labels) || consoleReady + return consoleReady } func hyperstackAPIReady(providerInstance virtualmachine.InstanceFields, publicIP string) bool { @@ -455,13 +451,12 @@ func makeLabels(refID string, tags v1.Tags) []string { return labels } -func parseLabels(providerLabels *[]string) (string, string, v1.Tags) { +func parseLabels(providerLabels *[]string) (string, v1.Tags) { tags := make(v1.Tags) if providerLabels == nil { - return "", "", tags + return "", tags } var refID string - var cloudCredRefID string for _, label := range *providerLabels { if key, value, ok := parseTagLabel(label); ok { tags[key] = value @@ -470,15 +465,13 @@ func parseLabels(providerLabels *[]string) (string, string, v1.Tags) { switch { case strings.HasPrefix(label, refIDLabelPrefix): refID = strings.TrimPrefix(label, refIDLabelPrefix) - case strings.HasPrefix(label, cloudRefLabelPrefix): - cloudCredRefID = strings.TrimPrefix(label, cloudRefLabelPrefix) - case strings.HasPrefix(label, managedKeyIDLabelPrefix), label == readinessLabel: + case strings.HasPrefix(label, managedKeyIDLabelPrefix): continue default: tags[label] = "" } } - return refID, cloudCredRefID, tags + return refID, tags } func providerTagLabelPrefix(key string) string { diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index eb51d17..ae016ba 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -22,6 +22,8 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef var labels []string deleted := false keyPairDeleteCount := 0 + stopCount := 0 + startCount := 0 server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { assert.Equal(t, "api-key", request.Header.Get("api_key")) switch { @@ -60,9 +62,10 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef assertSecurityRule(t, (*payload.SecurityRules)[0], "10.0.0.0/8", 8080, 8080) require.NotNil(t, payload.Labels) labels = *payload.Labels - assert.Contains(t, labels, readinessLabel) require.NotNil(t, payload.UserData) assert.Contains(t, *payload.UserData, readinessMarker) + assert.Contains(t, *payload.UserData, "WantedBy=multi-user.target") + assert.Contains(t, *payload.UserData, "systemctl, enable, brev-cloud-ready.service") writeJSON(t, writer, map[string]any{"status": true, "instances": []map[string]any{{"id": 42}}}) case request.URL.Path == "/v1/core/virtual-machines/42/logs" && request.Method == http.MethodPost: var payload virtualmachine.RequestInstanceLogsPayload @@ -117,8 +120,10 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef deleted = true writeJSON(t, writer, map[string]any{"status": true}) case request.URL.Path == "/v1/core/virtual-machines/42/stop" && request.Method == http.MethodGet: + stopCount++ writeJSON(t, writer, map[string]any{"status": true}) case request.URL.Path == "/v1/core/virtual-machines/42/start" && request.Method == http.MethodGet: + startCount++ writeJSON(t, writer, map[string]any{"status": true}) default: http.NotFound(writer, request) @@ -169,6 +174,8 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef assert.Equal(t, instance.RefID, instances[0].RefID) require.NoError(t, client.StopInstance(context.Background(), "42")) require.NoError(t, client.StartInstance(context.Background(), "42")) + assert.Equal(t, 1, stopCount) + assert.Equal(t, 1, startCount) require.NoError(t, client.TerminateInstance(context.Background(), "42")) require.NoError(t, client.TerminateInstance(context.Background(), "42")) @@ -235,38 +242,26 @@ func TestLabelsRoundTripPlainValues(t *testing.T) { } labels := makeLabels(refID, tags) labels = append(labels, managedKeyIDLabelPrefix+"7") - labels = append(labels, readinessLabel) - require.Len(t, labels, 10) assert.Contains(t, labels, refIDLabelPrefix+refID) assert.Contains(t, labels, "brev-tag-dev-plane-managedby_dev-plane") assert.Contains(t, labels, "brev-tag-dev-plane-x-instanceid_instance-id") - assert.NotContains(t, labels, cloudRefLabelPrefix+"credential-ref") assert.NotContains(t, labels, tagLabelPrefix+"team=gpu-workers") - parsedRefID, cloudRefID, parsedTags := parseLabels(&labels) + parsedRefID, parsedTags := parseLabels(&labels) assert.Equal(t, refID, parsedRefID) - assert.Empty(t, cloudRefID) for _, key := range instanceTagLabelKeys { assert.Equal(t, tags[key], parsedTags[key]) } assert.NotContains(t, parsedTags, "team") } -func TestParseLabelsSupportsLegacyCloudRefLabel(t *testing.T) { - labels := []string{cloudRefLabelPrefix + "credential-ref"} - - _, cloudRefID, _ := parseLabels(&labels) - - assert.Equal(t, "credential-ref", cloudRefID) -} - func TestParseLabelsOnlyDecodesKnownTagPrefixes(t *testing.T) { labels := []string{ "brev-tag-team_gpu-workers", "brev-tag-dev-plane-x-instanceid_instance-123", } - _, _, tags := parseLabels(&labels) + _, tags := parseLabels(&labels) assert.Equal(t, "instance-123", tags["dev-plane-x-instanceId"]) assert.Equal(t, "", tags["brev-tag-team_gpu-workers"]) @@ -296,14 +291,6 @@ func TestActiveInstanceWaitsForFloatingIP(t *testing.T) { Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attaching, }, false) assert.Equal(t, v1.LifecycleStatusPending, withAttachingIP.Status.LifecycleStatus) - - attached := "ATTACHED" - running := "RUNNING" - ready := client.convertInstance(virtualmachine.InstanceFields{ - Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, - VmState: &status, PowerState: &running, - }, false) - assert.Equal(t, v1.LifecycleStatusRunning, ready.Status.LifecycleStatus) } func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { @@ -311,10 +298,9 @@ func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { publicIP := "203.0.113.42" attached := "ATTACHED" running := "RUNNING" - labels := []string{readinessLabel} providerInstance := virtualmachine.InstanceFields{ Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, - VmState: &status, PowerState: &running, Labels: &labels, + VmState: &status, PowerState: &running, } assert.False(t, hyperstackInstanceReady(providerInstance, publicIP, false)) diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index 103cf86..e9834a8 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -116,16 +116,6 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s memory, memoryBytes := byteSizes(memoryGB, v1.Gigabyte) storageGB := int64(intValue(providerType.Disk)) - storageType := "ssd" - isEphemeral := false - if ephemeralGB := int64(intValue(providerType.Ephemeral)); ephemeralGB > 0 { - // Hyperstack exposes flavor-local ephemeral storage as a separate disk - // from the root disk. Present that capacity when available so consumers - // do not mistake the fixed root disk for workload storage. - storageGB = ephemeralGB - storageType = "ephemeral" - isEphemeral = true - } storage, storageBytes := byteSizes(storageGB, v1.Gigabyte) gpuType := strings.TrimSpace(stringValue(providerType.Gpu)) @@ -157,13 +147,24 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s } if storageGB > 0 { instanceType.SupportedStorage = []v1.Storage{{ - Type: storageType, - Count: 1, - Size: storage, - SizeBytes: storageBytes, - IsEphemeral: isEphemeral, + 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)} } diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go index 62de23b..fc67089 100644 --- a/v1/providers/hyperstack/instancetype_test.go +++ b/v1/providers/hyperstack/instancetype_test.go @@ -92,10 +92,15 @@ func TestGetInstanceTypesAndLocations(t *testing.T) { assert.Equal(t, v1.NewBytes(80, v1.Gigabyte), gpuType.SupportedGPUs[0].MemoryBytes) assert.Equal(t, "H100", gpuType.SupportedGPUs[0].Name) assert.Equal(t, "PCIe", gpuType.SupportedGPUs[0].NetworkDetails) - require.Len(t, gpuType.SupportedStorage, 1) - assert.Equal(t, "ephemeral", gpuType.SupportedStorage[0].Type) - assert.Equal(t, v1.NewBytes(1500, v1.Gigabyte), gpuType.SupportedStorage[0].SizeBytes) - assert.True(t, gpuType.SupportedStorage[0].IsEphemeral) + require.Len(t, gpuType.SupportedStorage, 2) + assert.Equal(t, "ssd", gpuType.SupportedStorage[0].Type) + assert.Equal(t, v1.NewBytes(100, v1.Gigabyte), gpuType.SupportedStorage[0].SizeBytes) + assert.False(t, gpuType.SupportedStorage[0].IsEphemeral) + assert.Equal(t, "ephemeral", gpuType.SupportedStorage[1].Type) + assert.Equal(t, v1.NewBytes(1500, v1.Gigabyte), gpuType.SupportedStorage[1].SizeBytes) + assert.True(t, gpuType.SupportedStorage[1].IsEphemeral) + assert.True(t, gpuType.SupportedStorage[1].IsAdditionalDisk) + assert.True(t, gpuType.SupportedStorage[1].RequiresVolumeMountPath) cpuType := instanceTypes[1] assert.Equal(t, "0.220", cpuType.BasePrice.Number()) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 2f96a75..9ef33c5 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -10,7 +10,6 @@ import ( ) const ( - readinessLabel = "brev-readiness-v1" readinessMarker = "BREV_CLOUD_READY_V1" consoleLogLineCount = 200 readinessCloudConfig = `#cloud-config @@ -25,30 +24,21 @@ write_files: [Service] Type=oneshot ExecStart=/bin/sh -c 'while systemctl list-jobs --no-legend --no-pager | grep -v brev-cloud-ready.service | grep -q .; do sleep 2; done; 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] ` ) -func requiresConsoleReadiness(labels *[]string) bool { - return labels != nil && containsString(*labels, readinessLabel) -} - -func containsString(values []string, expected string) bool { - for _, value := range values { - if value == expected { - return true - } - } - return false -} - func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bool, error) { - requestID, ready := c.readinessState(instanceID) - if ready { + if _, ok := c.readyInstances[instanceID]; ok { return true, nil } + requestID := c.logRequests[instanceID] if requestID == 0 { var err error requestID, err = c.requestConsoleLogs(ctx, instanceID) @@ -58,7 +48,7 @@ func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bo if requestID == 0 { return false, nil } - c.setLogRequest(instanceID, requestID) + c.logRequests[instanceID] = requestID } response, err := c.virtualMachines.GetVMLogsWithResponse(ctx, instanceID, &virtualmachine.GetVMLogsParams{ @@ -82,16 +72,16 @@ func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bo // request instead of starting over indefinitely. return false, nil } - c.clearLogRequest(instanceID) + delete(c.logRequests, instanceID) if !strings.Contains(*response.JSON200.Logs, readinessMarker) { return false, nil } - c.setReady(instanceID) + c.readyInstances[instanceID] = struct{}{} return true, nil case http.StatusUnauthorized, http.StatusForbidden: return false, responseError("get virtual machine console logs", response.StatusCode(), response.Body, nil) default: - c.clearLogRequest(instanceID) + delete(c.logRequests, instanceID) return false, nil } } @@ -126,34 +116,7 @@ func (c *HyperstackClient) requestConsoleLogs(ctx context.Context, instanceID in return payload.RequestID, nil } -func (c *HyperstackClient) readinessState(instanceID int) (requestID int, ready bool) { - c.readinessMu.Lock() - defer c.readinessMu.Unlock() - _, ready = c.readyInstances[instanceID] - return c.logRequests[instanceID], ready -} - -func (c *HyperstackClient) setLogRequest(instanceID, requestID int) { - c.readinessMu.Lock() - defer c.readinessMu.Unlock() - if c.logRequests == nil { - c.logRequests = make(map[int]int) - } - c.logRequests[instanceID] = requestID -} - -func (c *HyperstackClient) clearLogRequest(instanceID int) { - c.readinessMu.Lock() - defer c.readinessMu.Unlock() - delete(c.logRequests, instanceID) -} - -func (c *HyperstackClient) setReady(instanceID int) { - c.readinessMu.Lock() - defer c.readinessMu.Unlock() - if c.readyInstances == nil { - c.readyInstances = make(map[int]struct{}) - } +func (c *HyperstackClient) resetReadiness(instanceID int) { delete(c.logRequests, instanceID) - c.readyInstances[instanceID] = struct{}{} + delete(c.readyInstances, instanceID) } From 56695107f62c4e6b07fd0261abc3f37f532a60c5 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sat, 19 Sep 2026 06:57:06 -0700 Subject: [PATCH 11/23] rename --- v1/providers/hyperstack/instance.go | 22 ++++--------------- v1/providers/hyperstack/instance_test.go | 26 +++++++++-------------- v1/providers/hyperstack/security_rules.go | 2 +- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 05b3e26..2569882 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -64,7 +64,7 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn if imageName == "" { imageName = defaultImageName } - securityRules, err := makeDirectSecurityRules(attrs.FirewallRules) + securityRules, err := makeSecurityRules(attrs.FirewallRules) if err != nil { return nil, err } @@ -80,7 +80,7 @@ func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateIn // 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, ""), + Name: managedResourceName(attrs.RefID), EnvironmentName: environmentName, KeyName: keyPair.name, ImageName: &imageName, @@ -317,7 +317,7 @@ func (c *HyperstackClient) convertInstance( } publicIP := strings.TrimSpace(stringValue(providerInstance.FloatingIp)) lifecycleStatus := hyperstackLifecycleStatus(stringValue(providerInstance.Status)) - if lifecycleStatus == v1.LifecycleStatusRunning && !hyperstackInstanceReady(providerInstance, publicIP, consoleReady) { + if lifecycleStatus == v1.LifecycleStatusRunning && (!hyperstackAPIReady(providerInstance, publicIP) || !consoleReady) { lifecycleStatus = v1.LifecycleStatusPending } @@ -356,17 +356,6 @@ func (c *HyperstackClient) convertInstance( return instance } -func hyperstackInstanceReady( - providerInstance virtualmachine.InstanceFields, - publicIP string, - consoleReady bool, -) bool { - if !hyperstackAPIReady(providerInstance, publicIP) { - return false - } - return consoleReady -} - func hyperstackAPIReady(providerInstance virtualmachine.InstanceFields, publicIP string) bool { if publicIP == "" { return false @@ -505,11 +494,8 @@ func managedKeyPairID(providerLabels *[]string) (int, error) { return 0, nil } -func managedResourceName(name, fallback string) string { +func managedResourceName(name string) string { name = strings.TrimSpace(name) - if name == "" { - name = fallback - } name = resourceNameInvalidCharacters.ReplaceAllString(name, "-") name = strings.Trim(name, "-") if len(name) > 63 { diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index ae016ba..77e2387 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -294,6 +294,7 @@ func TestActiveInstanceWaitsForFloatingIP(t *testing.T) { } func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { + client := &HyperstackClient{} status := "ACTIVE" publicIP := "203.0.113.42" attached := "ATTACHED" @@ -303,8 +304,11 @@ func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { VmState: &status, PowerState: &running, } - assert.False(t, hyperstackInstanceReady(providerInstance, publicIP, false)) - assert.True(t, hyperstackInstanceReady(providerInstance, publicIP, true)) + 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 TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { @@ -389,25 +393,15 @@ func assertSecurityRule(t *testing.T, rule virtualmachine.CreateSecurityRulePayl assert.Equal(t, toPort, *rule.PortRangeMax) } -func TestMakeDirectSecurityRulesDoesNotWidenCallerSSHIngress(t *testing.T) { - rules, err := makeDirectSecurityRules(v1.FirewallRules{IngressRules: []v1.FirewallRule{{ +func TestMakeSecurityRules(t *testing.T) { + rules, err := makeSecurityRules(v1.FirewallRules{IngressRules: []v1.FirewallRule{{ FromPort: defaultSSHPort, ToPort: defaultSSHPort, - IPRanges: []string{"52.9.0.116/32", "52.52.248.36/32"}, + IPRanges: []string{"52.9.0.116/32"}, }}}) require.NoError(t, err) - require.Len(t, rules, 2) + require.Len(t, rules, 1) assertSecurityRule(t, rules[0], "52.9.0.116/32", defaultSSHPort, defaultSSHPort) - assertSecurityRule(t, rules[1], "52.52.248.36/32", defaultSSHPort, defaultSSHPort) - for _, rule := range rules { - assert.NotEqual(t, "0.0.0.0/0", rule.RemoteIpPrefix) - } -} - -func TestMakeDirectSecurityRulesDoesNotInventSSHIngress(t *testing.T) { - rules, err := makeDirectSecurityRules(v1.FirewallRules{}) - require.NoError(t, err) - assert.Empty(t, rules) } func TestValidateCreateInstanceAttrs(t *testing.T) { diff --git a/v1/providers/hyperstack/security_rules.go b/v1/providers/hyperstack/security_rules.go index 4568931..df3c238 100644 --- a/v1/providers/hyperstack/security_rules.go +++ b/v1/providers/hyperstack/security_rules.go @@ -9,7 +9,7 @@ import ( v1 "github.com/brevdev/cloud/v1" ) -func makeDirectSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSecurityRulePayload, error) { +func makeSecurityRules(rules v1.FirewallRules) ([]virtualmachine.CreateSecurityRulePayload, error) { securityRules := make([]virtualmachine.CreateSecurityRulePayload, 0, len(rules.IngressRules)) seen := make(map[string]bool) From 33a7badb37d2a6c39070a7b55dd385d26a26b5a6 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sat, 19 Sep 2026 07:41:11 -0700 Subject: [PATCH 12/23] simplify state fetch --- v1/providers/hyperstack/client.go | 12 ++-- v1/providers/hyperstack/instance.go | 2 - v1/providers/hyperstack/instance_test.go | 6 -- v1/providers/hyperstack/readiness.go | 77 ++++++++++-------------- 4 files changed, 36 insertions(+), 61 deletions(-) diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go index dcdc65e..0b43eaf 100644 --- a/v1/providers/hyperstack/client.go +++ b/v1/providers/hyperstack/client.go @@ -104,8 +104,6 @@ type HyperstackClient struct { keypairs *keypair.ClientWithResponses regions *region.ClientWithResponses pricebook *pricebook.Client - logRequests map[int]int - readyInstances map[int]struct{} } var _ v1.CloudClient = &HyperstackClient{} @@ -124,12 +122,10 @@ func NewHyperstackClient(credential HyperstackCredential, location string, opts } client := &HyperstackClient{ - refID: credential.RefID, - apiKey: credential.APIKey, - location: location, - httpClient: http.DefaultClient, - logRequests: make(map[int]int), - readyInstances: make(map[int]struct{}), + refID: credential.RefID, + apiKey: credential.APIKey, + location: location, + httpClient: http.DefaultClient, } for _, opt := range opts { opt(client) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 2569882..b332ab8 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -228,7 +228,6 @@ func (c *HyperstackClient) StopInstance(ctx context.Context, instanceID v1.Cloud if response.StatusCode() != http.StatusOK { return responseError("stop virtual machine", response.StatusCode(), response.Body, v1.ErrInstanceNotFound) } - c.resetReadiness(numericID) return nil } @@ -237,7 +236,6 @@ func (c *HyperstackClient) StartInstance(ctx context.Context, instanceID v1.Clou if err != nil { return err } - c.resetReadiness(numericID) response, err := c.virtualMachines.StartVMWithResponse(ctx, numericID) if err != nil { return wrapTransportError("start virtual machine", err) diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 77e2387..d61a8f6 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -338,9 +338,6 @@ func TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { client := newTestClient(t, server.URL) ready, err := client.consoleReady(context.Background(), 42) require.NoError(t, err) - assert.False(t, ready) - ready, err = client.consoleReady(context.Background(), 42) - require.NoError(t, err) assert.True(t, ready) assert.Equal(t, 1, requestCalls) assert.Equal(t, 2, getCalls) @@ -373,9 +370,6 @@ func TestConsoleReadyRetainsRequestWhileLogsAreProcessing(t *testing.T) { client := newTestClient(t, server.URL) ready, err := client.consoleReady(context.Background(), 42) require.NoError(t, err) - assert.False(t, ready) - ready, err = client.consoleReady(context.Background(), 42) - require.NoError(t, err) assert.True(t, ready) assert.Equal(t, 1, requestCalls) assert.Equal(t, 2, getCalls) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 9ef33c5..3cfe2b6 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "strings" + "time" virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" ) @@ -12,6 +13,8 @@ import ( const ( readinessMarker = "BREV_CLOUD_READY_V1" consoleLogLineCount = 200 + consoleLogPolls = 5 + consoleLogPollPeriod = 500 * time.Millisecond readinessCloudConfig = `#cloud-config write_files: - path: /etc/systemd/system/brev-cloud-ready.service @@ -35,55 +38,44 @@ runcmd: ) func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bool, error) { - if _, ok := c.readyInstances[instanceID]; ok { - return true, nil + requestID, err := c.requestConsoleLogs(ctx, instanceID) + if err != nil || requestID == 0 { + return false, err } - requestID := c.logRequests[instanceID] - if requestID == 0 { - var err error - requestID, err = c.requestConsoleLogs(ctx, instanceID) + + for poll := 0; poll < consoleLogPolls; poll++ { + response, err := c.virtualMachines.GetVMLogsWithResponse(ctx, instanceID, &virtualmachine.GetVMLogsParams{ + RequestId: requestID, + }) if err != nil { - return false, err - } - if requestID == 0 { + if ctx.Err() != nil { + return false, ctx.Err() + } return false, nil } - c.logRequests[instanceID] = requestID - } - 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.StatusAccepted, http.StatusBadRequest: - // The request endpoint is asynchronous. The retrieval endpoint documents - // 400, rather than 202, while a valid request is not ready to read yet. - return false, nil - case http.StatusOK: - if response.JSON200 == nil || response.JSON200.Logs == nil { - // Hyperstack returns 200 while the asynchronous log request is still - // processing. Keep its request ID so the next poll retrieves the same - // request instead of starting over indefinitely. + switch response.StatusCode() { + case http.StatusOK: + if response.JSON200 != nil && response.JSON200.Logs != nil { + return strings.Contains(*response.JSON200.Logs, readinessMarker), 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 } - delete(c.logRequests, instanceID) - if !strings.Contains(*response.JSON200.Logs, readinessMarker) { - return false, nil + + if poll < consoleLogPolls-1 { + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-time.After(consoleLogPollPeriod): + } } - c.readyInstances[instanceID] = struct{}{} - return true, nil - case http.StatusUnauthorized, http.StatusForbidden: - return false, responseError("get virtual machine console logs", response.StatusCode(), response.Body, nil) - default: - delete(c.logRequests, instanceID) - return false, nil } + return false, nil } func (c *HyperstackClient) requestConsoleLogs(ctx context.Context, instanceID int) (int, error) { @@ -115,8 +107,3 @@ func (c *HyperstackClient) requestConsoleLogs(ctx context.Context, instanceID in } return payload.RequestID, nil } - -func (c *HyperstackClient) resetReadiness(instanceID int) { - delete(c.logRequests, instanceID) - delete(c.readyInstances, instanceID) -} From 08f3250363f8dd6d747af3136425193f984050af Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Sat, 19 Sep 2026 12:29:50 -0700 Subject: [PATCH 13/23] idempotent start --- v1/providers/hyperstack/instance.go | 8 ++++++++ v1/providers/hyperstack/instance_test.go | 6 +++++- v1/providers/hyperstack/readiness.go | 5 +++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index b332ab8..3a517ff 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -236,6 +236,14 @@ func (c *HyperstackClient) StartInstance(ctx context.Context, instanceID v1.Clou 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) diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index d61a8f6..a80f157 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -24,6 +24,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef keyPairDeleteCount := 0 stopCount := 0 startCount := 0 + providerStatus := "ACTIVE" server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { assert.Equal(t, "api-key", request.Header.Get("api_key")) switch { @@ -87,7 +88,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef "instance": map[string]any{ "id": 42, "name": "ref-123", - "status": "ACTIVE", + "status": providerStatus, "created_at": "2026-09-04T12:00:00", "floating_ip": "203.0.113.42", "fixed_ip": "10.0.0.42", @@ -121,9 +122,11 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef writeJSON(t, writer, map[string]any{"status": true}) case request.URL.Path == "/v1/core/virtual-machines/42/stop" && request.Method == http.MethodGet: stopCount++ + providerStatus = "SHUTOFF" writeJSON(t, writer, map[string]any{"status": true}) case request.URL.Path == "/v1/core/virtual-machines/42/start" && request.Method == http.MethodGet: startCount++ + providerStatus = "ACTIVE" writeJSON(t, writer, map[string]any{"status": true}) default: http.NotFound(writer, request) @@ -174,6 +177,7 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef assert.Equal(t, instance.RefID, instances[0].RefID) require.NoError(t, client.StopInstance(context.Background(), "42")) require.NoError(t, client.StartInstance(context.Background(), "42")) + require.NoError(t, client.StartInstance(context.Background(), "42")) assert.Equal(t, 1, stopCount) assert.Equal(t, 1, startCount) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 3cfe2b6..c710f23 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -22,11 +22,12 @@ write_files: content: | [Unit] Description=Brev cloud instance readiness signal - After=cloud-final.service + Wants=network-online.target + After=network-online.target [Service] Type=oneshot - ExecStart=/bin/sh -c 'while systemctl list-jobs --no-legend --no-pager | grep -v brev-cloud-ready.service | grep -q .; do sleep 2; done; printf "BREV_CLOUD_READY_V1\n" > /dev/ttyS0' + ExecStart=/bin/sh -c 'printf "BREV_CLOUD_READY_V1\n" > /dev/ttyS0' [Install] WantedBy=multi-user.target From 50d53dccb8d912284709b4b838737e796b298b9a Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 21 Sep 2026 15:38:04 -0700 Subject: [PATCH 14/23] more cleanup --- v1/providers/hyperstack/client.go | 9 ++-- v1/providers/hyperstack/environment.go | 54 +++++++++++++++++------- v1/providers/hyperstack/errors.go | 2 + v1/providers/hyperstack/instance.go | 3 +- v1/providers/hyperstack/instance_test.go | 4 +- v1/providers/hyperstack/location.go | 25 ++++++----- v1/providers/hyperstack/readiness.go | 7 ++- 7 files changed, 69 insertions(+), 35 deletions(-) diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go index 0b43eaf..5ee4cd8 100644 --- a/v1/providers/hyperstack/client.go +++ b/v1/providers/hyperstack/client.go @@ -94,10 +94,11 @@ func (c *HyperstackCredential) MakeClientWithOptions(_ context.Context, location type HyperstackClient struct { v1.NotImplCloudClient - refID string - apiKey string - location string - httpClient *http.Client + refID string + apiKey string + location string + httpClient *http.Client + virtualMachines *virtualmachine.ClientWithResponses environments *environment.ClientWithResponses flavors *flavor.ClientWithResponses diff --git a/v1/providers/hyperstack/environment.go b/v1/providers/hyperstack/environment.go index 7ef5fc2..07dc64b 100644 --- a/v1/providers/hyperstack/environment.go +++ b/v1/providers/hyperstack/environment.go @@ -10,27 +10,24 @@ import ( "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 - pageSize := strconv.Itoa(defaultPageSize) + for page := 1; ; page++ { - pageNumber := strconv.Itoa(page) - response, err := c.environments.ListEnvironmentsWithResponse(ctx, &environment.ListEnvironmentsParams{ - Page: &pageNumber, - PageSize: &pageSize, - Search: &environmentName, + providerEnvironments, err := c.listEnvironmentPage(ctx, listEnvironmentPageArgs{ + Page: page, + PageSize: listEnvironmentsPageSize, + Search: environmentName, }) if err != nil { - return environment.EnvironmentFields{}, wrapTransportError("list environments", err) - } - if response.StatusCode() != http.StatusOK { - return environment.EnvironmentFields{}, responseError("list environments", response.StatusCode(), response.Body, nil) - } - if response.JSON200 == nil || response.JSON200.Environments == nil { - return environment.EnvironmentFields{}, errors.New("hyperstack list environments response did not contain data") + return environment.EnvironmentFields{}, err } - providerEnvironments := *response.JSON200.Environments for _, providerEnvironment := range providerEnvironments { if stringValue(providerEnvironment.Name) != environmentName || stringValue(providerEnvironment.Region) != location { continue @@ -40,8 +37,35 @@ func (c *HyperstackClient) getDefaultEnvironment(ctx context.Context, location s } return providerEnvironment, nil } - if len(providerEnvironments) < defaultPageSize { + + 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 index 5286138..93d0fea 100644 --- a/v1/providers/hyperstack/errors.go +++ b/v1/providers/hyperstack/errors.go @@ -22,6 +22,7 @@ func wrapTransportError(operation string, err error) error { 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 == "" { @@ -30,6 +31,7 @@ func responseError(operation string, statusCode int, body []byte, notFound error requestError := fmt.Errorf("HTTP %d: %s", statusCode, detail) lowerDetail := strings.ToLower(detail) + var sentinel error switch { case statusCode == http.StatusNotFound && notFound != nil: diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 3a517ff..9a1c2de 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -25,7 +25,6 @@ const ( tagLabelPrefix = "brev-tag-" tagLabelSeparator = "_" managedKeyIDLabelPrefix = "brev-managed-key-id-" - defaultEnvironmentTag = "default-" ) var resourceNameInvalidCharacters = regexp.MustCompile(`[^a-zA-Z0-9-]+`) @@ -288,7 +287,7 @@ func (c *HyperstackClient) convertProviderInstance( return v1.Instance{}, errors.New("hyperstack virtual machine response did not contain an instance ID") } var err error - consoleReady, err = c.consoleReady(ctx, instanceID) + consoleReady, err = c.vmOperatingSystemReportsReady(ctx, instanceID) if err != nil { return v1.Instance{}, err } diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index a80f157..820455a 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -340,7 +340,7 @@ func TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - ready, err := client.consoleReady(context.Background(), 42) + ready, err := client.vmOperatingSystemReportsReady(context.Background(), 42) require.NoError(t, err) assert.True(t, ready) assert.Equal(t, 1, requestCalls) @@ -372,7 +372,7 @@ func TestConsoleReadyRetainsRequestWhileLogsAreProcessing(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - ready, err := client.consoleReady(context.Background(), 42) + ready, err := client.vmOperatingSystemReportsReady(context.Background(), 42) require.NoError(t, err) assert.True(t, ready) assert.Equal(t, 1, requestCalls) diff --git a/v1/providers/hyperstack/location.go b/v1/providers/hyperstack/location.go index 355ca2c..5ec4622 100644 --- a/v1/providers/hyperstack/location.go +++ b/v1/providers/hyperstack/location.go @@ -23,16 +23,17 @@ func (c *HyperstackClient) GetLocations(ctx context.Context, args v1.GetLocation if response.JSON200 == nil || response.JSON200.Regions == nil { return nil, fmt.Errorf("hyperstack list regions response did not contain data") } + regions := *response.JSON200.Regions - flavorGroups, err := c.listFlavors(ctx) + allFlavors, err := c.listFlavors(ctx) if err != nil { return nil, err } - available := availableFlavorLocations(flavorGroups) + regionsWithAvailability := availableFlavorLocations(allFlavors) - locations := make([]v1.Location, 0, len(*response.JSON200.Regions)) - for _, providerRegion := range *response.JSON200.Regions { - location, ok := hyperstackLocation(providerRegion, available) + locations := make([]v1.Location, 0, len(regions)) + for _, providerRegion := range regions { + location, ok := hyperstackLocation(providerRegion, regionsWithAvailability) if !ok { continue } @@ -66,23 +67,25 @@ func availableFlavorLocations(groups []flavor.FlavorItemGetResponse) map[string] return available } -func hyperstackLocation(providerRegion region.RegionFields, available map[string]bool) (v1.Location, bool) { - name := strings.TrimSpace(stringValue(providerRegion.Name)) - if name == "" { +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 = name + description = regionName } return v1.Location{ - Name: name, + Name: regionName, Description: description, - Available: available[name] && supportsFloatingIP(providerRegion.Features), + 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 diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index c710f23..441a81c 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -38,7 +38,12 @@ runcmd: ` ) -func (c *HyperstackClient) consoleReady(ctx context.Context, instanceID int) (bool, error) { +// 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. +// See: https://docs.hyperstack.cloud/docs/virtual-machines/virtual-machine-features/#managing-virtual-machines +func (c *HyperstackClient) vmOperatingSystemReportsReady(ctx context.Context, instanceID int) (bool, error) { requestID, err := c.requestConsoleLogs(ctx, instanceID) if err != nil || requestID == 0 { return false, err From a9591c82ce707717a13c63edfc769c0df358b0ad Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Mon, 21 Sep 2026 15:38:29 -0700 Subject: [PATCH 15/23] remove superfluous test --- v1/providers/hyperstack/client_test.go | 34 -------------------------- 1 file changed, 34 deletions(-) delete mode 100644 v1/providers/hyperstack/client_test.go diff --git a/v1/providers/hyperstack/client_test.go b/v1/providers/hyperstack/client_test.go deleted file mode 100644 index c424f16..0000000 --- a/v1/providers/hyperstack/client_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package hyperstack - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - v1 "github.com/brevdev/cloud/v1" -) - -func TestHyperstackCredential(t *testing.T) { - credential := NewHyperstackCredential("credential-ref", "api-key") - - assert.Equal(t, DefaultAPIURL, credential.APIURL) - assert.Equal(t, v1.CloudProviderID(CloudProviderID), credential.GetCloudProviderID()) - assert.Equal(t, v1.APITypeGlobal, credential.GetAPIType()) - assert.Equal(t, "credential-ref", credential.GetReferenceID()) - require.NoError(t, credential.Validate()) - - tenantID, err := credential.GetTenantID() - require.NoError(t, err) - assert.NotEmpty(t, tenantID) - assert.NotContains(t, tenantID, credential.APIKey) - - capabilities, err := credential.GetCapabilities(context.Background()) - require.NoError(t, err) - assert.Equal(t, getCapabilities(), capabilities) - assert.True(t, capabilities.IsCapable(v1.CapabilityStopStartInstance)) - - invalid := NewHyperstackCredential("credential-ref", "") - require.Error(t, invalid.Validate()) -} From 0626b0421e0d61c1c84a48e4c08222e318182ddc Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 10:17:25 -0700 Subject: [PATCH 16/23] try to label --- v1/providers/hyperstack/client.go | 8 +++++ v1/providers/hyperstack/instance.go | 11 ++---- v1/providers/hyperstack/instance_test.go | 6 ++-- v1/providers/hyperstack/readiness.go | 45 ++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go index 5ee4cd8..2daf755 100644 --- a/v1/providers/hyperstack/client.go +++ b/v1/providers/hyperstack/client.go @@ -98,6 +98,7 @@ type HyperstackClient struct { apiKey string location string httpClient *http.Client + logger v1.Logger virtualMachines *virtualmachine.ClientWithResponses environments *environment.ClientWithResponses @@ -117,6 +118,12 @@ func WithHTTPClient(httpClient *http.Client) HyperstackClientOption { } } +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 @@ -127,6 +134,7 @@ func NewHyperstackClient(credential HyperstackCredential, location string, opts apiKey: credential.APIKey, location: location, httpClient: http.DefaultClient, + logger: &v1.NoopLogger{}, } for _, opt := range opts { opt(client) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 9a1c2de..8f1cc43 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -275,19 +275,12 @@ func parseInstanceID(instanceID v1.CloudProviderInstanceID) (int, error) { return numericID, nil } -func (c *HyperstackClient) convertProviderInstance( - ctx context.Context, - providerInstance virtualmachine.InstanceFields, -) (v1.Instance, error) { +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))) { - instanceID := intValue(providerInstance.Id) - if instanceID <= 0 { - return v1.Instance{}, errors.New("hyperstack virtual machine response did not contain an instance ID") - } var err error - consoleReady, err = c.vmOperatingSystemReportsReady(ctx, instanceID) + consoleReady, err = c.vmOperatingSystemReportsReady(ctx, providerInstance) if err != nil { return v1.Instance{}, err } diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 820455a..5924926 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -340,7 +340,8 @@ func TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - ready, err := client.vmOperatingSystemReportsReady(context.Background(), 42) + 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) @@ -372,7 +373,8 @@ func TestConsoleReadyRetainsRequestWhileLogsAreProcessing(t *testing.T) { defer server.Close() client := newTestClient(t, server.URL) - ready, err := client.vmOperatingSystemReportsReady(context.Background(), 42) + 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) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 441a81c..07f72cc 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -3,11 +3,14 @@ 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 ( @@ -15,6 +18,7 @@ const ( 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 @@ -41,15 +45,23 @@ runcmd: // 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. +// 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, instanceID int) (bool, error) { +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, }) @@ -63,7 +75,9 @@ func (c *HyperstackClient) vmOperatingSystemReportsReady(ctx context.Context, in switch response.StatusCode() { case http.StatusOK: if response.JSON200 != nil && response.JSON200.Logs != nil { - return strings.Contains(*response.JSON200.Logs, readinessMarker), 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. @@ -113,3 +127,28 @@ func (c *HyperstackClient) requestConsoleLogs(ctx context.Context, instanceID in } 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 + } + + response, err := c.virtualMachines.AddVMLabelWithResponse(ctx, *providerInstance.Id, virtualmachine.AddVMLabelJSONRequestBody{ + Labels: &[]string{readinessLabel}, + }) + 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)) + } +} From 7414b72a9914008035410a44c7aebabb2ce6e4e9 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 10:36:59 -0700 Subject: [PATCH 17/23] try to label --- v1/providers/hyperstack/readiness.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 07f72cc..2063a57 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -140,8 +140,14 @@ func (c *HyperstackClient) updateReadinessLabel(ctx context.Context, providerIns return } + labels := *providerInstance.Labels + if labels == nil { + labels = []string{} + } + labels = append(labels, readinessLabel) + response, err := c.virtualMachines.AddVMLabelWithResponse(ctx, *providerInstance.Id, virtualmachine.AddVMLabelJSONRequestBody{ - Labels: &[]string{readinessLabel}, + Labels: &labels, }) if err != nil { c.logger.Warn(ctx, fmt.Sprintf("error adding virtual machine label: %v", err), v1.LogField("instance_id", *providerInstance.Id)) From 64d52ad1302d8a6cef706d5772a65f08ceae5602 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 10:48:24 -0700 Subject: [PATCH 18/23] dont panic --- v1/providers/hyperstack/readiness.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/v1/providers/hyperstack/readiness.go b/v1/providers/hyperstack/readiness.go index 2063a57..96303bd 100644 --- a/v1/providers/hyperstack/readiness.go +++ b/v1/providers/hyperstack/readiness.go @@ -140,9 +140,9 @@ func (c *HyperstackClient) updateReadinessLabel(ctx context.Context, providerIns return } - labels := *providerInstance.Labels - if labels == nil { - labels = []string{} + labels := []string{} + if providerInstance.Labels != nil { + labels = *providerInstance.Labels } labels = append(labels, readinessLabel) From a2fb75f3049354c2f6dbfad56309922e1a07b5d2 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 12:04:41 -0700 Subject: [PATCH 19/23] refactor tests --- v1/providers/hyperstack/instance.go | 2 +- v1/providers/hyperstack/instance_test.go | 310 +++++++------------ v1/providers/hyperstack/instancetype_test.go | 136 +++----- 3 files changed, 158 insertions(+), 290 deletions(-) diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index 8f1cc43..bb29017 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -41,7 +41,7 @@ var instanceTagLabelKeys = []string{ "dev-plane-stage", } -func (c *HyperstackClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { +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) diff --git a/v1/providers/hyperstack/instance_test.go b/v1/providers/hyperstack/instance_test.go index 5924926..a4ca642 100644 --- a/v1/providers/hyperstack/instance_test.go +++ b/v1/providers/hyperstack/instance_test.go @@ -3,131 +3,59 @@ package hyperstack import ( "context" "encoding/json" - "errors" + "fmt" "net/http" "net/http/httptest" + "strconv" "testing" virtualmachine "github.com/NexGenCloud/hyperstack-sdk-go/lib/virtual_machine" - "github.com/alecthomas/units" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "github.com/brevdev/cloud/v1" ) -const testSSHPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBdptDTzJ2cOmdyryG1B7yb1YssiCQs6SWu4HlbZXGE" +const ( + testSSHPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBdptDTzJ2cOmdyryG1B7yb1YssiCQs6SWu4HlbZXGE" + statusActive = "ACTIVE" + statusShuttingOff = "SHUTOFF" + statusAttached = "ATTACHED" + statusAttaching = "ATTACHING" +) -func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one stateful server makes the lifecycle easy to verify - var labels []string - deleted := false - keyPairDeleteCount := 0 - stopCount := 0 - startCount := 0 - providerStatus := "ACTIVE" +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) { - assert.Equal(t, "api-key", request.Header.Get("api_key")) switch { case request.URL.Path == "/v1/core/environments" && request.Method == http.MethodGet: - assert.Equal(t, "default-CANADA-1", request.URL.Query().Get("search")) 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: - var payload map[string]any - require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) - assert.Equal(t, "default-CANADA-1", payload["environment_name"]) - assert.Equal(t, "ref-123", payload["name"]) - assert.Equal(t, testSSHPublicKey, payload["public_key"]) - writeJSON(t, writer, map[string]any{"status": true, "keypair": map[string]any{"id": 7, "name": payload["name"]}}) - case request.URL.Path == "/v1/core/keypair/7" && request.Method == http.MethodDelete: - keyPairDeleteCount++ - writeJSON(t, writer, map[string]any{"status": true}) + 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: - var payload virtualmachine.CreateInstancesPayload require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) - assert.Equal(t, "ref-123", payload.Name) - assert.Equal(t, "default-CANADA-1", payload.EnvironmentName) - assert.Equal(t, "n3-H100x1", payload.FlavorName) - assert.Equal(t, defaultImageName, stringValue(payload.ImageName)) - require.NotNil(t, payload.AssignFloatingIp) - assert.True(t, *payload.AssignFloatingIp) - require.NotNil(t, payload.EnablePortRandomization) - assert.False(t, *payload.EnablePortRandomization) - require.NotNil(t, payload.EnhancedMonitoringEnabled) - assert.False(t, *payload.EnhancedMonitoringEnabled) - require.NotNil(t, payload.SecurityRules) - require.Len(t, *payload.SecurityRules, 1) - assertSecurityRule(t, (*payload.SecurityRules)[0], "10.0.0.0/8", 8080, 8080) - require.NotNil(t, payload.Labels) - labels = *payload.Labels - require.NotNil(t, payload.UserData) - assert.Contains(t, *payload.UserData, readinessMarker) - assert.Contains(t, *payload.UserData, "WantedBy=multi-user.target") - assert.Contains(t, *payload.UserData, "systemctl, enable, brev-cloud-ready.service") writeJSON(t, writer, map[string]any{"status": true, "instances": []map[string]any{{"id": 42}}}) - case request.URL.Path == "/v1/core/virtual-machines/42/logs" && request.Method == http.MethodPost: - var payload virtualmachine.RequestInstanceLogsPayload - require.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) - require.NotNil(t, payload.Length) - assert.Equal(t, consoleLogLineCount, *payload.Length) - writeJSON(t, writer, map[string]any{"request_id": 99}) - case request.URL.Path == "/v1/core/virtual-machines/42/logs" && request.Method == http.MethodGet: - assert.Equal(t, "99", request.URL.Query().Get("request_id")) - writeJSON(t, writer, map[string]any{"logs": "boot output\n" + readinessMarker + "\n"}) - case request.URL.Path == "/v1/core/virtual-machines/42" && request.Method == http.MethodGet: - if deleted { - writer.WriteHeader(http.StatusNotFound) - writeJSON(t, writer, map[string]any{"status": false, "message": "not found"}) - return - } + 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": 42, + "id": id, "name": "ref-123", - "status": providerStatus, - "created_at": "2026-09-04T12:00:00", + "status": statusActive, "floating_ip": "203.0.113.42", - "fixed_ip": "10.0.0.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", "disk": 100}, + "flavor": map[string]any{"name": "n3-H100x1"}, "labels": labels, - "security_rules": []map[string]any{{ - "id": 8, "direction": "ingress", "protocol": "tcp", "port_range_min": 8080, - "port_range_max": 8080, "remote_ip_prefix": "10.0.0.0/8", - }}, }, }) - case request.URL.Path == "/v1/core/virtual-machines" && request.Method == http.MethodGet: - writeJSON(t, writer, map[string]any{ - "status": true, - "instances": []map[string]any{{ - "id": 42, "name": "ref-123", "status": "ACTIVE", "created_at": "2026-09-04T12:00:00", - "environment": map[string]any{"region": "CANADA-1"}, - "flavor": map[string]any{"name": "n3-H100x1", "disk": 100}, "image": map[string]any{"name": defaultImageName}, - "labels": labels, - }}, - }) - case request.URL.Path == "/v1/core/virtual-machines/42" && request.Method == http.MethodDelete: - if deleted { - writer.WriteHeader(http.StatusNotFound) - writeJSON(t, writer, map[string]any{"status": false, "message": "not found"}) - return - } - deleted = true - writeJSON(t, writer, map[string]any{"status": true}) - case request.URL.Path == "/v1/core/virtual-machines/42/stop" && request.Method == http.MethodGet: - stopCount++ - providerStatus = "SHUTOFF" - writeJSON(t, writer, map[string]any{"status": true}) - case request.URL.Path == "/v1/core/virtual-machines/42/start" && request.Method == http.MethodGet: - startCount++ - providerStatus = "ACTIVE" - writeJSON(t, writer, map[string]any{"status": true}) default: http.NotFound(writer, request) } @@ -136,62 +64,93 @@ func TestInstanceLifecycleRequests(t *testing.T) { //nolint:funlen // one statef client := newTestClient(t, server.URL+"/v1") instance, err := client.CreateInstance(context.Background(), v1.CreateInstanceAttrs{ - Location: "CANADA-1", - Name: "display-name", - RefID: "ref-123", - PublicKey: testSSHPublicKey, - InstanceType: "n3-H100x1", - DiskSize: 256 * units.Gibibyte, - DiskSizeBytes: v1.NewBytes(256, v1.Gibibyte), - Tags: v1.Tags{ - "dev-plane-managedBy": "dev-plane", - "dev-plane-x-instanceId": "instance-123", - "team": "compute", - }, - FirewallRules: v1.FirewallRules{IngressRules: []v1.FirewallRule{{ - FromPort: 8080, - ToPort: 8080, - IPRanges: []string{"10.0.0.0/8"}, - }}}, + 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.Name) assert.Equal(t, "ref-123", instance.RefID) - assert.Equal(t, "credential-ref", instance.CloudCredRefID) - assert.Equal(t, "dev-plane", instance.Tags["dev-plane-managedBy"]) assert.Equal(t, "instance-123", instance.Tags["dev-plane-x-instanceId"]) - assert.NotContains(t, instance.Tags, "team") assert.Equal(t, v1.LifecycleStatusRunning, instance.Status.LifecycleStatus) assert.Equal(t, "203.0.113.42", instance.PublicIP) - assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x1"), instance.InstanceTypeID) - assert.True(t, instance.Stoppable) +} - instances, err := client.ListInstances(context.Background(), v1.ListInstancesArgs{ - InstanceIDs: []v1.CloudProviderInstanceID{"42"}, - Locations: v1.LocationsFilter{"CANADA-1"}, - TagFilters: map[string][]string{"dev-plane-managedBy": {"dev-plane"}}, - }) - require.NoError(t, err) - require.Len(t, instances, 1) - assert.Equal(t, instance.RefID, instances[0].RefID) - require.NoError(t, client.StopInstance(context.Background(), "42")) - require.NoError(t, client.StartInstance(context.Background(), "42")) - require.NoError(t, client.StartInstance(context.Background(), "42")) - assert.Equal(t, 1, stopCount) - assert.Equal(t, 1, startCount) +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")) - require.NoError(t, client.TerminateInstance(context.Background(), "42")) - assert.Equal(t, 1, keyPairDeleteCount) - _, err = client.GetInstance(context.Background(), "42") - require.Error(t, err) - assert.True(t, errors.Is(err, v1.ErrInstanceNotFound)) + 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 { @@ -208,7 +167,7 @@ func TestResolveKeyPairSearchesEveryPage(t *testing.T) { return } writeJSON(t, writer, map[string]any{"status": true, "keypairs": []map[string]any{{ - "id": 7, "name": "ref", "public_key": testSSHPublicKey, + "id": keyPairID, "name": "ref", "public_key": testSSHPublicKey, "environment": map[string]any{"name": "default-CANADA-1"}, }}}) case http.MethodPost: @@ -227,49 +186,24 @@ func TestResolveKeyPairSearchesEveryPage(t *testing.T) { }, "default-CANADA-1") require.NoError(t, err) assert.Equal(t, "ref", keyPair.name) - assert.Equal(t, 7, keyPair.managedID) + assert.Equal(t, keyPairID, keyPair.managedID) assert.Equal(t, 2, listCalls) assert.Zero(t, importCalls) } -func TestLabelsRoundTripPlainValues(t *testing.T) { - const refID = "82d299a7-dfd9-40e6-8707-3c477374b2a6" +func TestLabelsRoundTrip(t *testing.T) { + const refID = "environment-123" + keyPairID := 7 tags := v1.Tags{ - "dev-plane-managedBy": "dev-plane", - "dev-plane-x-instanceId": "instance-id", - "dev-plane-x-environmentId": "environment-id", - "dev-plane-x-userId": "user-id", - "dev-plane-x-launchableId": "launchable-id", - "dev-plane-x-cloudCredId": "cloud-cred-id", - "dev-plane-stage": "dev", - "team": "gpu-workers", + "dev-plane-managedBy": "dev-plane", + "dev-plane-x-instanceId": "instance-123", } labels := makeLabels(refID, tags) - labels = append(labels, managedKeyIDLabelPrefix+"7") - assert.Contains(t, labels, refIDLabelPrefix+refID) - assert.Contains(t, labels, "brev-tag-dev-plane-managedby_dev-plane") - assert.Contains(t, labels, "brev-tag-dev-plane-x-instanceid_instance-id") - assert.NotContains(t, labels, tagLabelPrefix+"team=gpu-workers") + labels = append(labels, managedKeyIDLabelPrefix+strconv.Itoa(keyPairID)) parsedRefID, parsedTags := parseLabels(&labels) assert.Equal(t, refID, parsedRefID) - for _, key := range instanceTagLabelKeys { - assert.Equal(t, tags[key], parsedTags[key]) - } - assert.NotContains(t, parsedTags, "team") -} - -func TestParseLabelsOnlyDecodesKnownTagPrefixes(t *testing.T) { - labels := []string{ - "brev-tag-team_gpu-workers", - "brev-tag-dev-plane-x-instanceid_instance-123", - } - - _, tags := parseLabels(&labels) - - assert.Equal(t, "instance-123", tags["dev-plane-x-instanceId"]) - assert.Equal(t, "", tags["brev-tag-team_gpu-workers"]) - assert.NotContains(t, tags, "team") + assert.Equal(t, tags, parsedTags) } func TestCallerKeyPairIsNotManaged(t *testing.T) { @@ -283,14 +217,14 @@ func TestCallerKeyPairIsNotManaged(t *testing.T) { } func TestActiveInstanceWaitsForFloatingIP(t *testing.T) { - status := "ACTIVE" + 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 := "ATTACHING" + attaching := statusAttaching withAttachingIP := client.convertInstance(virtualmachine.InstanceFields{ Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attaching, }, false) @@ -299,10 +233,10 @@ func TestActiveInstanceWaitsForFloatingIP(t *testing.T) { func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { client := &HyperstackClient{} - status := "ACTIVE" + status := statusActive publicIP := "203.0.113.42" - attached := "ATTACHED" - running := "RUNNING" + attached := statusAttached + running := statusActive providerInstance := virtualmachine.InstanceFields{ Status: &status, FloatingIp: &publicIP, FloatingIpStatus: &attached, VmState: &status, PowerState: &running, @@ -315,47 +249,15 @@ func TestActiveInstanceWaitsForGuestBoot(t *testing.T) { assert.Equal(t, v1.LifecycleStatusRunning, ready.Status.LifecycleStatus) } -func TestConsoleReadyPollsAsyncLogRequest(t *testing.T) { +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.Header().Set("Content-Type", "application/json") 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 { - writer.WriteHeader(http.StatusBadRequest) - 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 TestConsoleReadyRetainsRequestWhileLogsAreProcessing(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++ - writeJSON(t, writer, map[string]any{"request_id": 17}) case http.MethodGet: getCalls++ assert.Equal(t, "17", request.URL.Query().Get("request_id")) diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go index fc67089..d650343 100644 --- a/v1/providers/hyperstack/instancetype_test.go +++ b/v1/providers/hyperstack/instancetype_test.go @@ -13,7 +13,7 @@ import ( v1 "github.com/brevdev/cloud/v1" ) -func TestGetInstanceTypesAndLocations(t *testing.T) { +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")) assert.Equal(t, "brev-cloud", request.Header.Get("User-Agent")) @@ -21,57 +21,63 @@ func TestGetInstanceTypesAndLocations(t *testing.T) { case "/v1/core/flavors": writeJSON(t, w, map[string]any{ "status": true, - "data": []map[string]any{ - { - "region_name": "CANADA-1", - "gpu": "H100-80G-PCIe", - "flavors": []map[string]any{{ - "id": 1, - "name": "n3-H100x2", - "region_name": "CANADA-1", - "cpu": 56, - "ram": 360, - "disk": 100, - "ephemeral": 1500, - "gpu": "H100-80G-PCIe", - "gpu_count": 2, - "stock_available": true, - }}, - }, - { - "region_name": "NORWAY-1", - "gpu": "", - "flavors": []map[string]any{{ - "id": 2, - "name": "n1-cpu-small", - "region_name": "NORWAY-1", - "cpu": 4, - "ram": 4, - "disk": 100, - "ephemeral": 0, - "gpu": "", - "gpu_count": 0, - "stock_available": false, - }}, - }, - }, + "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"}, - {"name": "vCPU (cpu-only-flavors)", "value": "0.01"}, - {"name": "RAM (cpu-only-flavors)", "value": "0.02"}, - {"name": "hypervisor-local-storage (cpu-only-flavors)", "value": "0.001"}, - }) + 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 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{ - {"id": 1, "name": "CANADA-1", "country": "CA", "features": map[string]any{"floating_ip": true}}, - {"id": 2, "name": "NORWAY-1", "country": "NO", "features": map[string]any{"floating_ip": true}}, - {"id": 3, "name": "CANADA-2", "country": "CA", "features": map[string]any{"floating_ip": false}}, + {"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) } @@ -79,53 +85,13 @@ func TestGetInstanceTypesAndLocations(t *testing.T) { 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, 2) - - gpuType := instanceTypes[0] - assert.Equal(t, v1.InstanceTypeID("CANADA-1-noSub-n3-H100x2"), gpuType.ID) - assert.Equal(t, v1.NewBytes(360, v1.Gigabyte), gpuType.MemoryBytes) - assert.Equal(t, "5.0", gpuType.BasePrice.Number()) - require.Len(t, gpuType.SupportedGPUs, 1) - assert.Equal(t, int32(2), gpuType.SupportedGPUs[0].Count) - assert.Equal(t, v1.NewBytes(80, v1.Gigabyte), gpuType.SupportedGPUs[0].MemoryBytes) - assert.Equal(t, "H100", gpuType.SupportedGPUs[0].Name) - assert.Equal(t, "PCIe", gpuType.SupportedGPUs[0].NetworkDetails) - require.Len(t, gpuType.SupportedStorage, 2) - assert.Equal(t, "ssd", gpuType.SupportedStorage[0].Type) - assert.Equal(t, v1.NewBytes(100, v1.Gigabyte), gpuType.SupportedStorage[0].SizeBytes) - assert.False(t, gpuType.SupportedStorage[0].IsEphemeral) - assert.Equal(t, "ephemeral", gpuType.SupportedStorage[1].Type) - assert.Equal(t, v1.NewBytes(1500, v1.Gigabyte), gpuType.SupportedStorage[1].SizeBytes) - assert.True(t, gpuType.SupportedStorage[1].IsEphemeral) - assert.True(t, gpuType.SupportedStorage[1].IsAdditionalDisk) - assert.True(t, gpuType.SupportedStorage[1].RequiresVolumeMountPath) - - cpuType := instanceTypes[1] - assert.Equal(t, "0.220", cpuType.BasePrice.Number()) - assert.False(t, cpuType.IsAvailable) - require.Len(t, cpuType.SupportedStorage, 1) - assert.Equal(t, "ssd", cpuType.SupportedStorage[0].Type) - assert.Equal(t, v1.NewBytes(100, v1.Gigabyte), cpuType.SupportedStorage[0].SizeBytes) - assert.False(t, cpuType.SupportedStorage[0].IsEphemeral) - - filteredTypes, err := client.GetInstanceTypes(context.Background(), v1.GetInstanceTypeArgs{ - Locations: v1.LocationsFilter{"NORWAY-1"}, - InstanceTypes: []string{"n1-cpu-small"}, - }) - require.NoError(t, err) - require.Len(t, filteredTypes, 1) - assert.Equal(t, "NORWAY-1", filteredTypes[0].Location) - locations, err := client.GetLocations(context.Background(), v1.GetLocationsArgs{IncludeUnavailable: true}) require.NoError(t, err) - require.Len(t, locations, 3) + 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) - assert.False(t, locations[2].Available) } func newTestClient(t *testing.T, apiURL string) *HyperstackClient { From 3ea7b4678fe2b9c76ddbb56b26855a57e1a1f7c1 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 12:08:04 -0700 Subject: [PATCH 20/23] lint --- v1/networking_validation.go | 2 +- v1/providers/hyperstack/instance.go | 4 ++-- v1/providers/hyperstack/instancetype.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/v1/networking_validation.go b/v1/networking_validation.go index 4564815..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 { diff --git a/v1/providers/hyperstack/instance.go b/v1/providers/hyperstack/instance.go index bb29017..cdec48c 100644 --- a/v1/providers/hyperstack/instance.go +++ b/v1/providers/hyperstack/instance.go @@ -404,8 +404,8 @@ func providerFirewallRules(providerRules *[]virtualmachine.SecurityRulesFieldsFo } ingressRules = append(ingressRules, v1.FirewallRule{ ID: strconv.Itoa(intValue(providerRule.Id)), - FromPort: int32(intValue(providerRule.PortRangeMin)), - ToPort: int32(intValue(providerRule.PortRangeMax)), + FromPort: int32(intValue(providerRule.PortRangeMin)), //nolint:gosec // ok + ToPort: int32(intValue(providerRule.PortRangeMax)), //nolint:gosec // ok IPRanges: []string{stringValue(providerRule.RemoteIpPrefix)}, }) } diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index e9834a8..6e0accd 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -136,7 +136,7 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s Location: location, Memory: memory, MemoryBytes: memoryBytes, - VCPU: int32(intValue(providerType.Cpu)), + VCPU: int32(intValue(providerType.Cpu)), //nolint:gosec // ok SupportedArchitectures: []v1.Architecture{v1.ArchitectureX86_64}, SupportedUsageClasses: []string{usageClass}, Preemptible: preemptible, From 4d5004185dd6206ab59c52a7dc85ea234737fc04 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 12:10:45 -0700 Subject: [PATCH 21/23] lint --- v1/providers/hyperstack/instancetype.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index 6e0accd..b1f63bc 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -223,7 +223,7 @@ func hyperstackGPU(providerGPU string, count int) v1.GPU { memoryGB := gpuMemoryGB(gpuType) memory, memoryBytes := byteSizes(memoryGB, v1.Gigabyte) return v1.GPU{ - Count: int32(count), + Count: int32(count), //nolint:gosec // ok Memory: memory, MemoryBytes: memoryBytes, NetworkDetails: gpuNetworkDetails(gpuType), From 46f44a9237e225a5713bd393e6ff4e96f7e68859 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 20:03:27 -0700 Subject: [PATCH 22/23] feedback --- v1/providers/hyperstack/client.go | 1 - v1/providers/hyperstack/instancetype.go | 10 +++++++++- v1/providers/hyperstack/instancetype_test.go | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/v1/providers/hyperstack/client.go b/v1/providers/hyperstack/client.go index 2daf755..5297f17 100644 --- a/v1/providers/hyperstack/client.go +++ b/v1/providers/hyperstack/client.go @@ -184,7 +184,6 @@ type authenticatedDoer struct { 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("User-Agent", "brev-cloud") request.Header.Set("Accept", "application/json") return d.client.Do(request) } diff --git a/v1/providers/hyperstack/instancetype.go b/v1/providers/hyperstack/instancetype.go index b1f63bc..204c82c 100644 --- a/v1/providers/hyperstack/instancetype.go +++ b/v1/providers/hyperstack/instancetype.go @@ -137,7 +137,7 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s Memory: memory, MemoryBytes: memoryBytes, VCPU: int32(intValue(providerType.Cpu)), //nolint:gosec // ok - SupportedArchitectures: []v1.Architecture{v1.ArchitectureX86_64}, + SupportedArchitectures: []v1.Architecture{hyperstackArchitecture(gpuType)}, SupportedUsageClasses: []string{usageClass}, Preemptible: preemptible, Stoppable: true, @@ -172,6 +172,14 @@ func hyperstackInstanceType(providerType flavor.FlavorFields, fallbackLocation s 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)) diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go index d650343..10de79d 100644 --- a/v1/providers/hyperstack/instancetype_test.go +++ b/v1/providers/hyperstack/instancetype_test.go @@ -59,6 +59,23 @@ func TestGetInstanceTypesMapsFlavor(t *testing.T) { 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 { From 93eeab7aad62a9e2fe761071a23ed0b86dba9496 Mon Sep 17 00:00:00 2001 From: Drew Malin Date: Tue, 22 Sep 2026 20:23:25 -0700 Subject: [PATCH 23/23] test --- v1/providers/hyperstack/instancetype_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/v1/providers/hyperstack/instancetype_test.go b/v1/providers/hyperstack/instancetype_test.go index 10de79d..4e1a843 100644 --- a/v1/providers/hyperstack/instancetype_test.go +++ b/v1/providers/hyperstack/instancetype_test.go @@ -16,7 +16,6 @@ import ( 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")) - assert.Equal(t, "brev-cloud", request.Header.Get("User-Agent")) switch request.URL.Path { case "/v1/core/flavors": writeJSON(t, w, map[string]any{