diff --git a/go.mod b/go.mod index a695f95..5fbfe0d 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/jarcoal/httpmock v1.4.0 - github.com/nebius/gosdk v0.0.0-20250826102719-940ad1dfb5de + github.com/nebius/gosdk v0.2.0 github.com/pkg/errors v0.9.1 github.com/sfcompute/nodes-go v0.1.0-alpha.4 github.com/stretchr/testify v1.11.1 @@ -35,7 +35,7 @@ require ( ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20250717185734-6c6e0d3c608e.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // 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 @@ -99,7 +99,7 @@ require ( golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.13.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 443dd04..cc551ef 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20250717185734-6c6e0d3c608e.1 h1:sjY1k5uszbIZfv11HO2keV4SLhNA47SabPO886v7Rvo= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.8-20250717185734-6c6e0d3c608e.1/go.mod h1:8EQ5GzyGJQ5tEIwMSxCl8RKJYsjCpAwkdcENoioXT6g= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= 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= @@ -141,6 +143,8 @@ 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.0.0-20250826102719-940ad1dfb5de h1:7GbDUDyH22dvN7ata8HuNVuDlcyaDzUs/s+03Y3pDqU= github.com/nebius/gosdk v0.0.0-20250826102719-940ad1dfb5de/go.mod h1:eVbm4Qc4GPzBn3EL4rLvy1WS9zqJDw+giksOA2NZERY= +github.com/nebius/gosdk v0.2.0 h1:Z/TzCX8dIl/z3iGTFdhB64Eyhu6k0HigNhQJ/PjgX/A= +github.com/nebius/gosdk v0.2.0/go.mod h1:GPWYtdzMcVuptJEUPi5eQIUoApK5hT3K1MF9VlwJb5U= 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= @@ -269,6 +273,8 @@ google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/v1/providers/nebius/client.go b/v1/providers/nebius/client.go index 0198f09..b254a2e 100644 --- a/v1/providers/nebius/client.go +++ b/v1/providers/nebius/client.go @@ -5,8 +5,11 @@ import ( "encoding/json" "fmt" "os" + "regexp" + "runtime/debug" "sort" "strings" + "sync" "github.com/brevdev/cloud/internal/errors" v1 "github.com/brevdev/cloud/v1" @@ -15,6 +18,15 @@ import ( nebiusiamv1 "github.com/nebius/gosdk/proto/nebius/iam/v1" ) +const nebiusSDKUserAgentPrefix = "brev-cloud-sdk-nebius" + +var ( + mainVersionLDFlagsPattern = regexp.MustCompile(`main\.Version=([^\s"']+)`) + readBuildInfo = debug.ReadBuildInfo + mainVersionCacheOnce sync.Once + mainVersionCache string +) + // It embeds NotImplCloudClient to handle unsupported features type NebiusClient struct { v1.NotImplCloudClient @@ -75,7 +87,11 @@ func NewNebiusClientWithOrg(ctx context.Context, refID, serviceAccountKey, tenan creds = gosdk.ServiceAccountReader(parser) } - sdk, err := gosdk.New(ctx, gosdk.WithCredentials(creds)) + sdk, err := gosdk.New( + ctx, + gosdk.WithCredentials(creds), + gosdk.WithUserAgentPrefix(nebiusUserAgentPrefix()), + ) if err != nil { return nil, errors.WrapAndTrace(err) } @@ -110,6 +126,48 @@ func NewNebiusClientWithOrg(ctx context.Context, refID, serviceAccountKey, tenan return client, nil } +func nebiusUserAgentPrefix() string { + return formatNebiusUserAgentPrefix(mainVersionFromBuildInfo()) +} + +func formatNebiusUserAgentPrefix(mainVersion string) string { + version := strings.TrimLeft(strings.TrimSpace(mainVersion), "/") + if version == "" { + return nebiusSDKUserAgentPrefix + } + + return nebiusSDKUserAgentPrefix + "/" + version +} + +func mainVersionFromBuildInfo() string { + mainVersionCacheOnce.Do(func() { + buildInfo, ok := readBuildInfo() + if !ok { + return + } + + for _, setting := range buildInfo.Settings { + if setting.Key != "-ldflags" { + continue + } + if version := mainVersionFromLDFlags(setting.Value); version != "" { + mainVersionCache = version + return + } + } + }) + + return mainVersionCache +} + +func mainVersionFromLDFlags(ldflags string) string { + matches := mainVersionLDFlagsPattern.FindStringSubmatch(ldflags) + if len(matches) < 2 { + return "" + } + return matches[1] +} + // findProjectForRegion attempts to find an existing project for the given region // Priority: // 1. Project named "default-project-{region}" or "default-{region}" @@ -119,7 +177,7 @@ func findProjectForRegion(ctx context.Context, sdk *gosdk.SDK, tenantID, region pageSize := int64(1000) projectsResp, err := sdk.Services().IAM().V1().Project().List(ctx, &nebiusiamv1.ListProjectsRequest{ ParentId: tenantID, - PageSize: &pageSize, + PageSize: pageSize, }) if err != nil { return "", errors.WrapAndTrace(err) @@ -183,7 +241,7 @@ func (c *NebiusClient) discoverAllProjects(ctx context.Context) ([]string, error pageSize := int64(1000) projectsResp, err := c.sdk.Services().IAM().V1().Project().List(ctx, &nebiusiamv1.ListProjectsRequest{ ParentId: c.tenantID, - PageSize: &pageSize, + PageSize: pageSize, }) if err != nil { return nil, fmt.Errorf("failed to list projects: %w", err) @@ -209,7 +267,7 @@ func (c *NebiusClient) discoverAllProjectsWithRegions(ctx context.Context) (map[ pageSize := int64(1000) projectsResp, err := c.sdk.Services().IAM().V1().Project().List(ctx, &nebiusiamv1.ListProjectsRequest{ ParentId: c.tenantID, - PageSize: &pageSize, + PageSize: pageSize, }) if err != nil { return nil, fmt.Errorf("failed to list projects: %w", err) diff --git a/v1/providers/nebius/client_test.go b/v1/providers/nebius/client_test.go index 29d0344..cf0902f 100644 --- a/v1/providers/nebius/client_test.go +++ b/v1/providers/nebius/client_test.go @@ -3,6 +3,8 @@ package v1 import ( "context" "encoding/json" + "runtime/debug" + "sync" "testing" v1 "github.com/brevdev/cloud/v1" @@ -341,3 +343,116 @@ func TestExtractRegionFromProjectName(t *testing.T) { }) } } + +func TestMainVersionFromLDFlags(t *testing.T) { + tests := []struct { + name string + ldflags string + wantValue string + }{ + { + name: "main version in standard format", + ldflags: "-X main.Version=v1.2.3 -X main.BuildTime=2026-03-04_12:00:00", + wantValue: "v1.2.3", + }, + { + name: "main version in quoted token", + ldflags: `-s -w -X "main.Version=2026.03.04"`, + wantValue: "2026.03.04", + }, + { + name: "main version after other -X values", + ldflags: "-X main.BuildTime=2026-03-04_12:00:00 -X main.Version=abc123", + wantValue: "abc123", + }, + { + name: "main version not present", + ldflags: "-s -w -X main.BuildTime=2026-03-04_12:00:00", + wantValue: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantValue, mainVersionFromLDFlags(tt.ldflags)) + }) + } +} + +func TestFormatNebiusUserAgentPrefix(t *testing.T) { + tests := []struct { + name string + version string + wantValue string + }{ + { + name: "no version", + version: "", + wantValue: nebiusSDKUserAgentPrefix, + }, + { + name: "version set", + version: "v1.2.3", + wantValue: "brev-cloud-sdk-nebius/v1.2.3", + }, + { + name: "leading slash in version", + version: "/v1.2.3", + wantValue: "brev-cloud-sdk-nebius/v1.2.3", + }, + { + name: "whitespace version", + version: " ", + wantValue: nebiusSDKUserAgentPrefix, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantValue, formatNebiusUserAgentPrefix(tt.version)) + }) + } +} + +func TestMainVersionFromBuildInfo_IsCached(t *testing.T) { + originalReadBuildInfo := readBuildInfo + t.Cleanup(func() { + readBuildInfo = originalReadBuildInfo + mainVersionCacheOnce = sync.Once{} + mainVersionCache = "" + }) + + mainVersionCacheOnce = sync.Once{} + mainVersionCache = "" + + calls := 0 + readBuildInfo = func() (*debug.BuildInfo, bool) { + calls++ + return &debug.BuildInfo{ + Settings: []debug.BuildSetting{ + { + Key: "-ldflags", + Value: "-X main.Version=v9.9.9", + }, + }, + }, true + } + + assert.Equal(t, "v9.9.9", mainVersionFromBuildInfo()) + + // Change the source to prove the second call comes from cache, not a new read. + readBuildInfo = func() (*debug.BuildInfo, bool) { + calls++ + return &debug.BuildInfo{ + Settings: []debug.BuildSetting{ + { + Key: "-ldflags", + Value: "-X main.Version=v0.0.1", + }, + }, + }, true + } + + assert.Equal(t, "v9.9.9", mainVersionFromBuildInfo()) + assert.Equal(t, 1, calls) +} diff --git a/v1/providers/nebius/image.go b/v1/providers/nebius/image.go index eda002c..966ade1 100644 --- a/v1/providers/nebius/image.go +++ b/v1/providers/nebius/image.go @@ -196,8 +196,8 @@ func (c *NebiusClient) getDefaultImages(ctx context.Context) ([]v1.Image, error) // getImageDescription extracts description from ImageSpec if available func getImageDescription(image *compute.Image) string { - if image.Spec != nil && image.Spec.Description != nil { - return *image.Spec.Description + if image.Spec != nil { + return image.Spec.Description } return "" }