diff --git a/internal/tiger/cmd/errors.go b/internal/tiger/cmd/errors.go index b2048b27..b359fc07 100644 --- a/internal/tiger/cmd/errors.go +++ b/internal/tiger/cmd/errors.go @@ -12,6 +12,7 @@ const ( ExitPermissionDenied = 5 // Permission denied ExitServiceNotFound = 6 // Service not found ExitUpdateAvailable = 7 // Update available + ExitMultipleMatches = 8 // Multiple resources match ) // exitCodeError creates an error that will cause the program to exit with the specified code diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index c43cc78c..bfedeed2 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -20,6 +20,10 @@ import ( var ( // getCredentialsForService can be overridden for testing getCredentialsForService = config.GetCredentials + // fetchAllServicesFunc can be overridden for testing + fetchAllServicesFunc = fetchAllServices + // fetchServiceFunc can be overridden for testing + fetchServiceFunc = fetchService ) // buildServiceCmd creates the main service command with all subcommands @@ -42,6 +46,77 @@ func buildServiceCmd() *cobra.Command { return cmd } +// getProjectApiClient retrieves the API client and project ID, handling authentication errors +func getProjectApiClient() (*api.ClientWithResponses, string, error) { + apiKey, projectID, err := getCredentialsForService() + if err != nil { + return nil, "", exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) + } + + // Create API client + client, err := api.NewTigerClient(apiKey) + if err != nil { + return nil, "", fmt.Errorf("failed to create API client: %w", err) + } + return client, projectID, nil +} + +// fetchAllServices fetches all services for a project, handling authentication and response errors +func fetchAllServices() ([]api.Service, error) { + client, projectID, err := getProjectApiClient() + if err != nil { + return nil, err + } + + // Make API call to list services + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetProjectsProjectIdServicesWithResponse(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("failed to list services: %w", err) + } + + // Handle API response + if resp.StatusCode() != 200 { + return nil, exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil || len(*resp.JSON200) == 0 { + return []api.Service{}, nil + } + + return *resp.JSON200, nil +} + +// fetchService fetches a specific service by ID, handling authentication and response errors +func fetchService(serviceID string) (*api.Service, error) { + client, projectID, err := getProjectApiClient() + if err != nil { + return nil, err + } + + // Make API call to get service details + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service details: %w", err) + } + + // Handle API response + if resp.StatusCode() != 200 { + return nil, exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil { + return nil, fmt.Errorf("empty response from API") + } + + return resp.JSON200, nil +} + // buildServiceGetCmd represents the get command under service func buildServiceGetCmd() *cobra.Command { var withPassword bool @@ -53,22 +128,28 @@ func buildServiceGetCmd() *cobra.Command { Short: "Show detailed information about a service", Long: `Show detailed information about a specific database service. -The service ID can be provided as an argument or will use the default service +The service ID or name can be provided as an argument or will use the default service from your configuration. This command displays comprehensive information about the service including configuration, status, endpoints, and resource usage. +If the provided name is ambiguous and matches multiple services, an error message will +list the matching services, and the process will exit with code ` + fmt.Sprintf("%d", ExitMultipleMatches) + `. + Examples: # Get default service details tiger service get - # Get specific service details - tiger service get svc-12345 + # Get specific service details by ID + tiger service get b0ysmfnr0y + + # Get specific service details by name + tiger service get my-service # Get service details in JSON format - tiger service get svc-12345 --output json + tiger service get my-service -o json # Get service details in YAML format - tiger service get svc-12345 --output yaml`, + tiger service get my-service -o yaml`, RunE: func(cmd *cobra.Command, args []string) error { // Get config cfg, err := config.Load() @@ -76,59 +157,68 @@ Examples: return fmt.Errorf("failed to load config: %w", err) } - // Use flag value if provided, otherwise use config value - if cmd.Flags().Changed("output") { - cfg.Output = output - } - - // Determine service ID - var serviceID string + idArg := "" if len(args) > 0 { - serviceID = args[0] - } else { - serviceID = cfg.ServiceID + idArg = args[0] } - if serviceID == "" { - return fmt.Errorf("service ID is required. Provide it as an argument or set a default with 'tiger config set service_id '") + if idArg == "" && cfg.ServiceID == "" { + return fmt.Errorf("target service was not specified. Provide it as an argument or set a default with 'tiger config set service_id '") } cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) + // Use flag value if provided, otherwise use config value + if cmd.Flags().Changed("output") { + cfg.Output = output } - // Create API client - client, err := api.NewTigerClient(apiKey) - if err != nil { - return fmt.Errorf("failed to create API client: %w", err) - } + // Determine service ID + var service *api.Service + if idArg == "" { + service, err = fetchServiceFunc(cfg.ServiceID) + if err != nil { + return err + } + } else { + services, err := fetchAllServicesFunc() + if err != nil { + return err + } - // Make API call to get service details - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + if len(services) == 0 { + return exitWithCode(ExitGeneralError, fmt.Errorf("you have no services")) + } - resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) - if err != nil { - return fmt.Errorf("failed to get service details: %w", err) - } + // Filter services by exact name or id match + var matches []api.Service + for _, service := range services { + if (service.ServiceId != nil && *service.ServiceId == idArg) || (service.Name != nil && *service.Name == idArg) { + matches = append(matches, service) + } + } - // Handle API response - if resp.StatusCode() != 200 { - return exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) - } + // Handle no matches + if len(matches) == 0 { + return exitWithCode(ExitServiceNotFound, fmt.Errorf("no services found matching '%s'", idArg)) + } - if resp.JSON200 == nil { - return fmt.Errorf("empty response from API") + if len(matches) > 1 { + // Multiple matches - output like 'service list' as error + if err := outputServices(cmd, matches, cfg.Output); err != nil { + return err + } + return exitWithCode(ExitMultipleMatches, fmt.Errorf("multiple services found matching '%s'", idArg)) + } + service = &matches[0] } - service := *resp.JSON200 + if service == nil { + return exitWithCode(ExitServiceNotFound, fmt.Errorf("service not found")) + } // Output service in requested format - return outputService(cmd, service, cfg.Output, withPassword, true) + return outputService(cmd, *service, cfg.Output, withPassword, true) }, } @@ -160,45 +250,18 @@ func buildServiceListCmd() *cobra.Command { cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(apiKey) + // Fetch all services using shared function + services, err := fetchAllServicesFunc() if err != nil { - return fmt.Errorf("failed to create API client: %w", err) - } - - // Make API call to list services - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - resp, err := client.GetProjectsProjectIdServicesWithResponse(ctx, projectID) - if err != nil { - return fmt.Errorf("failed to list services: %w", err) - } - - statusOutput := cmd.ErrOrStderr() - - // Handle API response - if resp.StatusCode() != 200 { - return exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + return err } - services := *resp.JSON200 if len(services) == 0 { + statusOutput := cmd.ErrOrStderr() fmt.Fprintln(statusOutput, "🏜️ No services found! Your project is looking a bit empty.") fmt.Fprintln(statusOutput, "🚀 Ready to get started? Create your first service with: tiger service create") - return nil - } - - if resp.JSON200 == nil { - fmt.Fprintln(statusOutput, "🏜️ No services found! Your project is looking a bit empty.") - fmt.Fprintln(statusOutput, "🚀 Ready to get started? Create your first service with: tiger service create") - return nil + cmd.SilenceErrors = true + return exitWithCode(ExitGeneralError, nil) } // Output services in requested format @@ -315,16 +378,9 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(apiKey) + client, projectID, err := getProjectApiClient() if err != nil { - return fmt.Errorf("failed to create API client: %w", err) + return err } // Prepare service creation request @@ -484,16 +540,9 @@ Examples: cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(apiKey) + client, projectID, err := getProjectApiClient() if err != nil { - return fmt.Errorf("failed to create API client: %w", err) + return err } // Prepare password update request @@ -885,10 +934,9 @@ Examples: cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() + client, projectID, err := getProjectApiClient() if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) + return err } statusOutput := cmd.ErrOrStderr() @@ -905,12 +953,6 @@ Examples: } } - // Create API client - client, err := api.NewTigerClient(apiKey) - if err != nil { - return fmt.Errorf("failed to create API client: %w", err) - } - // Make the delete request resp, err := client.DeleteProjectsProjectIdServicesServiceIdWithResponse( context.Background(), @@ -1101,16 +1143,9 @@ Examples: cmd.SilenceUsage = true - // Get API key and project ID for authentication - apiKey, projectID, err := getCredentialsForService() - if err != nil { - return exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err)) - } - - // Create API client - client, err := api.NewTigerClient(apiKey) + client, projectID, err := getProjectApiClient() if err != nil { - return fmt.Errorf("failed to create API client: %w", err) + return err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/internal/tiger/cmd/service_test.go b/internal/tiger/cmd/service_test.go index 56651162..0aaef067 100644 --- a/internal/tiger/cmd/service_test.go +++ b/internal/tiger/cmd/service_test.go @@ -564,7 +564,7 @@ func TestServiceGet_NoServiceID(t *testing.T) { t.Fatal("Expected error when no service ID is provided or configured") } - if !strings.Contains(err.Error(), "service ID is required") { + if !strings.Contains(err.Error(), "target service was not specified") { t.Errorf("Expected error about missing service ID, got: %v", err) } } @@ -599,6 +599,347 @@ func TestServiceGet_NoAuth(t *testing.T) { } } +func TestServiceGet_ByName_SingleMatch(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := getCredentialsForService + getCredentialsForService = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { getCredentialsForService = originalGetCredentials }() + + // Mock fetchAllServices to return test services + originalFetchAllServices := fetchAllServicesFunc + fetchAllServicesFunc = func() ([]api.Service, error) { + serviceID1 := "svc-match-123" + serviceName1 := "my-production-db" + serviceID2 := "svc-other-456" + serviceName2 := "other-service" + region := "us-east-1" + status := api.READY + serviceType := api.TIMESCALEDB + created := time.Now() + cpuMillis := 2000 + memoryGbs := 8 + + return []api.Service{ + { + ServiceId: &serviceID1, + Name: &serviceName1, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + Resources: &[]struct { + Id *string `json:"id,omitempty"` + Spec *struct { + CpuMillis *int `json:"cpu_millis,omitempty"` + MemoryGbs *int `json:"memory_gbs,omitempty"` + VolumeType *string `json:"volume_type,omitempty"` + } `json:"spec,omitempty"` + }{ + { + Spec: &struct { + CpuMillis *int `json:"cpu_millis,omitempty"` + MemoryGbs *int `json:"memory_gbs,omitempty"` + VolumeType *string `json:"volume_type,omitempty"` + }{ + CpuMillis: &cpuMillis, + MemoryGbs: &memoryGbs, + }, + }, + }, + }, + { + ServiceId: &serviceID2, + Name: &serviceName2, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + }, nil + } + defer func() { fetchAllServicesFunc = originalFetchAllServices }() + + // Execute service get by name (single match) + output, err, _ := executeServiceCommand("service", "get", "my-production-db") + if err != nil { + t.Fatalf("Expected success for single name match, got error: %v", err) + } + + // Verify detailed output format (like service get by ID) + expectedContents := []string{ + "svc-match-123", + "my-production-db", + "READY", + "TIMESCALEDB", + "us-east-1", + "2 cores (2000m)", + "8 GB", + } + + for _, content := range expectedContents { + if !strings.Contains(output, content) { + t.Errorf("Expected output to contain %q, got: %s", content, output) + } + } + + // Verify it's using detailed format (PROPERTY/VALUE headers) + if !strings.Contains(output, "PROPERTY") || !strings.Contains(output, "VALUE") { + t.Errorf("Single name match should use detailed table format, got: %s", output) + } +} + +func TestServiceGet_ByName_MultipleMatches(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := getCredentialsForService + getCredentialsForService = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { getCredentialsForService = originalGetCredentials }() + + // Mock fetchAllServices to return services with duplicate names + originalFetchAllServices := fetchAllServicesFunc + fetchAllServicesFunc = func() ([]api.Service, error) { + serviceID1 := "svc-dup1-123" + serviceID2 := "svc-dup2-456" + duplicateName := "duplicate-service" + region := "us-east-1" + status := api.READY + serviceType := api.TIMESCALEDB + created := time.Now() + + return []api.Service{ + { + ServiceId: &serviceID1, + Name: &duplicateName, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + { + ServiceId: &serviceID2, + Name: &duplicateName, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + }, nil + } + defer func() { fetchAllServicesFunc = originalFetchAllServices }() + + // Execute service get by name (multiple matches) + output, err, _ := executeServiceCommand("service", "get", "duplicate-service") + + // Should return an error + if err == nil { + t.Fatal("Expected error for multiple matches") + } + + // Verify error is about multiple matches + if !strings.Contains(err.Error(), "multiple services found") { + t.Errorf("Expected 'multiple services found' error, got: %v", err) + } + + // Verify exit code is ExitMultipleMatches + if exitErr, ok := err.(interface{ ExitCode() int }); ok { + if exitErr.ExitCode() != ExitMultipleMatches { + t.Errorf("Expected exit code %d, got %d", ExitMultipleMatches, exitErr.ExitCode()) + } + } else { + t.Error("Expected exitCodeError with ExitMultipleMatches code") + } + + // Verify both services are shown in output + if !strings.Contains(output, "svc-dup1-123") { + t.Errorf("Expected output to contain first service ID") + } + if !strings.Contains(output, "svc-dup2-456") { + t.Errorf("Expected output to contain second service ID") + } + if !strings.Contains(output, "duplicate-service") { + t.Errorf("Expected output to contain duplicate name") + } + + // Verify it's using list format (SERVICE ID, NAME headers) + if !strings.Contains(output, "SERVICE ID") || !strings.Contains(output, "NAME") { + t.Errorf("Multiple matches should use list table format, got: %s", output) + } +} + +func TestServiceGet_ByName_NoMatch(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := getCredentialsForService + getCredentialsForService = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { getCredentialsForService = originalGetCredentials }() + + // Mock fetchAllServices to return services that don't match + originalFetchAllServices := fetchAllServicesFunc + fetchAllServicesFunc = func() ([]api.Service, error) { + serviceID1 := "svc-one-123" + serviceName1 := "service-one" + serviceID2 := "svc-two-456" + serviceName2 := "service-two" + region := "us-east-1" + status := api.READY + serviceType := api.TIMESCALEDB + created := time.Now() + + return []api.Service{ + { + ServiceId: &serviceID1, + Name: &serviceName1, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + { + ServiceId: &serviceID2, + Name: &serviceName2, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + }, nil + } + defer func() { fetchAllServicesFunc = originalFetchAllServices }() + + // Execute service get by name (no matches) + _, err, _ = executeServiceCommand("service", "get", "non-existent-service") + + // Should return an error + if err == nil { + t.Fatal("Expected error for no matches") + } + + // Verify error is about service not found + if !strings.Contains(err.Error(), "no services found matching") { + t.Errorf("Expected 'no services found matching' error, got: %v", err) + } + + // Verify exit code is ExitServiceNotFound + if exitErr, ok := err.(interface{ ExitCode() int }); ok { + if exitErr.ExitCode() != ExitServiceNotFound { + t.Errorf("Expected exit code %d, got %d", ExitServiceNotFound, exitErr.ExitCode()) + } + } else { + t.Error("Expected exitCodeError with ExitServiceNotFound code") + } +} + +func TestServiceGet_ByID_WithSameNameExists(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := getCredentialsForService + getCredentialsForService = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { getCredentialsForService = originalGetCredentials }() + + // Mock fetchAllServices - edge case where service ID matches another service's name + originalFetchAllServices := fetchAllServicesFunc + fetchAllServicesFunc = func() ([]api.Service, error) { + serviceID1 := "svc-12345" + serviceName1 := "my-service" + serviceID2 := "svc-67890" + serviceName2 := serviceID1 // Name that matches the first service's ID + region := "us-east-1" + status := api.READY + serviceType := api.TIMESCALEDB + created := time.Now() + + return []api.Service{ + { + ServiceId: &serviceID1, + Name: &serviceName1, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + { + ServiceId: &serviceID2, + Name: &serviceName2, + RegionCode: ®ion, + Status: &status, + ServiceType: &serviceType, + Created: &created, + }, + }, nil + } + defer func() { fetchAllServicesFunc = originalFetchAllServices }() + + // Execute service get with "svc-12345" - matches service 1 by ID and service 2 by name + output, err, _ := executeServiceCommand("service", "get", "svc-12345") + + // Should return an error for multiple matches + if err == nil { + t.Fatal("Expected error for multiple matches (ID + name collision)") + } + + // Verify error is about multiple matches + if !strings.Contains(err.Error(), "multiple services found") { + t.Errorf("Expected 'multiple services found' error, got: %v", err) + } + + // Verify both services are shown (one matched by ID, one by name) + if !strings.Contains(output, "svc-12345") || !strings.Contains(output, "svc-67890") { + t.Errorf("Expected output to show both services, got: %s", output) + } + + // Verify exit code is ExitMultipleMatches + if exitErr, ok := err.(interface{ ExitCode() int }); ok { + if exitErr.ExitCode() != ExitMultipleMatches { + t.Errorf("Expected exit code %d, got %d", ExitMultipleMatches, exitErr.ExitCode()) + } + } +} + func TestOutputService_JSON(t *testing.T) { // Create a test service object serviceID := "svc-12345" diff --git a/specs/spec.md b/specs/spec.md index 49d387c1..ae2dde51 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -655,6 +655,7 @@ tiger config reset - `5`: Permission denied - `6`: Service not found - `7`: Update available (for explicit `version --check`) +- `8`: Multiple matches found (e.g., ambiguous service name) ## Output Formats