diff --git a/cmd/bcloud/commands/create.go b/cmd/bcloud/commands/create.go new file mode 100644 index 0000000..c3e4e3a --- /dev/null +++ b/cmd/bcloud/commands/create.go @@ -0,0 +1,92 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + 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] + + credEntry, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") + } + + if location == "" { + 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)") + } + + 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..2e7f6ad --- /dev/null +++ b/cmd/bcloud/commands/get.go @@ -0,0 +1,64 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + 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]) + + credEntry, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") + } + + var defaultLocation string + if provider, ok := cred.(config.DefaultLocationProvider); ok { + defaultLocation = provider.GetDefaultLocation() + } + if defaultLocation == "" { + return fmt.Errorf("default location is required in config") + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, 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..d56a30c --- /dev/null +++ b/cmd/bcloud/commands/list.go @@ -0,0 +1,72 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + 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] + + credEntry, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential entry has no value") + } + + if listLocation == "" { + 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)") + } + + 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..af417fb --- /dev/null +++ b/cmd/bcloud/commands/terminate.go @@ -0,0 +1,58 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + 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]) + + credEntry, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential value is nil") + } + + var defaultLocation string + if provider, ok := cred.(config.DefaultLocationProvider); ok { + defaultLocation = provider.GetDefaultLocation() + } + if defaultLocation == "" { + return fmt.Errorf("default location is required in config") + } + + ctx := context.Background() + client, err := cred.MakeClient(ctx, 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..1517a8c --- /dev/null +++ b/cmd/bcloud/commands/types.go @@ -0,0 +1,70 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/brevdev/cloud/cmd/bcloud/config" + 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] + + credEntry, exists := cfg.Credentials[refID] + if !exists { + return fmt.Errorf("credential '%s' not found in config", refID) + } + + cred := credEntry.Value + if cred == nil { + return fmt.Errorf("credential value is nil") + } + + if typesLocation == "" { + 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)") + } + + 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..844d4d3 --- /dev/null +++ b/cmd/bcloud/config/config.go @@ -0,0 +1,206 @@ +package config + +import ( + "encoding/json" + "fmt" + "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" +) + +const ( + ProviderLambdaLabs = "lambdalabs" + ProviderNebius = "nebius" +) + +var providerRegistry = map[string]func() v1.CloudCredential{} + +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(ProviderLambdaLabs, func() v1.CloudCredential { + return &LambdaLabsCredentialWrapper{ + LambdaLabsCredential: &lambdalabs.LambdaLabsCredential{}, + } + }) + RegisterProvider(ProviderNebius, func() v1.CloudCredential { + return &NebiusCredentialWrapper{ + NebiusCredential: &nebius.NebiusCredential{}, + } + }) +} + +type CredentialEntry struct { + Provider string `json:"provider" yaml:"provider"` + Value v1.CloudCredential `json:"-" yaml:"-"` +} + +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 CredentialEntry) MarshalYAML() (interface{}, error) { + m, err := c.encodeToMap() + if err != nil { + return nil, err + } + 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) { + 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 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" + } + if config.Settings.DefaultTimeout == "" { + config.Settings.DefaultTimeout = "5m" + } + + return &config, nil +} diff --git a/cmd/bcloud/config/config_test.go b/cmd/bcloud/config/config_test.go new file mode 100644 index 0000000..dad26cc --- /dev/null +++ b/cmd/bcloud/config/config_test.go @@ -0,0 +1,482 @@ +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) { + 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) + } + }) + } +} + +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" + api_key: "test-key" + default_location: "us-west-1" + test-lambda-with-ref: + provider: "lambdalabs" + api_key: "test-key-2" + ref_id: "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 != 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()) + } + + 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" + api_key: "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" + api_key: "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", + "api_key": "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 != 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()) + } + + 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", + "api_key": "test-key", + "ref_id": "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: ProviderLambdaLabs, + 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: ProviderLambdaLabs, + 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: ProviderLambdaLabs, + 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() != ProviderLambdaLabs { + t.Errorf("Expected provider ID '%s', got '%s'", ProviderLambdaLabs, 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") + } +} 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/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" 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= 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{}