From 902ee4e7a37c24e341fe3f8a714a68c613fb69a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 03:42:02 +0000 Subject: [PATCH 1/6] Implement bcloud CLI with YAML configuration and key-as-ref_id pattern - Add cmd/bcloud directory structure with main.go and command files - Add cobra and viper dependencies for CLI framework - Implement bcloud command structure - Support YAML credential configuration in ~/.bcloud/credentials.yaml - Use YAML key as ref_id by default with optional explicit override - Implement YAML output format for all commands - Add core commands: create, terminate, list, get, types - Integrate with existing CloudClient interfaces and provider implementations - Support LambdaLabs provider with extensible factory pattern Co-Authored-By: Alec Fong --- cmd/bcloud/commands/create.go | 96 ++++++++++++++++++++++++++++++++ cmd/bcloud/commands/get.go | 62 +++++++++++++++++++++ cmd/bcloud/commands/list.go | 76 +++++++++++++++++++++++++ cmd/bcloud/commands/root.go | 44 +++++++++++++++ cmd/bcloud/commands/terminate.go | 56 +++++++++++++++++++ cmd/bcloud/commands/types.go | 74 ++++++++++++++++++++++++ cmd/bcloud/config/config.go | 71 +++++++++++++++++++++++ cmd/bcloud/main.go | 13 +++++ cmd/bcloud/providers/factory.go | 34 +++++++++++ go.mod | 13 +++-- go.sum | 21 ++++--- 11 files changed, 546 insertions(+), 14 deletions(-) create mode 100644 cmd/bcloud/commands/create.go create mode 100644 cmd/bcloud/commands/get.go create mode 100644 cmd/bcloud/commands/list.go create mode 100644 cmd/bcloud/commands/root.go create mode 100644 cmd/bcloud/commands/terminate.go create mode 100644 cmd/bcloud/commands/types.go create mode 100644 cmd/bcloud/config/config.go create mode 100644 cmd/bcloud/main.go create mode 100644 cmd/bcloud/providers/factory.go diff --git a/cmd/bcloud/commands/create.go b/cmd/bcloud/commands/create.go new file mode 100644 index 0000000..7854be3 --- /dev/null +++ b/cmd/bcloud/commands/create.go @@ -0,0 +1,96 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/providers" + v1 "github.com/brevdev/cloud/pkg/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var createCmd = &cobra.Command{ + Use: "create ", + Short: "Create a new instance", + Args: cobra.ExactArgs(1), + RunE: runCreate, +} + +var ( + instanceType string + location string + name string + imageID string + publicKey string +) + +func init() { + createCmd.Flags().StringVar(&instanceType, "instance-type", "", "Instance type to create") + createCmd.Flags().StringVar(&location, "location", "", "Location to create instance in") + createCmd.Flags().StringVar(&name, "name", "", "Name for the instance") + createCmd.Flags().StringVar(&imageID, "image-id", "", "Image ID to use") + createCmd.Flags().StringVar(&publicKey, "public-key", "", "SSH public key") + + if err := createCmd.MarkFlagRequired("instance-type"); err != nil { + panic(err) + } +} + +func runCreate(_ *cobra.Command, args []string) error { + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + refID := args[0] + + credConfig, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + configMap := map[string]interface{}{ + "provider": credConfig.Provider, + "api_key": credConfig.APIKey, + "ref_id": credConfig.RefID, + } + + cred, err := providers.CreateCredential(refID, configMap) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + if location == "" { + location = credConfig.DefaultLocation + } + if location == "" { + return fmt.Errorf("location is required (use --location or set default_location in config)") + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, location) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + attrs := v1.CreateInstanceAttrs{ + Location: location, + Name: name, + InstanceType: instanceType, + ImageID: imageID, + PublicKey: publicKey, + } + + instance, err := client.CreateInstance(ctx, attrs) + if err != nil { + return fmt.Errorf("failed to create instance: %w", err) + } + + output, err := yaml.Marshal(instance) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + fmt.Print(string(output)) + return nil +} diff --git a/cmd/bcloud/commands/get.go b/cmd/bcloud/commands/get.go new file mode 100644 index 0000000..0489b51 --- /dev/null +++ b/cmd/bcloud/commands/get.go @@ -0,0 +1,62 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/providers" + v1 "github.com/brevdev/cloud/pkg/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get instance details", + Args: cobra.ExactArgs(2), + RunE: runGet, +} + +func runGet(_ *cobra.Command, args []string) error { + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + refID := args[0] + instanceID := v1.CloudProviderInstanceID(args[1]) + + credConfig, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + configMap := map[string]interface{}{ + "provider": credConfig.Provider, + "api_key": credConfig.APIKey, + "ref_id": credConfig.RefID, + } + + cred, err := providers.CreateCredential(refID, configMap) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, credConfig.DefaultLocation) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + instance, err := client.GetInstance(ctx, instanceID) + if err != nil { + return fmt.Errorf("failed to get instance: %w", err) + } + + output, err := yaml.Marshal(instance) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + fmt.Print(string(output)) + return nil +} diff --git a/cmd/bcloud/commands/list.go b/cmd/bcloud/commands/list.go new file mode 100644 index 0000000..dcd26b0 --- /dev/null +++ b/cmd/bcloud/commands/list.go @@ -0,0 +1,76 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/providers" + v1 "github.com/brevdev/cloud/pkg/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var listCmd = &cobra.Command{ + Use: "list ", + Short: "List instances", + Args: cobra.ExactArgs(1), + RunE: runList, +} + +var listLocation string + +func init() { + listCmd.Flags().StringVar(&listLocation, "location", "", "Location to list instances from") +} + +func runList(_ *cobra.Command, args []string) error { + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + refID := args[0] + + credConfig, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + configMap := map[string]interface{}{ + "provider": credConfig.Provider, + "api_key": credConfig.APIKey, + "ref_id": credConfig.RefID, + } + + cred, err := providers.CreateCredential(refID, configMap) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + if listLocation == "" { + listLocation = credConfig.DefaultLocation + } + if listLocation == "" { + return fmt.Errorf("location is required (use --location or set default_location in config)") + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, listLocation) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + instances, err := client.ListInstances(ctx, v1.ListInstancesArgs{ + Locations: []string{listLocation}, + }) + if err != nil { + return fmt.Errorf("failed to list instances: %w", err) + } + + output, err := yaml.Marshal(instances) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + fmt.Print(string(output)) + return nil +} diff --git a/cmd/bcloud/commands/root.go b/cmd/bcloud/commands/root.go new file mode 100644 index 0000000..a8952ba --- /dev/null +++ b/cmd/bcloud/commands/root.go @@ -0,0 +1,44 @@ +package commands + +import ( + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + "github.com/spf13/cobra" +) + +var ( + cfgFile string + cfg *config.Config +) + +var rootCmd = &cobra.Command{ + Use: "bcloud ", + Short: "Brev Cloud CLI for managing GPU compute across providers", + Long: `A vendor-agnostic CLI for managing clusterable, GPU-accelerated compute +across multiple cloud providers using the Brev Cloud SDK.`, +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ~/.bcloud/credentials.yaml)") + + rootCmd.AddCommand(createCmd) + rootCmd.AddCommand(terminateCmd) + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(getCmd) + rootCmd.AddCommand(typesCmd) +} + +func initConfig() { + var err error + cfg, err = config.LoadConfig() + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + fmt.Println("Create ~/.bcloud/credentials.yaml with your cloud credentials") + } +} diff --git a/cmd/bcloud/commands/terminate.go b/cmd/bcloud/commands/terminate.go new file mode 100644 index 0000000..098597d --- /dev/null +++ b/cmd/bcloud/commands/terminate.go @@ -0,0 +1,56 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/providers" + v1 "github.com/brevdev/cloud/pkg/v1" + "github.com/spf13/cobra" +) + +var terminateCmd = &cobra.Command{ + Use: "terminate ", + Short: "Terminate an instance", + Args: cobra.ExactArgs(2), + RunE: runTerminate, +} + +func runTerminate(_ *cobra.Command, args []string) error { + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + refID := args[0] + instanceID := v1.CloudProviderInstanceID(args[1]) + + credConfig, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + configMap := map[string]interface{}{ + "provider": credConfig.Provider, + "api_key": credConfig.APIKey, + "ref_id": credConfig.RefID, + } + + cred, err := providers.CreateCredential(refID, configMap) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, credConfig.DefaultLocation) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + err = client.TerminateInstance(ctx, instanceID) + if err != nil { + return fmt.Errorf("failed to terminate instance: %w", err) + } + + fmt.Printf("Instance %s terminated successfully\n", instanceID) + return nil +} diff --git a/cmd/bcloud/commands/types.go b/cmd/bcloud/commands/types.go new file mode 100644 index 0000000..43c62bd --- /dev/null +++ b/cmd/bcloud/commands/types.go @@ -0,0 +1,74 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/providers" + v1 "github.com/brevdev/cloud/pkg/v1" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var typesCmd = &cobra.Command{ + Use: "types ", + Short: "List available instance types", + Args: cobra.ExactArgs(1), + RunE: runTypes, +} + +var typesLocation string + +func init() { + typesCmd.Flags().StringVar(&typesLocation, "location", "", "Location to get instance types for") +} + +func runTypes(_ *cobra.Command, args []string) error { + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } + + refID := args[0] + + credConfig, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + configMap := map[string]interface{}{ + "provider": credConfig.Provider, + "api_key": credConfig.APIKey, + "ref_id": credConfig.RefID, + } + + cred, err := providers.CreateCredential(refID, configMap) + if err != nil { + return fmt.Errorf("failed to create credential: %w", err) + } + + if typesLocation == "" { + typesLocation = credConfig.DefaultLocation + } + if typesLocation == "" { + return fmt.Errorf("location is required (use --location or set default_location in config)") + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, typesLocation) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + instanceTypes, err := client.GetInstanceTypes(ctx, v1.GetInstanceTypeArgs{}) + if err != nil { + return fmt.Errorf("failed to get instance types: %w", err) + } + + output, err := yaml.Marshal(instanceTypes) + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + fmt.Print(string(output)) + return nil +} diff --git a/cmd/bcloud/config/config.go b/cmd/bcloud/config/config.go new file mode 100644 index 0000000..c97104c --- /dev/null +++ b/cmd/bcloud/config/config.go @@ -0,0 +1,71 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Credentials map[string]CredentialConfig `yaml:"credentials"` + Settings Settings `yaml:"settings"` +} + +type CredentialConfig struct { + Provider string `yaml:"provider"` + RefID string `yaml:"ref_id,omitempty"` + APIKey string `yaml:"api_key,omitempty"` + ServiceAccountKey string `yaml:"service_account_key,omitempty"` + ProjectID string `yaml:"project_id,omitempty"` + DefaultLocation string `yaml:"default_location,omitempty"` +} + +type Settings struct { + OutputFormat string `yaml:"output_format"` + DefaultTimeout string `yaml:"default_timeout"` +} + +func (c *CredentialConfig) GetRefID(yamlKey string) string { + if c.RefID != "" { + return c.RefID + } + return yamlKey +} + +func LoadConfig() (*Config, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + configPath := filepath.Join(homeDir, ".bcloud", "credentials.yaml") + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + workingDirConfig := "./bcloud.yaml" + if _, err := os.Stat(workingDirConfig); os.IsNotExist(err) { + return nil, fmt.Errorf("no configuration found at %s or %s", configPath, workingDirConfig) + } + configPath = workingDirConfig + } + + data, err := os.ReadFile(configPath) // #nosec G304 - configPath is constructed from user home dir and known filename + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) + } + + if config.Settings.OutputFormat == "" { + config.Settings.OutputFormat = "yaml" + } + if config.Settings.DefaultTimeout == "" { + config.Settings.DefaultTimeout = "5m" + } + + return &config, nil +} diff --git a/cmd/bcloud/main.go b/cmd/bcloud/main.go new file mode 100644 index 0000000..dd67d27 --- /dev/null +++ b/cmd/bcloud/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/brevdev/cloud/cmd/bcloud/commands" +) + +func main() { + if err := commands.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/bcloud/providers/factory.go b/cmd/bcloud/providers/factory.go new file mode 100644 index 0000000..87070ae --- /dev/null +++ b/cmd/bcloud/providers/factory.go @@ -0,0 +1,34 @@ +package providers + +import ( + "fmt" + + lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" + v1 "github.com/brevdev/cloud/pkg/v1" +) + +func CreateCredential(yamlKey string, config map[string]interface{}) (v1.CloudCredential, error) { + provider, ok := config["provider"].(string) + if !ok { + return nil, fmt.Errorf("provider field is required") + } + + var refID string + if explicitRefID, exists := config["ref_id"].(string); exists && explicitRefID != "" { + refID = explicitRefID + } else { + refID = yamlKey + } + + switch provider { + case "lambdalabs": + apiKey, ok := config["api_key"].(string) + if !ok { + return nil, fmt.Errorf("api_key required for lambdalabs provider") + } + return lambdalabs.NewLambdaLabsCredential(refID, apiKey), nil + + default: + return nil, fmt.Errorf("unsupported provider: %s", provider) + } +} diff --git a/go.mod b/go.mod index cfaadd6..e398919 100644 --- a/go.mod +++ b/go.mod @@ -7,26 +7,30 @@ toolchain go1.23.2 require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/bojanz/currency v1.3.1 + github.com/cenkalti/backoff/v4 v4.3.0 + github.com/gliderlabs/ssh v0.3.8 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-20250731090238-d96c0d4a5930 + github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.41.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.31.0-20231030212536-12f9cba37c9d.2 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gliderlabs/ssh v0.3.8 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - golang.org/x/crypto v0.41.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect @@ -35,5 +39,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect google.golang.org/grpc v1.64.1 // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 949962d..245590d 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,12 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= @@ -28,6 +30,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -45,6 +49,11 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= @@ -58,20 +67,14 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 59ac5e609e1295e644caa52fa8fa06dcde7c2a05 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 03:42:12 +0000 Subject: [PATCH 2/6] Add example credentials configuration file for CLI usage Co-Authored-By: Alec Fong --- example-credentials.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 example-credentials.yaml diff --git a/example-credentials.yaml b/example-credentials.yaml new file mode 100644 index 0000000..6995540 --- /dev/null +++ b/example-credentials.yaml @@ -0,0 +1,16 @@ + +credentials: + my-lambda-creds: + provider: "lambdalabs" + api_key: "your-lambda-api-key-here" + default_location: "us-west-1" + + prod-lambda: + provider: "lambdalabs" + api_key: "your-prod-api-key" + default_location: "us-east-1" + ref_id: "production-lambda" # Optional explicit override + +settings: + output_format: "yaml" + default_timeout: "5m" From 54b46890d69130c142313b3e275e7f32121deca4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 23:40:13 +0000 Subject: [PATCH 3/6] Refactor to use pkg/v1.CloudCredential interface as requested in PR feedback - Replace custom ProviderCredential with existing CloudCredential interface - Add DefaultLocationProvider interface for CLI-specific functionality - Update factory pattern to work with CloudCredential interface - Maintain key-as-ref_id pattern with improved architecture - All lint checks and tests pass Co-Authored-By: Alec Fong --- cmd/bcloud/commands/create.go | 12 +-- cmd/bcloud/commands/get.go | 17 ++-- cmd/bcloud/commands/list.go | 12 +-- cmd/bcloud/commands/terminate.go | 17 ++-- cmd/bcloud/commands/types.go | 12 +-- cmd/bcloud/config/config.go | 131 ++++++++++++++++++++++++++----- cmd/bcloud/config/providers.go | 89 +++++++++++++++++++++ cmd/bcloud/providers/factory.go | 33 +++----- 8 files changed, 239 insertions(+), 84 deletions(-) create mode 100644 cmd/bcloud/config/providers.go diff --git a/cmd/bcloud/commands/create.go b/cmd/bcloud/commands/create.go index 7854be3..f27b594 100644 --- a/cmd/bcloud/commands/create.go +++ b/cmd/bcloud/commands/create.go @@ -44,24 +44,18 @@ func runCreate(_ *cobra.Command, args []string) error { refID := args[0] - credConfig, exists := cfg.Credentials[refID] + credEntry, exists := cfg.Credentials[refID] if !exists { return fmt.Errorf("credential '%s' not found in config", refID) } - configMap := map[string]interface{}{ - "provider": credConfig.Provider, - "api_key": credConfig.APIKey, - "ref_id": credConfig.RefID, - } - - cred, err := providers.CreateCredential(refID, configMap) + cred, err := providers.CreateCredential(refID, credEntry) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } if location == "" { - location = credConfig.DefaultLocation + location = providers.GetDefaultLocation(cred) } if location == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/commands/get.go b/cmd/bcloud/commands/get.go index 0489b51..9ffdd28 100644 --- a/cmd/bcloud/commands/get.go +++ b/cmd/bcloud/commands/get.go @@ -25,24 +25,23 @@ func runGet(_ *cobra.Command, args []string) error { refID := args[0] instanceID := v1.CloudProviderInstanceID(args[1]) - credConfig, exists := cfg.Credentials[refID] + credEntry, exists := cfg.Credentials[refID] if !exists { return fmt.Errorf("credential '%s' not found in config", refID) } - configMap := map[string]interface{}{ - "provider": credConfig.Provider, - "api_key": credConfig.APIKey, - "ref_id": credConfig.RefID, - } - - cred, err := providers.CreateCredential(refID, configMap) + cred, err := providers.CreateCredential(refID, credEntry) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } + defaultLocation := providers.GetDefaultLocation(cred) + if defaultLocation == "" { + return fmt.Errorf("default location is required in config") + } + ctx := context.Background() - client, err := cred.MakeClient(ctx, credConfig.DefaultLocation) + client, err := cred.MakeClient(ctx, defaultLocation) if err != nil { return fmt.Errorf("failed to create client: %w", err) } diff --git a/cmd/bcloud/commands/list.go b/cmd/bcloud/commands/list.go index dcd26b0..1172e61 100644 --- a/cmd/bcloud/commands/list.go +++ b/cmd/bcloud/commands/list.go @@ -30,24 +30,18 @@ func runList(_ *cobra.Command, args []string) error { refID := args[0] - credConfig, exists := cfg.Credentials[refID] + credEntry, exists := cfg.Credentials[refID] if !exists { return fmt.Errorf("credential '%s' not found in config", refID) } - configMap := map[string]interface{}{ - "provider": credConfig.Provider, - "api_key": credConfig.APIKey, - "ref_id": credConfig.RefID, - } - - cred, err := providers.CreateCredential(refID, configMap) + cred, err := providers.CreateCredential(refID, credEntry) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } if listLocation == "" { - listLocation = credConfig.DefaultLocation + listLocation = providers.GetDefaultLocation(cred) } if listLocation == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/commands/terminate.go b/cmd/bcloud/commands/terminate.go index 098597d..01a80c5 100644 --- a/cmd/bcloud/commands/terminate.go +++ b/cmd/bcloud/commands/terminate.go @@ -24,24 +24,23 @@ func runTerminate(_ *cobra.Command, args []string) error { refID := args[0] instanceID := v1.CloudProviderInstanceID(args[1]) - credConfig, exists := cfg.Credentials[refID] + credEntry, exists := cfg.Credentials[refID] if !exists { return fmt.Errorf("credential '%s' not found in config", refID) } - configMap := map[string]interface{}{ - "provider": credConfig.Provider, - "api_key": credConfig.APIKey, - "ref_id": credConfig.RefID, - } - - cred, err := providers.CreateCredential(refID, configMap) + cred, err := providers.CreateCredential(refID, credEntry) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } + defaultLocation := providers.GetDefaultLocation(cred) + if defaultLocation == "" { + return fmt.Errorf("default location is required in config") + } + ctx := context.Background() - client, err := cred.MakeClient(ctx, credConfig.DefaultLocation) + client, err := cred.MakeClient(ctx, defaultLocation) if err != nil { return fmt.Errorf("failed to create client: %w", err) } diff --git a/cmd/bcloud/commands/types.go b/cmd/bcloud/commands/types.go index 43c62bd..0d3e5bf 100644 --- a/cmd/bcloud/commands/types.go +++ b/cmd/bcloud/commands/types.go @@ -30,24 +30,18 @@ func runTypes(_ *cobra.Command, args []string) error { refID := args[0] - credConfig, exists := cfg.Credentials[refID] + credEntry, exists := cfg.Credentials[refID] if !exists { return fmt.Errorf("credential '%s' not found in config", refID) } - configMap := map[string]interface{}{ - "provider": credConfig.Provider, - "api_key": credConfig.APIKey, - "ref_id": credConfig.RefID, - } - - cred, err := providers.CreateCredential(refID, configMap) + cred, err := providers.CreateCredential(refID, credEntry) if err != nil { return fmt.Errorf("failed to create credential: %w", err) } if typesLocation == "" { - typesLocation = credConfig.DefaultLocation + typesLocation = providers.GetDefaultLocation(cred) } if typesLocation == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/config/config.go b/cmd/bcloud/config/config.go index c97104c..e70d76a 100644 --- a/cmd/bcloud/config/config.go +++ b/cmd/bcloud/config/config.go @@ -1,37 +1,114 @@ package config import ( + "encoding/json" "fmt" "os" "path/filepath" + v1 "github.com/brevdev/cloud/pkg/v1" "gopkg.in/yaml.v3" ) -type Config struct { - Credentials map[string]CredentialConfig `yaml:"credentials"` - Settings Settings `yaml:"settings"` +var providerRegistry = map[string]func() v1.CloudCredential{} + +func RegisterProvider(id string, factory func() v1.CloudCredential) { + providerRegistry[id] = factory } -type CredentialConfig struct { - Provider string `yaml:"provider"` - RefID string `yaml:"ref_id,omitempty"` - APIKey string `yaml:"api_key,omitempty"` - ServiceAccountKey string `yaml:"service_account_key,omitempty"` - ProjectID string `yaml:"project_id,omitempty"` - DefaultLocation string `yaml:"default_location,omitempty"` +type CredentialEntry struct { + Provider string `json:"provider" yaml:"provider"` + Value v1.CloudCredential `json:"-" yaml:"-"` } -type Settings struct { - OutputFormat string `yaml:"output_format"` - DefaultTimeout string `yaml:"default_timeout"` +func (c *CredentialEntry) decodeFromMap(m map[string]any, yamlKey string) error { + rawProv, ok := m["provider"] + if !ok { + return fmt.Errorf("missing 'provider'") + } + provider, ok := rawProv.(string) + if !ok || provider == "" { + return fmt.Errorf("invalid 'provider'") + } + factory, ok := providerRegistry[provider] + if !ok { + return fmt.Errorf("unknown provider: %s", provider) + } + cred := factory() + + if _, hasRefID := m["ref_id"]; !hasRefID { + m["ref_id"] = yamlKey + } + + b, err := json.Marshal(m) + if err != nil { + return err + } + if err := json.Unmarshal(b, cred); err != nil { + return err + } + + c.Provider = provider + c.Value = cred + return nil +} + +func (c CredentialEntry) encodeToMap() (map[string]any, error) { + if c.Value == nil { + return nil, fmt.Errorf("nil credential Value") + } + b, err := json.Marshal(c.Value) // serialize provider-specific fields + if err != nil { + return nil, err + } + out := map[string]any{} + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + out["provider"] = string(c.Value.GetCloudProviderID()) + return out, nil +} + +func (c *CredentialEntry) UnmarshalJSON(b []byte) error { + m := map[string]any{} + if err := json.Unmarshal(b, &m); err != nil { + return err + } + return c.decodeFromMap(m, "") +} + +func (c CredentialEntry) MarshalJSON() ([]byte, error) { + m, err := c.encodeToMap() + if err != nil { + return nil, err + } + return json.Marshal(m) +} + +func (c *CredentialEntry) UnmarshalYAML(n *yaml.Node) error { + m := map[string]any{} + if err := n.Decode(&m); err != nil { + return err + } + return c.decodeFromMap(m, "") } -func (c *CredentialConfig) GetRefID(yamlKey string) string { - if c.RefID != "" { - return c.RefID +func (c CredentialEntry) MarshalYAML() (interface{}, error) { + m, err := c.encodeToMap() + if err != nil { + return nil, err } - return yamlKey + return m, nil // let yaml encode the map +} + +type Config struct { + Credentials map[string]CredentialEntry `json:"credentials" yaml:"credentials"` + Settings Settings `json:"settings" yaml:"settings"` +} + +type Settings struct { + OutputFormat string `yaml:"output_format"` + DefaultTimeout string `yaml:"default_timeout"` } func LoadConfig() (*Config, error) { @@ -55,11 +132,27 @@ func LoadConfig() (*Config, error) { return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) } - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { + var rawConfig struct { + Credentials map[string]map[string]any `yaml:"credentials"` + Settings Settings `yaml:"settings"` + } + if err := yaml.Unmarshal(data, &rawConfig); err != nil { return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) } + config := Config{ + Credentials: make(map[string]CredentialEntry), + Settings: rawConfig.Settings, + } + + for yamlKey, credData := range rawConfig.Credentials { + var credEntry CredentialEntry + if err := credEntry.decodeFromMap(credData, yamlKey); err != nil { + return nil, fmt.Errorf("failed to parse credential '%s': %w", yamlKey, err) + } + config.Credentials[yamlKey] = credEntry + } + if config.Settings.OutputFormat == "" { config.Settings.OutputFormat = "yaml" } diff --git a/cmd/bcloud/config/providers.go b/cmd/bcloud/config/providers.go new file mode 100644 index 0000000..e7dfbdf --- /dev/null +++ b/cmd/bcloud/config/providers.go @@ -0,0 +1,89 @@ +package config + +import ( + "context" + "fmt" + + v1 "github.com/brevdev/cloud/pkg/v1" + + lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" +) + +type LambdaLabsCredential struct { + APIKey string `json:"api_key" yaml:"api_key"` + DefaultLocation string `json:"default_location" yaml:"default_location"` + RefID string `json:"ref_id,omitempty" yaml:"ref_id,omitempty"` +} + +func (l *LambdaLabsCredential) GetCloudProviderID() v1.CloudProviderID { + return "lambdalabs" +} + +func (l *LambdaLabsCredential) GetReferenceID() string { + return l.RefID +} + +func (l *LambdaLabsCredential) GetAPIType() v1.APIType { + return v1.APITypeLocational +} + +func (l *LambdaLabsCredential) GetTenantID() (string, error) { + return "", nil +} + +func (l *LambdaLabsCredential) GetCapabilities(ctx context.Context) (v1.Capabilities, error) { + cred := lambdalabs.NewLambdaLabsCredential(l.RefID, l.APIKey) + return cred.GetCapabilities(ctx) +} + +func (l *LambdaLabsCredential) MakeClient(ctx context.Context, location string) (v1.CloudClient, error) { + actualRefID := l.RefID + if actualRefID == "" { + return nil, fmt.Errorf("ref_id is required") + } + cred := lambdalabs.NewLambdaLabsCredential(actualRefID, l.APIKey) + return cred.MakeClient(ctx, location) +} + +func (l *LambdaLabsCredential) GetDefaultLocation() string { + return l.DefaultLocation +} + +type NebiusCredential struct { + ServiceAccountJSON string `json:"service_account_json" yaml:"service_account_json"` + DefaultLocation string `json:"default_location" yaml:"default_location"` + RefID string `json:"ref_id,omitempty" yaml:"ref_id,omitempty"` +} + +func (n *NebiusCredential) GetCloudProviderID() v1.CloudProviderID { + return "nebius" +} + +func (n *NebiusCredential) GetReferenceID() string { + return n.RefID +} + +func (n *NebiusCredential) GetAPIType() v1.APIType { + return v1.APITypeLocational +} + +func (n *NebiusCredential) GetTenantID() (string, error) { + return "", nil +} + +func (n *NebiusCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) { + return v1.Capabilities{}, nil +} + +func (n *NebiusCredential) MakeClient(_ context.Context, _ string) (v1.CloudClient, error) { + return nil, fmt.Errorf("nebius provider not yet implemented") +} + +func (n *NebiusCredential) GetDefaultLocation() string { + return n.DefaultLocation +} + +func init() { + RegisterProvider("lambdalabs", func() v1.CloudCredential { return &LambdaLabsCredential{} }) + RegisterProvider("nebius", func() v1.CloudCredential { return &NebiusCredential{} }) +} diff --git a/cmd/bcloud/providers/factory.go b/cmd/bcloud/providers/factory.go index 87070ae..f708d87 100644 --- a/cmd/bcloud/providers/factory.go +++ b/cmd/bcloud/providers/factory.go @@ -3,32 +3,25 @@ package providers import ( "fmt" - lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" ) -func CreateCredential(yamlKey string, config map[string]interface{}) (v1.CloudCredential, error) { - provider, ok := config["provider"].(string) - if !ok { - return nil, fmt.Errorf("provider field is required") - } +type DefaultLocationProvider interface { + GetDefaultLocation() string +} - var refID string - if explicitRefID, exists := config["ref_id"].(string); exists && explicitRefID != "" { - refID = explicitRefID - } else { - refID = yamlKey +func CreateCredential(_ string, credEntry config.CredentialEntry) (v1.CloudCredential, error) { + if credEntry.Value == nil { + return nil, fmt.Errorf("credential entry has no value") } - switch provider { - case "lambdalabs": - apiKey, ok := config["api_key"].(string) - if !ok { - return nil, fmt.Errorf("api_key required for lambdalabs provider") - } - return lambdalabs.NewLambdaLabsCredential(refID, apiKey), nil + return credEntry.Value, nil +} - default: - return nil, fmt.Errorf("unsupported provider: %s", provider) +func GetDefaultLocation(cred v1.CloudCredential) string { + if provider, ok := cred.(DefaultLocationProvider); ok { + return provider.GetDefaultLocation() } + return "" } From 81666d1b895b1c1323023a3663c15fd8b2250ae3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 23:58:22 +0000 Subject: [PATCH 4/6] Address GitHub feedback: simplify credential system using existing SDK implementations - Delete cmd/bcloud/providers/factory.go as requested - Remove cmd/bcloud/config/providers.go and move registration to config.go - Use existing LambdaLabsCredential and NebiusCredential from SDK with wrapper types - Add CLI-specific DefaultLocationProvider interface for default_location field - Add test ensuring all registered providers have RefID field in struct - Update all command files to work with simplified credential system - All lint checks and tests pass Co-Authored-By: Alec Fong --- cmd/bcloud/commands/create.go | 12 +++-- cmd/bcloud/commands/get.go | 13 +++-- cmd/bcloud/commands/list.go | 12 +++-- cmd/bcloud/commands/terminate.go | 13 +++-- cmd/bcloud/commands/types.go | 12 +++-- cmd/bcloud/config/config.go | 37 +++++++++++++ cmd/bcloud/config/config_test.go | 42 +++++++++++++++ cmd/bcloud/config/providers.go | 89 -------------------------------- cmd/bcloud/providers/factory.go | 27 ---------- 9 files changed, 116 insertions(+), 141 deletions(-) create mode 100644 cmd/bcloud/config/config_test.go delete mode 100644 cmd/bcloud/config/providers.go delete mode 100644 cmd/bcloud/providers/factory.go diff --git a/cmd/bcloud/commands/create.go b/cmd/bcloud/commands/create.go index f27b594..c3e4e3a 100644 --- a/cmd/bcloud/commands/create.go +++ b/cmd/bcloud/commands/create.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/brevdev/cloud/cmd/bcloud/providers" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -49,13 +49,15 @@ func runCreate(_ *cobra.Command, args []string) error { return fmt.Errorf("credential '%s' not found in config", refID) } - cred, err := providers.CreateCredential(refID, credEntry) - if err != nil { - return fmt.Errorf("failed to create credential: %w", err) + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") } if location == "" { - location = providers.GetDefaultLocation(cred) + if provider, ok := cred.(config.DefaultLocationProvider); ok { + location = provider.GetDefaultLocation() + } } if location == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/commands/get.go b/cmd/bcloud/commands/get.go index 9ffdd28..2e7f6ad 100644 --- a/cmd/bcloud/commands/get.go +++ b/cmd/bcloud/commands/get.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/brevdev/cloud/cmd/bcloud/providers" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -30,12 +30,15 @@ func runGet(_ *cobra.Command, args []string) error { return fmt.Errorf("credential '%s' not found in config", refID) } - cred, err := providers.CreateCredential(refID, credEntry) - if err != nil { - return fmt.Errorf("failed to create credential: %w", err) + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") } - defaultLocation := providers.GetDefaultLocation(cred) + var defaultLocation string + if provider, ok := cred.(config.DefaultLocationProvider); ok { + defaultLocation = provider.GetDefaultLocation() + } if defaultLocation == "" { return fmt.Errorf("default location is required in config") } diff --git a/cmd/bcloud/commands/list.go b/cmd/bcloud/commands/list.go index 1172e61..d56a30c 100644 --- a/cmd/bcloud/commands/list.go +++ b/cmd/bcloud/commands/list.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/brevdev/cloud/cmd/bcloud/providers" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -35,13 +35,15 @@ func runList(_ *cobra.Command, args []string) error { return fmt.Errorf("credential '%s' not found in config", refID) } - cred, err := providers.CreateCredential(refID, credEntry) - if err != nil { - return fmt.Errorf("failed to create credential: %w", err) + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") } if listLocation == "" { - listLocation = providers.GetDefaultLocation(cred) + if provider, ok := cred.(config.DefaultLocationProvider); ok { + listLocation = provider.GetDefaultLocation() + } } if listLocation == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/commands/terminate.go b/cmd/bcloud/commands/terminate.go index 01a80c5..af417fb 100644 --- a/cmd/bcloud/commands/terminate.go +++ b/cmd/bcloud/commands/terminate.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/brevdev/cloud/cmd/bcloud/providers" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" "github.com/spf13/cobra" ) @@ -29,12 +29,15 @@ func runTerminate(_ *cobra.Command, args []string) error { return fmt.Errorf("credential '%s' not found in config", refID) } - cred, err := providers.CreateCredential(refID, credEntry) - if err != nil { - return fmt.Errorf("failed to create credential: %w", err) + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential value is nil") } - defaultLocation := providers.GetDefaultLocation(cred) + var defaultLocation string + if provider, ok := cred.(config.DefaultLocationProvider); ok { + defaultLocation = provider.GetDefaultLocation() + } if defaultLocation == "" { return fmt.Errorf("default location is required in config") } diff --git a/cmd/bcloud/commands/types.go b/cmd/bcloud/commands/types.go index 0d3e5bf..1517a8c 100644 --- a/cmd/bcloud/commands/types.go +++ b/cmd/bcloud/commands/types.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/brevdev/cloud/cmd/bcloud/providers" + "github.com/brevdev/cloud/cmd/bcloud/config" v1 "github.com/brevdev/cloud/pkg/v1" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -35,13 +35,15 @@ func runTypes(_ *cobra.Command, args []string) error { return fmt.Errorf("credential '%s' not found in config", refID) } - cred, err := providers.CreateCredential(refID, credEntry) - if err != nil { - return fmt.Errorf("failed to create credential: %w", err) + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential value is nil") } if typesLocation == "" { - typesLocation = providers.GetDefaultLocation(cred) + if provider, ok := cred.(config.DefaultLocationProvider); ok { + typesLocation = provider.GetDefaultLocation() + } } if typesLocation == "" { return fmt.Errorf("location is required (use --location or set default_location in config)") diff --git a/cmd/bcloud/config/config.go b/cmd/bcloud/config/config.go index e70d76a..8a0a142 100644 --- a/cmd/bcloud/config/config.go +++ b/cmd/bcloud/config/config.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" + lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" + nebius "github.com/brevdev/cloud/internal/nebius/v1" v1 "github.com/brevdev/cloud/pkg/v1" "gopkg.in/yaml.v3" ) @@ -16,6 +18,41 @@ func RegisterProvider(id string, factory func() v1.CloudCredential) { providerRegistry[id] = factory } +type LambdaLabsCredentialWrapper struct { + *lambdalabs.LambdaLabsCredential + DefaultLocation string `json:"default_location" yaml:"default_location"` +} + +func (w *LambdaLabsCredentialWrapper) GetDefaultLocation() string { + return w.DefaultLocation +} + +type NebiusCredentialWrapper struct { + *nebius.NebiusCredential + DefaultLocation string `json:"default_location" yaml:"default_location"` +} + +func (w *NebiusCredentialWrapper) GetDefaultLocation() string { + return w.DefaultLocation +} + +type DefaultLocationProvider interface { + GetDefaultLocation() string +} + +func init() { + RegisterProvider("lambdalabs", func() v1.CloudCredential { + return &LambdaLabsCredentialWrapper{ + LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{}, + } + }) + RegisterProvider("nebius", func() v1.CloudCredential { + return &NebiusCredentialWrapper{ + NebiusCredential: &nebius.NebiusCredential{}, + } + }) +} + type CredentialEntry struct { Provider string `json:"provider" yaml:"provider"` Value v1.CloudCredential `json:"-" yaml:"-"` diff --git a/cmd/bcloud/config/config_test.go b/cmd/bcloud/config/config_test.go new file mode 100644 index 0000000..0521bb0 --- /dev/null +++ b/cmd/bcloud/config/config_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestRegisteredProvidersHaveRefIDField(t *testing.T) { + for providerID, factory := range providerRegistry { + t.Run(providerID, func(t *testing.T) { + cred := factory() + credType := reflect.TypeOf(cred) + + if credType.Kind() == reflect.Ptr { + credType = credType.Elem() + } + + hasRefID := false + for i := 0; i < credType.NumField(); i++ { + field := credType.Field(i) + if field.Name == "RefID" { + hasRefID = true + break + } + if field.Anonymous && field.Type.Kind() == reflect.Ptr { + embeddedType := field.Type.Elem() + for j := 0; j < embeddedType.NumField(); j++ { + embeddedField := embeddedType.Field(j) + if embeddedField.Name == "RefID" { + hasRefID = true + break + } + } + } + } + + if !hasRefID { + t.Errorf("Provider %s does not have a RefID field in its credential struct", providerID) + } + }) + } +} diff --git a/cmd/bcloud/config/providers.go b/cmd/bcloud/config/providers.go deleted file mode 100644 index e7dfbdf..0000000 --- a/cmd/bcloud/config/providers.go +++ /dev/null @@ -1,89 +0,0 @@ -package config - -import ( - "context" - "fmt" - - v1 "github.com/brevdev/cloud/pkg/v1" - - lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" -) - -type LambdaLabsCredential struct { - APIKey string `json:"api_key" yaml:"api_key"` - DefaultLocation string `json:"default_location" yaml:"default_location"` - RefID string `json:"ref_id,omitempty" yaml:"ref_id,omitempty"` -} - -func (l *LambdaLabsCredential) GetCloudProviderID() v1.CloudProviderID { - return "lambdalabs" -} - -func (l *LambdaLabsCredential) GetReferenceID() string { - return l.RefID -} - -func (l *LambdaLabsCredential) GetAPIType() v1.APIType { - return v1.APITypeLocational -} - -func (l *LambdaLabsCredential) GetTenantID() (string, error) { - return "", nil -} - -func (l *LambdaLabsCredential) GetCapabilities(ctx context.Context) (v1.Capabilities, error) { - cred := lambdalabs.NewLambdaLabsCredential(l.RefID, l.APIKey) - return cred.GetCapabilities(ctx) -} - -func (l *LambdaLabsCredential) MakeClient(ctx context.Context, location string) (v1.CloudClient, error) { - actualRefID := l.RefID - if actualRefID == "" { - return nil, fmt.Errorf("ref_id is required") - } - cred := lambdalabs.NewLambdaLabsCredential(actualRefID, l.APIKey) - return cred.MakeClient(ctx, location) -} - -func (l *LambdaLabsCredential) GetDefaultLocation() string { - return l.DefaultLocation -} - -type NebiusCredential struct { - ServiceAccountJSON string `json:"service_account_json" yaml:"service_account_json"` - DefaultLocation string `json:"default_location" yaml:"default_location"` - RefID string `json:"ref_id,omitempty" yaml:"ref_id,omitempty"` -} - -func (n *NebiusCredential) GetCloudProviderID() v1.CloudProviderID { - return "nebius" -} - -func (n *NebiusCredential) GetReferenceID() string { - return n.RefID -} - -func (n *NebiusCredential) GetAPIType() v1.APIType { - return v1.APITypeLocational -} - -func (n *NebiusCredential) GetTenantID() (string, error) { - return "", nil -} - -func (n *NebiusCredential) GetCapabilities(_ context.Context) (v1.Capabilities, error) { - return v1.Capabilities{}, nil -} - -func (n *NebiusCredential) MakeClient(_ context.Context, _ string) (v1.CloudClient, error) { - return nil, fmt.Errorf("nebius provider not yet implemented") -} - -func (n *NebiusCredential) GetDefaultLocation() string { - return n.DefaultLocation -} - -func init() { - RegisterProvider("lambdalabs", func() v1.CloudCredential { return &LambdaLabsCredential{} }) - RegisterProvider("nebius", func() v1.CloudCredential { return &NebiusCredential{} }) -} diff --git a/cmd/bcloud/providers/factory.go b/cmd/bcloud/providers/factory.go deleted file mode 100644 index f708d87..0000000 --- a/cmd/bcloud/providers/factory.go +++ /dev/null @@ -1,27 +0,0 @@ -package providers - -import ( - "fmt" - - "github.com/brevdev/cloud/cmd/bcloud/config" - v1 "github.com/brevdev/cloud/pkg/v1" -) - -type DefaultLocationProvider interface { - GetDefaultLocation() string -} - -func CreateCredential(_ string, credEntry config.CredentialEntry) (v1.CloudCredential, error) { - if credEntry.Value == nil { - return nil, fmt.Errorf("credential entry has no value") - } - - return credEntry.Value, nil -} - -func GetDefaultLocation(cred v1.CloudCredential) string { - if provider, ok := cred.(DefaultLocationProvider); ok { - return provider.GetDefaultLocation() - } - return "" -} From b934902e9aeb2ca42b1879f72210d865559ccc81 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 01:23:07 +0000 Subject: [PATCH 5/6] Add comprehensive testing for config.go LoadConfig and credential parsing - Test LoadConfig with various scenarios: success, missing files, invalid YAML - Test credential parsing edge cases: missing provider, invalid provider, unknown provider - Test key-as-ref_id pattern with both explicit and implicit ref_id values - Test JSON/YAML marshaling and unmarshaling for credential entries - Test provider registry functionality and wrapper type validation - Test error handling for malformed configurations and nil values - Test default settings application and file path resolution - All tests use isolated temp directories and proper cleanup - Fixed lint issues: error checking for os.Setenv/os.Chdir, file permissions, formatting Co-Authored-By: Alec Fong --- cmd/bcloud/config/config.go | 17 +- cmd/bcloud/config/config_test.go | 440 +++++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+), 4 deletions(-) diff --git a/cmd/bcloud/config/config.go b/cmd/bcloud/config/config.go index 8a0a142..e7e36e3 100644 --- a/cmd/bcloud/config/config.go +++ b/cmd/bcloud/config/config.go @@ -14,6 +14,11 @@ import ( var providerRegistry = map[string]func() v1.CloudCredential{} +var providerIDToRegistryKey = map[string]string{ + "lambda-labs": "lambdalabs", + "nebius": "nebius", +} + func RegisterProvider(id string, factory func() v1.CloudCredential) { providerRegistry[id] = factory } @@ -67,14 +72,18 @@ func (c *CredentialEntry) decodeFromMap(m map[string]any, yamlKey string) error if !ok || provider == "" { return fmt.Errorf("invalid 'provider'") } - factory, ok := providerRegistry[provider] + registryKey := provider + if mappedKey, exists := providerIDToRegistryKey[provider]; exists { + registryKey = mappedKey + } + factory, ok := providerRegistry[registryKey] if !ok { return fmt.Errorf("unknown provider: %s", provider) } cred := factory() - if _, hasRefID := m["ref_id"]; !hasRefID { - m["ref_id"] = yamlKey + if _, hasRefID := m["RefID"]; !hasRefID { + m["RefID"] = yamlKey } b, err := json.Marshal(m) @@ -85,7 +94,7 @@ func (c *CredentialEntry) decodeFromMap(m map[string]any, yamlKey string) error return err } - c.Provider = provider + c.Provider = registryKey c.Value = cred return nil } diff --git a/cmd/bcloud/config/config_test.go b/cmd/bcloud/config/config_test.go index 0521bb0..f805459 100644 --- a/cmd/bcloud/config/config_test.go +++ b/cmd/bcloud/config/config_test.go @@ -1,8 +1,15 @@ package config import ( + "encoding/json" + "os" + "path/filepath" "reflect" + "strings" "testing" + + lambdalabs "github.com/brevdev/cloud/internal/lambdalabs/v1" + "gopkg.in/yaml.v3" ) func TestRegisteredProvidersHaveRefIDField(t *testing.T) { @@ -40,3 +47,436 @@ func TestRegisteredProvidersHaveRefIDField(t *testing.T) { }) } } + +func TestLoadConfig_Success(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, ".bcloud", "credentials.yaml") + + err := os.MkdirAll(filepath.Dir(configPath), 0o750) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + configContent := ` +credentials: + test-lambda: + provider: "lambdalabs" + APIKey: "test-key" + default_location: "us-west-1" + test-lambda-with-ref: + provider: "lambdalabs" + APIKey: "test-key-2" + RefID: "custom-ref-id" + default_location: "us-east-1" +settings: + output_format: "json" + default_timeout: "10m" +` + + err = os.WriteFile(configPath, []byte(configContent), 0o600) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + originalHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + _ = os.Setenv("HOME", tempDir) + + config, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if len(config.Credentials) != 2 { + t.Errorf("Expected 2 credentials, got %d", len(config.Credentials)) + } + + testLambda, exists := config.Credentials["test-lambda"] + if !exists { + t.Fatal("test-lambda credential not found") + } + if testLambda.Provider != "lambdalabs" { + t.Errorf("Expected provider 'lambdalabs', got '%s'", testLambda.Provider) + } + if testLambda.Value.GetReferenceID() != "test-lambda" { + t.Errorf("Expected ref_id 'test-lambda', got '%s'", testLambda.Value.GetReferenceID()) + } + + testLambdaWithRef, exists := config.Credentials["test-lambda-with-ref"] + if !exists { + t.Fatal("test-lambda-with-ref credential not found") + } + if testLambdaWithRef.Value.GetReferenceID() != "custom-ref-id" { + t.Errorf("Expected ref_id 'custom-ref-id', got '%s'", testLambdaWithRef.Value.GetReferenceID()) + } + + if config.Settings.OutputFormat != "json" { + t.Errorf("Expected output_format 'json', got '%s'", config.Settings.OutputFormat) + } + if config.Settings.DefaultTimeout != "10m" { + t.Errorf("Expected default_timeout '10m', got '%s'", config.Settings.DefaultTimeout) + } +} + +func TestLoadConfig_WorkingDirectoryFallback(t *testing.T) { + tempDir := t.TempDir() + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + _ = os.Chdir(tempDir) + + originalHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + _ = os.Setenv("HOME", "/nonexistent") + + configContent := ` +credentials: + test-cred: + provider: "lambdalabs" + APIKey: "test-key" + default_location: "us-west-1" +` + + err := os.WriteFile("bcloud.yaml", []byte(configContent), 0o600) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + config, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if len(config.Credentials) != 1 { + t.Errorf("Expected 1 credential, got %d", len(config.Credentials)) + } +} + +func TestLoadConfig_NoConfigFound(t *testing.T) { + originalHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + _ = os.Setenv("HOME", "/nonexistent") + + originalWd, _ := os.Getwd() + defer func() { _ = os.Chdir(originalWd) }() + tempDir := t.TempDir() + _ = os.Chdir(tempDir) + + _, err := LoadConfig() + if err == nil { + t.Fatal("Expected error when no config found") + } + if !strings.Contains(err.Error(), "no configuration found") { + t.Errorf("Expected 'no configuration found' error, got: %v", err) + } +} + +func TestLoadConfig_InvalidYAML(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, ".bcloud", "credentials.yaml") + + err := os.MkdirAll(filepath.Dir(configPath), 0o750) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + invalidYAML := ` +credentials: + test-cred: + provider: "lambdalabs" + api_key: "test-key" + invalid: [unclosed +` + + err = os.WriteFile(configPath, []byte(invalidYAML), 0o600) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + originalHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + _ = os.Setenv("HOME", tempDir) + + _, err = LoadConfig() + if err == nil { + t.Fatal("Expected error for invalid YAML") + } + if !strings.Contains(err.Error(), "failed to parse config file") { + t.Errorf("Expected 'failed to parse config file' error, got: %v", err) + } +} + +func TestLoadConfig_DefaultSettings(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, ".bcloud", "credentials.yaml") + + err := os.MkdirAll(filepath.Dir(configPath), 0o750) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + configContent := ` +credentials: + test-cred: + provider: "lambdalabs" + APIKey: "test-key" + default_location: "us-west-1" +` + + err = os.WriteFile(configPath, []byte(configContent), 0o600) + if err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + originalHome := os.Getenv("HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + _ = os.Setenv("HOME", tempDir) + + config, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.Settings.OutputFormat != "yaml" { + t.Errorf("Expected default output_format 'yaml', got '%s'", config.Settings.OutputFormat) + } + if config.Settings.DefaultTimeout != "5m" { + t.Errorf("Expected default default_timeout '5m', got '%s'", config.Settings.DefaultTimeout) + } +} + +func TestCredentialEntry_DecodeFromMap_MissingProvider(t *testing.T) { + var entry CredentialEntry + m := map[string]any{ + "api_key": "test-key", + } + + err := entry.decodeFromMap(m, "test-key") + if err == nil { + t.Fatal("Expected error for missing provider") + } + if !strings.Contains(err.Error(), "missing 'provider'") { + t.Errorf("Expected 'missing provider' error, got: %v", err) + } +} + +func TestCredentialEntry_DecodeFromMap_InvalidProvider(t *testing.T) { + var entry CredentialEntry + m := map[string]any{ + "provider": 123, + "api_key": "test-key", + } + + err := entry.decodeFromMap(m, "test-key") + if err == nil { + t.Fatal("Expected error for invalid provider") + } + if !strings.Contains(err.Error(), "invalid 'provider'") { + t.Errorf("Expected 'invalid provider' error, got: %v", err) + } +} + +func TestCredentialEntry_DecodeFromMap_UnknownProvider(t *testing.T) { + var entry CredentialEntry + m := map[string]any{ + "provider": "unknown-provider", + "api_key": "test-key", + } + + err := entry.decodeFromMap(m, "test-key") + if err == nil { + t.Fatal("Expected error for unknown provider") + } + if !strings.Contains(err.Error(), "unknown provider: unknown-provider") { + t.Errorf("Expected 'unknown provider' error, got: %v", err) + } +} + +func TestCredentialEntry_DecodeFromMap_KeyAsRefID(t *testing.T) { + var entry CredentialEntry + m := map[string]any{ + "provider": "lambdalabs", + "APIKey": "test-key", + "default_location": "us-west-1", + } + + err := entry.decodeFromMap(m, "my-lambda-key") + if err != nil { + t.Fatalf("decodeFromMap failed: %v", err) + } + + if entry.Provider != "lambdalabs" { + t.Errorf("Expected provider 'lambdalabs', got '%s'", entry.Provider) + } + if entry.Value.GetReferenceID() != "my-lambda-key" { + t.Errorf("Expected ref_id 'my-lambda-key', got '%s'", entry.Value.GetReferenceID()) + } + + wrapper, ok := entry.Value.(*LambdaLabsCredentialWrapper) + if !ok { + t.Fatal("Expected LambdaLabsCredentialWrapper") + } + if wrapper.LambdaLabsCredential.RefID != "my-lambda-key" { + t.Errorf("Expected embedded RefID 'my-lambda-key', got '%s'", wrapper.LambdaLabsCredential.RefID) + } +} + +func TestCredentialEntry_DecodeFromMap_ExplicitRefID(t *testing.T) { + var entry CredentialEntry + m := map[string]any{ + "provider": "lambdalabs", + "APIKey": "test-key", + "RefID": "explicit-ref-id", + "default_location": "us-west-1", + } + + err := entry.decodeFromMap(m, "yaml-key") + if err != nil { + t.Fatalf("decodeFromMap failed: %v", err) + } + + if entry.Value.GetReferenceID() != "explicit-ref-id" { + t.Errorf("Expected ref_id 'explicit-ref-id', got '%s'", entry.Value.GetReferenceID()) + } +} + +func TestCredentialEntry_JSONMarshalUnmarshal(t *testing.T) { + original := CredentialEntry{ + Provider: "lambdalabs", + Value: &LambdaLabsCredentialWrapper{ + LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{ + RefID: "test-ref", + APIKey: "test-key", + }, + DefaultLocation: "us-west-1", + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("JSON marshal failed: %v", err) + } + + var unmarshaled CredentialEntry + err = json.Unmarshal(data, &unmarshaled) + if err != nil { + t.Fatalf("JSON unmarshal failed: %v", err) + } + + if unmarshaled.Provider != original.Provider { + t.Errorf("Provider mismatch: expected %s, got %s", original.Provider, unmarshaled.Provider) + } + if unmarshaled.Value.GetReferenceID() != original.Value.GetReferenceID() { + t.Errorf("RefID mismatch: expected %s, got %s", original.Value.GetReferenceID(), unmarshaled.Value.GetReferenceID()) + } +} + +func TestCredentialEntry_YAMLMarshalUnmarshal(t *testing.T) { + original := CredentialEntry{ + Provider: "lambdalabs", + Value: &LambdaLabsCredentialWrapper{ + LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{ + RefID: "test-ref", + APIKey: "test-key", + }, + DefaultLocation: "us-west-1", + }, + } + + data, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("YAML marshal failed: %v", err) + } + + var unmarshaled CredentialEntry + err = yaml.Unmarshal(data, &unmarshaled) + if err != nil { + t.Fatalf("YAML unmarshal failed: %v", err) + } + + if unmarshaled.Provider != original.Provider { + t.Errorf("Provider mismatch: expected %s, got %s", original.Provider, unmarshaled.Provider) + } + if unmarshaled.Value.GetReferenceID() != original.Value.GetReferenceID() { + t.Errorf("RefID mismatch: expected %s, got %s", original.Value.GetReferenceID(), unmarshaled.Value.GetReferenceID()) + } +} + +func TestCredentialEntry_EncodeToMap_NilValue(t *testing.T) { + entry := CredentialEntry{ + Provider: "lambdalabs", + Value: nil, + } + + _, err := entry.encodeToMap() + if err == nil { + t.Fatal("Expected error for nil credential value") + } + if !strings.Contains(err.Error(), "nil credential Value") { + t.Errorf("Expected 'nil credential Value' error, got: %v", err) + } +} + +func TestDefaultLocationProvider_Interface(t *testing.T) { + wrapper := &LambdaLabsCredentialWrapper{ + DefaultLocation: "us-west-1", + } + + provider, ok := interface{}(wrapper).(DefaultLocationProvider) + if !ok { + t.Fatal("LambdaLabsCredentialWrapper should implement DefaultLocationProvider") + } + + if provider.GetDefaultLocation() != "us-west-1" { + t.Errorf("Expected default location 'us-west-1', got '%s'", provider.GetDefaultLocation()) + } +} + +func TestProviderRegistry_LambdaLabs(t *testing.T) { + factory, exists := providerRegistry["lambdalabs"] + if !exists { + t.Fatal("lambdalabs provider not registered") + } + + cred := factory() + if cred == nil { + t.Fatal("Factory returned nil credential") + } + + if cred.GetCloudProviderID() != "lambda-labs" { + t.Errorf("Expected provider ID 'lambda-labs', got '%s'", cred.GetCloudProviderID()) + } + + wrapper, ok := cred.(*LambdaLabsCredentialWrapper) + if !ok { + t.Fatal("Expected LambdaLabsCredentialWrapper") + } + + if wrapper.LambdaLabsCredential == nil { + t.Fatal("Embedded LambdaLabsCredential is nil") + } +} + +func TestProviderRegistry_Nebius(t *testing.T) { + factory, exists := providerRegistry["nebius"] + if !exists { + t.Fatal("nebius provider not registered") + } + + cred := factory() + if cred == nil { + t.Fatal("Factory returned nil credential") + } + + if cred.GetCloudProviderID() != "nebius" { + t.Errorf("Expected provider ID 'nebius', got '%s'", cred.GetCloudProviderID()) + } + + wrapper, ok := cred.(*NebiusCredentialWrapper) + if !ok { + t.Fatal("Expected NebiusCredentialWrapper") + } + + if wrapper.NebiusCredential == nil { + t.Fatal("Embedded NebiusCredential is nil") + } +} From 5151264b6ebccf6c3f886e6652d8fc3038f370ba Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 01:59:33 +0000 Subject: [PATCH 6/6] Implement GitHub feedback: add JSON tags and unify provider IDs - Add JSON tags to LambdaLabsCredential and NebiusCredential structs - Use lowercase field names: ref_id, api_key, service_account_key, project_id - Unify Lambda Labs provider ID from 'lambda-labs' to 'lambdalabs' to match registry key - Remove providerIDToRegistryKey mapping from config.go (no longer needed) - Add provider constants (ProviderLambdaLabs, ProviderNebius) to eliminate goconst lint warnings - Update all tests to use new lowercase field names and provider constants - All tests pass and lint checks pass Co-Authored-By: Alec Fong --- cmd/bcloud/config/config.go | 26 +++++++++++------------- cmd/bcloud/config/config_test.go | 34 ++++++++++++++++---------------- internal/lambdalabs/v1/client.go | 8 ++++---- internal/nebius/v1/client.go | 6 +++--- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/cmd/bcloud/config/config.go b/cmd/bcloud/config/config.go index e7e36e3..844d4d3 100644 --- a/cmd/bcloud/config/config.go +++ b/cmd/bcloud/config/config.go @@ -12,12 +12,12 @@ import ( "gopkg.in/yaml.v3" ) -var providerRegistry = map[string]func() v1.CloudCredential{} +const ( + ProviderLambdaLabs = "lambdalabs" + ProviderNebius = "nebius" +) -var providerIDToRegistryKey = map[string]string{ - "lambda-labs": "lambdalabs", - "nebius": "nebius", -} +var providerRegistry = map[string]func() v1.CloudCredential{} func RegisterProvider(id string, factory func() v1.CloudCredential) { providerRegistry[id] = factory @@ -46,12 +46,12 @@ type DefaultLocationProvider interface { } func init() { - RegisterProvider("lambdalabs", func() v1.CloudCredential { + RegisterProvider(ProviderLambdaLabs, func() v1.CloudCredential { return &LambdaLabsCredentialWrapper{ LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{}, } }) - RegisterProvider("nebius", func() v1.CloudCredential { + RegisterProvider(ProviderNebius, func() v1.CloudCredential { return &NebiusCredentialWrapper{ NebiusCredential: &nebius.NebiusCredential{}, } @@ -72,18 +72,14 @@ func (c *CredentialEntry) decodeFromMap(m map[string]any, yamlKey string) error if !ok || provider == "" { return fmt.Errorf("invalid 'provider'") } - registryKey := provider - if mappedKey, exists := providerIDToRegistryKey[provider]; exists { - registryKey = mappedKey - } - factory, ok := providerRegistry[registryKey] + factory, ok := providerRegistry[provider] if !ok { return fmt.Errorf("unknown provider: %s", provider) } cred := factory() - if _, hasRefID := m["RefID"]; !hasRefID { - m["RefID"] = yamlKey + if _, hasRefID := m["ref_id"]; !hasRefID { + m["ref_id"] = yamlKey } b, err := json.Marshal(m) @@ -94,7 +90,7 @@ func (c *CredentialEntry) decodeFromMap(m map[string]any, yamlKey string) error return err } - c.Provider = registryKey + c.Provider = provider c.Value = cred return nil } diff --git a/cmd/bcloud/config/config_test.go b/cmd/bcloud/config/config_test.go index f805459..dad26cc 100644 --- a/cmd/bcloud/config/config_test.go +++ b/cmd/bcloud/config/config_test.go @@ -61,12 +61,12 @@ func TestLoadConfig_Success(t *testing.T) { credentials: test-lambda: provider: "lambdalabs" - APIKey: "test-key" + api_key: "test-key" default_location: "us-west-1" test-lambda-with-ref: provider: "lambdalabs" - APIKey: "test-key-2" - RefID: "custom-ref-id" + api_key: "test-key-2" + ref_id: "custom-ref-id" default_location: "us-east-1" settings: output_format: "json" @@ -95,8 +95,8 @@ settings: if !exists { t.Fatal("test-lambda credential not found") } - if testLambda.Provider != "lambdalabs" { - t.Errorf("Expected provider 'lambdalabs', got '%s'", testLambda.Provider) + if testLambda.Provider != ProviderLambdaLabs { + t.Errorf("Expected provider '%s', got '%s'", ProviderLambdaLabs, testLambda.Provider) } if testLambda.Value.GetReferenceID() != "test-lambda" { t.Errorf("Expected ref_id 'test-lambda', got '%s'", testLambda.Value.GetReferenceID()) @@ -132,7 +132,7 @@ func TestLoadConfig_WorkingDirectoryFallback(t *testing.T) { credentials: test-cred: provider: "lambdalabs" - APIKey: "test-key" + api_key: "test-key" default_location: "us-west-1" ` @@ -218,7 +218,7 @@ func TestLoadConfig_DefaultSettings(t *testing.T) { credentials: test-cred: provider: "lambdalabs" - APIKey: "test-key" + api_key: "test-key" default_location: "us-west-1" ` @@ -295,7 +295,7 @@ func TestCredentialEntry_DecodeFromMap_KeyAsRefID(t *testing.T) { var entry CredentialEntry m := map[string]any{ "provider": "lambdalabs", - "APIKey": "test-key", + "api_key": "test-key", "default_location": "us-west-1", } @@ -304,8 +304,8 @@ func TestCredentialEntry_DecodeFromMap_KeyAsRefID(t *testing.T) { t.Fatalf("decodeFromMap failed: %v", err) } - if entry.Provider != "lambdalabs" { - t.Errorf("Expected provider 'lambdalabs', got '%s'", entry.Provider) + if entry.Provider != ProviderLambdaLabs { + t.Errorf("Expected provider '%s', got '%s'", ProviderLambdaLabs, entry.Provider) } if entry.Value.GetReferenceID() != "my-lambda-key" { t.Errorf("Expected ref_id 'my-lambda-key', got '%s'", entry.Value.GetReferenceID()) @@ -324,8 +324,8 @@ func TestCredentialEntry_DecodeFromMap_ExplicitRefID(t *testing.T) { var entry CredentialEntry m := map[string]any{ "provider": "lambdalabs", - "APIKey": "test-key", - "RefID": "explicit-ref-id", + "api_key": "test-key", + "ref_id": "explicit-ref-id", "default_location": "us-west-1", } @@ -341,7 +341,7 @@ func TestCredentialEntry_DecodeFromMap_ExplicitRefID(t *testing.T) { func TestCredentialEntry_JSONMarshalUnmarshal(t *testing.T) { original := CredentialEntry{ - Provider: "lambdalabs", + Provider: ProviderLambdaLabs, Value: &LambdaLabsCredentialWrapper{ LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{ RefID: "test-ref", @@ -372,7 +372,7 @@ func TestCredentialEntry_JSONMarshalUnmarshal(t *testing.T) { func TestCredentialEntry_YAMLMarshalUnmarshal(t *testing.T) { original := CredentialEntry{ - Provider: "lambdalabs", + Provider: ProviderLambdaLabs, Value: &LambdaLabsCredentialWrapper{ LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{ RefID: "test-ref", @@ -403,7 +403,7 @@ func TestCredentialEntry_YAMLMarshalUnmarshal(t *testing.T) { func TestCredentialEntry_EncodeToMap_NilValue(t *testing.T) { entry := CredentialEntry{ - Provider: "lambdalabs", + Provider: ProviderLambdaLabs, Value: nil, } @@ -442,8 +442,8 @@ func TestProviderRegistry_LambdaLabs(t *testing.T) { t.Fatal("Factory returned nil credential") } - if cred.GetCloudProviderID() != "lambda-labs" { - t.Errorf("Expected provider ID 'lambda-labs', got '%s'", cred.GetCloudProviderID()) + if cred.GetCloudProviderID() != ProviderLambdaLabs { + t.Errorf("Expected provider ID '%s', got '%s'", ProviderLambdaLabs, cred.GetCloudProviderID()) } wrapper, ok := cred.(*LambdaLabsCredentialWrapper) diff --git a/internal/lambdalabs/v1/client.go b/internal/lambdalabs/v1/client.go index 75ae00e..561295b 100644 --- a/internal/lambdalabs/v1/client.go +++ b/internal/lambdalabs/v1/client.go @@ -14,8 +14,8 @@ import ( // LambdaLabsCredential implements the CloudCredential interface for Lambda Labs type LambdaLabsCredential struct { - RefID string - APIKey string + RefID string `json:"ref_id" yaml:"ref_id"` + APIKey string `json:"api_key" yaml:"api_key"` } var _ v1.CloudCredential = &LambdaLabsCredential{} @@ -38,7 +38,7 @@ func (c *LambdaLabsCredential) GetAPIType() v1.APIType { return v1.APITypeGlobal } -const CloudProviderID = "lambda-labs" +const CloudProviderID = "lambdalabs" const DefaultRegion string = "us-west-1" @@ -91,7 +91,7 @@ func (c *LambdaLabsClient) GetAPIType() v1.APIType { // GetCloudProviderID returns the cloud provider ID for Lambda Labs func (c *LambdaLabsClient) GetCloudProviderID() v1.CloudProviderID { - return "lambdalabs" + return CloudProviderID } // MakeClient creates a new client instance diff --git a/internal/nebius/v1/client.go b/internal/nebius/v1/client.go index 25d6d20..d1d0902 100644 --- a/internal/nebius/v1/client.go +++ b/internal/nebius/v1/client.go @@ -9,9 +9,9 @@ import ( ) type NebiusCredential struct { - RefID string - ServiceAccountKey string // JSON service account key - ProjectID string + RefID string `json:"ref_id" yaml:"ref_id"` + ServiceAccountKey string `json:"service_account_key" yaml:"service_account_key"` // JSON service account key + ProjectID string `json:"project_id" yaml:"project_id"` } var _ v1.CloudCredential = &NebiusCredential{}