diff --git a/CLAUDE.md b/CLAUDE.md index 692eeb8e..bf91f61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -267,7 +267,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da - **Command Structure**: `internal/tiger/cmd/` - Cobra-based command definitions - `root.go` - Root command with global flags and configuration initialization - `auth.go` - Authentication commands (login, logout, status) - - `service.go` - Service management commands (list, create, get, fork, start, stop, delete, update-password) + - `service.go` - Service management commands (list, create, get, fork, start, stop, resize, delete, update-password) - `db.go` - Database operation commands (connection-string, connect, test-connection) - `config.go` - Configuration management commands (show, set, unset, reset) - `mcp.go` - MCP server commands (install, start, list) @@ -277,7 +277,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da - **API Client**: `internal/tiger/api/` - Generated OpenAPI client with mocks - **MCP Server**: `internal/tiger/mcp/` - Model Context Protocol server implementation - `server.go` - MCP server initialization, tool registration, and lifecycle management - - `service_tools.go` - Service management tools (list, get, create, fork, start, stop, update-password) + - `service_tools.go` - Service management tools (list, get, create, fork, start, stop, resize, update-password) - `db_tools.go` - Database operation tools (execute-query) - `proxy.go` - Proxy client that forwards tools/resources/prompts from remote docs MCP server - `capabilities.go` - Lists all available MCP capabilities (tools, prompts, resources, resource templates) diff --git a/README.md b/README.md index 1d479f28..8e4de228 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Tiger CLI provides the following commands: - `fork` - Fork an existing service - `start` - Start a stopped service - `stop` - Stop a running service + - `resize` - Resize service CPU and memory allocation - `delete` - Delete a service - `update-password` - Update service master password - `tiger db` - Database operations @@ -178,6 +179,7 @@ The MCP server exposes the following tools to AI assistants: - `service_fork` - Fork an existing database service to create an independent copy - `service_start` - Start a stopped database service - `service_stop` - Stop a running database service +- `service_resize` - Resize a database service by changing CPU and memory allocation - `service_update_password` - Update the master password for a service **Database Operations:** diff --git a/internal/tiger/api/client.go b/internal/tiger/api/client.go index 4d74141a..89f33bdb 100644 --- a/internal/tiger/api/client.go +++ b/internal/tiger/api/client.go @@ -3020,6 +3020,7 @@ func (r PostProjectsProjectIdServicesServiceIdReplicaSetsReplicaSetIdSetEnvironm type PostProjectsProjectIdServicesServiceIdResizeResponse struct { Body []byte HTTPResponse *http.Response + JSON202 *Service JSON4XX *ClientError } @@ -4412,6 +4413,13 @@ func ParsePostProjectsProjectIdServicesServiceIdResizeResponse(rsp *http.Respons } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest Service + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode/100 == 4: var dest ClientError if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/internal/tiger/api/types.go b/internal/tiger/api/types.go index 20234e2d..398f9524 100644 --- a/internal/tiger/api/types.go +++ b/internal/tiger/api/types.go @@ -247,14 +247,11 @@ type ReadReplicaSetCreate struct { // ResizeInput defines model for ResizeInput. type ResizeInput struct { - // CpuMillis The new CPU allocation in milli-cores (e.g., 1000 for 1 vCPU). - CpuMillis int `json:"cpu_millis"` + // CpuMillis The new CPU allocation in milli-cores. + CpuMillis string `json:"cpu_millis"` // MemoryGbs The new memory allocation in gigabytes. - MemoryGbs int `json:"memory_gbs"` - - // Nodes The new number of nodes in the replica set. - Nodes *int `json:"nodes,omitempty"` + MemoryGbs string `json:"memory_gbs"` } // Service defines model for Service. diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index 6153ed38..ecf7b633 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" "os" "os/exec" "strings" @@ -787,7 +788,7 @@ func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (a } // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return api.Service{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } diff --git a/internal/tiger/cmd/integration_test.go b/internal/tiger/cmd/integration_test.go index dc7a5167..0109f151 100644 --- a/internal/tiger/cmd/integration_test.go +++ b/internal/tiger/cmd/integration_test.go @@ -1016,6 +1016,146 @@ func TestServiceLifecycleIntegration(t *testing.T) { } }) + t.Run("ResizeService", func(t *testing.T) { + if serviceID == "" { + t.Skip("No service ID available from create test") + } + + t.Logf("Resizing service: %s", serviceID) + + // First, get current service details to see current CPU/memory + output, err := executeIntegrationCommand( + t.Context(), + "service", "describe", serviceID, + "--output", "json", + ) + + if err != nil { + t.Fatalf("Failed to describe service before resize: %v\nOutput: %s", err, output) + } + + // Parse JSON to check current resources + var serviceBefore api.Service + if err := json.Unmarshal([]byte(output), &serviceBefore); err != nil { + t.Fatalf("Failed to parse service JSON: %v", err) + } + + var currentCPU, currentMemory string + if serviceBefore.Resources != nil && len(*serviceBefore.Resources) > 0 { + resource := (*serviceBefore.Resources)[0] + if resource.Spec != nil { + if resource.Spec.CpuMillis != nil { + cpuCores := float64(*resource.Spec.CpuMillis) / 1000 + currentCPU = fmt.Sprintf("%.1f CPU", cpuCores) + } + if resource.Spec.MemoryGbs != nil { + currentMemory = fmt.Sprintf("%d GB", *resource.Spec.MemoryGbs) + } + } + } + + t.Logf("Current resources: CPU=%s, Memory=%s", currentCPU, currentMemory) + + // Resize to 1 CPU / 4 GB (larger than default 0.5 CPU / 2 GB) + // Note: --cpu expects millicores (1000 = 1 CPU), --memory expects GB as integer + targetCPUMillis := "1000" // 1 CPU = 1000 millicores + targetMemoryGB := "4" // 4 GB + + t.Logf("Resizing to: CPU=%s millicores (1 CPU), Memory=%s GB", targetCPUMillis, targetMemoryGB) + + output, err = executeIntegrationCommand( + t.Context(), + "service", "resize", serviceID, + "--cpu", targetCPUMillis, + "--memory", targetMemoryGB, + "--wait-timeout", "10m", // Longer timeout for resize operations + ) + + if err != nil { + t.Fatalf("Service resize failed: %v\nOutput: %s", err, output) + } + + // Verify resize success message + if !strings.Contains(output, "Resize completed successfully") && + !strings.Contains(output, "resized successfully") { + t.Logf("Note: Expected resize success message, got: %s", output) + } + + t.Logf("Service resize command completed successfully") + }) + + t.Run("VerifyServiceResized", func(t *testing.T) { + if serviceID == "" { + t.Skip("No service ID available from create test") + } + + t.Logf("Verifying service has been resized") + + output, err := executeIntegrationCommand( + t.Context(), + "service", "describe", serviceID, + "--output", "json", + ) + + if err != nil { + t.Fatalf("Failed to describe service after resize: %v\nOutput: %s", err, output) + } + + // Parse JSON to check new resources + var serviceAfter api.Service + if err := json.Unmarshal([]byte(output), &serviceAfter); err != nil { + t.Fatalf("Failed to parse service JSON: %v", err) + } + + var newCPUMillis, newMemoryGbs int + if serviceAfter.Resources != nil && len(*serviceAfter.Resources) > 0 { + resource := (*serviceAfter.Resources)[0] + if resource.Spec != nil { + if resource.Spec.CpuMillis != nil { + newCPUMillis = *resource.Spec.CpuMillis + } + if resource.Spec.MemoryGbs != nil { + newMemoryGbs = *resource.Spec.MemoryGbs + } + } + } + + newCPU := fmt.Sprintf("%.1f CPU", float64(newCPUMillis)/1000) + newMemory := fmt.Sprintf("%d GB", newMemoryGbs) + + t.Logf("New resources after resize: CPU=%s (millis=%d), Memory=%s", newCPU, newCPUMillis, newMemory) + + // Verify the service has been resized to expected values + expectedCPUMillis := 1000 // 1 CPU = 1000 millicores + expectedMemoryGbs := 4 // 4 GB + + if newCPUMillis != expectedCPUMillis { + t.Errorf("Expected CPU to be %d millicores after resize, got %d", expectedCPUMillis, newCPUMillis) + } else { + t.Logf("✅ CPU correctly resized to %d millicores (1 CPU)", newCPUMillis) + } + + if newMemoryGbs != expectedMemoryGbs { + t.Errorf("Expected Memory to be %d GB after resize, got %d", expectedMemoryGbs, newMemoryGbs) + } else { + t.Logf("✅ Memory correctly resized to %d GB", newMemoryGbs) + } + + // Verify service is still in READY state after resize + var status string + if serviceAfter.Status != nil { + status = string(*serviceAfter.Status) + } + + if status != "READY" { + t.Logf("Warning: Expected service status to be READY after resize, got %s", status) + } else { + t.Logf("✅ Service is correctly in READY state after resize") + } + + t.Logf("✅ Service resize verified successfully") + }) + t.Run("DeleteService", func(t *testing.T) { if serviceID == "" { t.Skip("No service ID available for deletion") @@ -1572,6 +1712,11 @@ func TestServiceForkIntegration(t *testing.T) { t.Fatalf("Login failed: %v\nOutput: %s", err, output) } + // Verify login success message + if !strings.Contains(output, "Successfully logged in") && !strings.Contains(output, "Logged in") { + t.Errorf("Login output: %s", output) + } + t.Logf("Login successful") }) diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 7bd125b4..3f08472d 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "net/http" "os" "strings" "time" @@ -37,6 +38,7 @@ func buildServiceCmd() *cobra.Command { cmd.AddCommand(buildServiceStopCmd()) cmd.AddCommand(buildServiceUpdatePasswordCmd()) cmd.AddCommand(buildServiceForkCmd()) + cmd.AddCommand(buildServiceResizeCmd()) return cmd } @@ -97,14 +99,13 @@ Examples: } // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } if resp.JSON200 == nil { return fmt.Errorf("empty response from API") } - service := *resp.JSON200 // Output service in requested format @@ -150,11 +151,15 @@ func buildServiceListCmd() *cobra.Command { statusOutput := cmd.ErrOrStderr() // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } + if resp.JSON200 == nil { + return fmt.Errorf("empty response from API") + } services := *resp.JSON200 + if len(services) == 0 { 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") @@ -268,7 +273,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } // Validate and normalize CPU/Memory configuration - cpuMillis, memoryGBs, err := common.ValidateAndNormalizeCPUMemory(createCpuMillis, createMemoryGBs) + cpuMemoryCfg, err := common.ValidateAndNormalizeCPUMemory(createCpuMillis, createMemoryGBs) if err != nil { return err } @@ -292,8 +297,8 @@ Note: You can specify both CPU and memory together, or specify only one (the oth Name: createServiceName, Addons: util.ConvertStringSlicePtr[api.ServiceCreateAddons](addons), ReplicaCount: &createReplicaCount, - CpuMillis: cpuMillis, - MemoryGbs: memoryGBs, + CpuMillis: cpuMemoryCfg.CPUMillisString(), + MemoryGbs: cpuMemoryCfg.MemoryGBsString(), EnvironmentTag: &environmentTag, } @@ -319,67 +324,64 @@ Note: You can specify both CPU and memory together, or specify only one (the oth } // Handle API response - switch resp.StatusCode() { - case 202: - // Success - service creation accepted - if resp.JSON202 == nil { - fmt.Fprintln(statusOutput, "✅ Service creation request accepted!") - return nil - } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } - service := *resp.JSON202 - serviceID := util.Deref(service.ServiceId) - fmt.Fprintf(statusOutput, "✅ Service creation request accepted!\n") - fmt.Fprintf(statusOutput, "📋 Service ID: %s\n", serviceID) - - // Save password immediately after service creation, before any waiting - // This ensures users have access even if they interrupt the wait or it fails - passwordSaved := handlePasswordSaving(service, util.Deref(service.InitialPassword), statusOutput) - - // Set as default service unless --no-set-default is specified - if !createNoSetDefault { - if err := setDefaultService(cfg.Config, serviceID, statusOutput); err != nil { - // Log warning but don't fail the command - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) - } - } + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + service := *resp.JSON202 + serviceID := util.Deref(service.ServiceId) - // Handle wait behavior - var waitErr error - if createNoWait { - fmt.Fprintf(statusOutput, "⏳ Service is being created. Use 'tiger service list' to check status.\n") - } else { - // Wait for service to be ready - fmt.Fprintf(statusOutput, "⏳ Waiting for service to be ready (wait Timeout: %v)...\n", createWaitTimeout) - if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ - Client: cfg.Client, - ProjectID: cfg.ProjectID, - ServiceID: serviceID, - Handler: &common.StatusWaitHandler{ - TargetStatus: "READY", - Service: &service, - }, - Output: statusOutput, - Timeout: createWaitTimeout, - TimeoutMsg: "service may still be provisioning", - }); waitErr != nil { - fmt.Fprintf(statusOutput, "❌ Error: %s\n", waitErr) - } else { - fmt.Fprintf(statusOutput, "🎉 Service is ready and running!\n") - printConnectMessage(statusOutput, passwordSaved, createNoSetDefault, serviceID) - } + fmt.Fprintf(statusOutput, "✅ Service creation request accepted!\n") + fmt.Fprintf(statusOutput, "📋 Service ID: %s\n", serviceID) + + // Save password immediately after service creation, before any waiting + // This ensures users have access even if they interrupt the wait or it fails + passwordSaved := handlePasswordSaving(service, util.Deref(service.InitialPassword), statusOutput) + + // Set as default service unless --no-set-default is specified + if !createNoSetDefault { + if err := setDefaultService(cfg.Config, serviceID, statusOutput); err != nil { + // Log warning but don't fail the command + fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to set service as default: %v\n", err) } + } - if err := outputService(cmd, service, cfg.Output, createWithPassword, false); err != nil { - fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) + // Handle wait behavior + var waitErr error + if createNoWait { + fmt.Fprintf(statusOutput, "⏳ Service is being created. Use 'tiger service list' to check status.\n") + } else { + // Wait for service to be ready + fmt.Fprintf(statusOutput, "⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout) + if waitErr = common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ + Client: cfg.Client, + ProjectID: cfg.ProjectID, + ServiceID: serviceID, + Handler: &common.StatusWaitHandler{ + TargetStatus: "READY", + Service: &service, + }, + Output: statusOutput, + Timeout: createWaitTimeout, + TimeoutMsg: "service may still be provisioning", + }); waitErr != nil { + fmt.Fprintf(statusOutput, "❌ Error: %s\n", waitErr) + } else { + fmt.Fprintf(statusOutput, "🎉 Service is ready and running!\n") + printConnectMessage(statusOutput, passwordSaved, createNoSetDefault, serviceID) } + } - // Return error for sake of exit code, but silence it since it was already output above - cmd.SilenceErrors = true - return waitErr - default: - return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + if err := outputService(cmd, service, cfg.Output, createWithPassword, false); err != nil { + fmt.Fprintf(statusOutput, "⚠️ Warning: Failed to output service details: %v\n", err) } + + // Return error for sake of exit code, but silence it since it was already output above + cmd.SilenceErrors = true + return waitErr }, } @@ -469,9 +471,13 @@ Examples: if err != nil { return fmt.Errorf("failed to get service details: %w", err) } - if serviceResp.StatusCode() != 200 { + if serviceResp.StatusCode() != http.StatusOK { return common.ExitWithErrorFromStatusCode(serviceResp.StatusCode(), serviceResp.JSON4XX) } + + if serviceResp.JSON200 == nil { + return fmt.Errorf("empty response from API") + } service := *serviceResp.JSON200 statusOutput := cmd.ErrOrStderr() @@ -858,7 +864,7 @@ Examples: } // Handle response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } @@ -950,9 +956,13 @@ Examples: } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } + + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } service := *resp.JSON202 statusOutput := cmd.ErrOrStderr() @@ -965,7 +975,7 @@ Examples: } // Wait for service to become ready - fmt.Fprintf(statusOutput, "⏳ Waiting for service to start (wait Timeout: %v)...\n", startWaitTimeout) + fmt.Fprintf(statusOutput, "⏳ Waiting for service to start (wait timeout: %v)...\n", startWaitTimeout) if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ Client: cfg.Client, ProjectID: cfg.ProjectID, @@ -1046,9 +1056,13 @@ Examples: } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } + + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } service := *resp.JSON202 statusOutput := cmd.ErrOrStderr() @@ -1197,7 +1211,7 @@ Examples: defer cancel() // Use provided custom values, validate against allowed combinations - cpuMillis, memoryGBs, err := common.ValidateAndNormalizeCPUMemory(forkCPU, forkMemory) + cpuMemoryCfg, err := common.ValidateAndNormalizeCPUMemory(forkCPU, forkMemory) if err != nil { return err } @@ -1239,8 +1253,8 @@ Examples: forkReq := api.ForkServiceCreate{ ForkStrategy: forkStrategy, TargetTime: targetTime, - CpuMillis: cpuMillis, - MemoryGbs: memoryGBs, + CpuMillis: cpuMemoryCfg.CPUMillisString(), + MemoryGbs: cpuMemoryCfg.MemoryGBsString(), EnvironmentTag: &environmentTag, } @@ -1256,13 +1270,16 @@ Examples: } // Handle API response - if forkResp.StatusCode() != 202 { + if forkResp.StatusCode() != http.StatusAccepted { return common.ExitWithErrorFromStatusCode(forkResp.StatusCode(), forkResp.JSON4XX) } - // Success - service fork accepted + if forkResp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } forkedService := *forkResp.JSON202 forkedServiceID := util.DerefStr(forkedService.ServiceId) + fmt.Fprintf(statusOutput, "✅ Fork request accepted!\n") fmt.Fprintf(statusOutput, "📋 New Service ID: %s\n", forkedServiceID) @@ -1334,6 +1351,150 @@ Examples: return cmd } +// buildServiceResizeCmd creates the resize subcommand +func buildServiceResizeCmd() *cobra.Command { + var resizeCPU string + var resizeMemory string + var resizeNoWait bool + var resizeWaitTimeout time.Duration + + cmd := &cobra.Command{ + Use: "resize [service-id]", + Short: "Resize a database service", + Long: `Resize a database service by changing its CPU and memory allocation. + +The service ID can be provided as an argument or will use the default service +from your configuration. This command changes the compute and memory resources +allocated to your database service. + +The service may be temporarily unavailable during the resize operation. Note +that changing resources will affect your billing - increasing resources will +increase costs. + +Examples: + # Resize default service to 2 CPU cores and 8GB memory + tiger service resize --cpu 2000 --memory 8 + + # Resize specific service to 4 CPU cores and 16GB memory + tiger service resize svc-12345 --cpu 4000 --memory 16 + + # Resize service using only CPU (memory will be auto-configured to 8GB) + tiger service resize --cpu 2000 + + # Resize service using only memory (CPU will be auto-configured to 4000m) + tiger service resize --memory 16 + + # Resize without waiting for completion (waits by default) + tiger service resize --cpu 2000 --memory 8 --no-wait + + # Resize with custom wait timeout + tiger service resize --cpu 2000 --memory 8 --wait-timeout 45m + +Allowed CPU/Memory Configurations: + 0.5 CPU (500m) / 2GB | 1 CPU (1000m) / 4GB | 2 CPU (2000m) / 8GB | 4 CPU (4000m) / 16GB + 8 CPU (8000m) / 32GB | 16 CPU (16000m) / 64GB | 32 CPU (32000m) / 128GB + +Note: You can specify both CPU and memory together, or specify only one (the other will be automatically configured).`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: serviceIDCompletion, + RunE: func(cmd *cobra.Command, args []string) error { + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + cmd.SilenceUsage = true + return err + } + + // Determine service ID + serviceID, err := getServiceID(cfg.Config, args) + if err != nil { + return err + } + + // Validate and normalize CPU/Memory configuration + cpuMemoryCfg, err := common.ValidateAndNormalizeCPUMemory(resizeCPU, resizeMemory) + if err != nil { + return err + } + + // At least one of CPU or memory must be specified + if cpuMemoryCfg == nil { + return fmt.Errorf("must specify at least one of --cpu or --memory") + } + + cmd.SilenceUsage = true + + // Display resize information + statusOutput := cmd.ErrOrStderr() + fmt.Fprintf(statusOutput, "📐 Resizing service '%s' to %s...\n", serviceID, cpuMemoryCfg) + + // Prepare resize request + resizeReq := api.ResizeInput{ + CpuMillis: *cpuMemoryCfg.CPUMillisString(), + MemoryGbs: *cpuMemoryCfg.MemoryGBsString(), + } + + // Make API call to resize service + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdResizeWithResponse(ctx, cfg.ProjectID, serviceID, resizeReq) + if err != nil { + return fmt.Errorf("failed to resize service: %w", err) + } + + // Handle API response + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + service := *resp.JSON202 + + fmt.Fprintf(statusOutput, "✅ Resize request accepted for service '%s'!\n", serviceID) + + // If not waiting, return early + if resizeNoWait { + fmt.Fprintln(statusOutput, "💡 Use 'tiger service get' to check service status.") + return nil + } + + // Wait for resize to complete + fmt.Fprintf(statusOutput, "⏳ Waiting for resize to complete (timeout: %v)...\n", resizeWaitTimeout) + if err := common.WaitForService(cmd.Context(), common.WaitForServiceArgs{ + Client: cfg.Client, + ProjectID: cfg.ProjectID, + ServiceID: serviceID, + Handler: &common.StatusWaitHandler{ + TargetStatus: "READY", + Service: &service, + }, + Output: statusOutput, + Timeout: resizeWaitTimeout, + TimeoutMsg: "service may still be resizing", + }); err != nil { + // Return error for sake of exit code, but silence since we already output it + fmt.Fprintf(statusOutput, "❌ Error: %s\n", err) + cmd.SilenceErrors = true + return err + } + + fmt.Fprintf(statusOutput, "🎉 Service '%s' has been successfully resized to %s!\n", serviceID, cpuMemoryCfg) + return nil + }, + } + + // Add flags + cmd.Flags().StringVar(&resizeCPU, "cpu", "", "CPU allocation in millicores") + cmd.Flags().StringVar(&resizeMemory, "memory", "", "Memory allocation in gigabytes") + cmd.Flags().BoolVar(&resizeNoWait, "no-wait", false, "Don't wait for resize operation to complete") + cmd.Flags().DurationVar(&resizeWaitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for operation to complete") + + return cmd +} + func serviceIDCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // Service ID is always first positional argument if len(args) > 0 { @@ -1371,7 +1532,7 @@ func listServices(cmd *cobra.Command) ([]api.Service, error) { } // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } diff --git a/internal/tiger/cmd/service_test.go b/internal/tiger/cmd/service_test.go index fc231bf1..74f95ea4 100644 --- a/internal/tiger/cmd/service_test.go +++ b/internal/tiger/cmd/service_test.go @@ -1672,3 +1672,97 @@ func TestServiceList_OutputFlagAffectsCommandOnly(t *testing.T) { string(originalConfigBytes), string(newConfigBytes)) } } + +func TestServiceResize_NoAuth(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config with API URL + _, 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 failure + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { + return "", "", fmt.Errorf("not logged in") + } + defer func() { common.GetCredentials = originalGetCredentials }() + + // Execute service resize command + _, err, _ = executeServiceCommand(t.Context(), "service", "resize", "svc-12345", "--cpu", "2000", "--memory", "8") + if err == nil { + t.Fatal("Expected error when not authenticated") + } + + if !strings.Contains(err.Error(), "authentication required") { + t.Errorf("Expected authentication error, got: %v", err) + } + + // Check for proper exit code + if exitErr, ok := err.(interface{ ExitCode() int }); !ok || exitErr.ExitCode() != common.ExitAuthenticationError { + t.Errorf("Expected exit code %d, got: %v", common.ExitAuthenticationError, err) + } +} + +func TestServiceResize_MissingParams(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config with API URL + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + "service_id": "svc-12345", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { common.GetCredentials = originalGetCredentials }() + + // Test missing both CPU and memory parameters + _, err, _ = executeServiceCommand(t.Context(), "service", "resize") + if err == nil { + t.Fatal("Expected error when CPU and memory are missing") + } + + if !strings.Contains(err.Error(), "must specify at least one of --cpu or --memory") { + t.Errorf("Expected missing params error, got: %v", err) + } +} + +func TestServiceResize_InvalidCPUMemoryCombination(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config with API URL + _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": "https://api.tigerdata.com/public/v1", + "service_id": "svc-12345", + }) + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetCredentials := common.GetCredentials + common.GetCredentials = func() (string, string, error) { + return "test-api-key", "test-project-123", nil + } + defer func() { common.GetCredentials = originalGetCredentials }() + + // Test invalid CPU/memory combination + _, err, _ = executeServiceCommand(t.Context(), "service", "resize", "--cpu", "3000", "--memory", "8") + if err == nil { + t.Fatal("Expected error for invalid CPU/memory combination") + } + + if !strings.Contains(err.Error(), "invalid CPU/Memory combination") { + t.Errorf("Expected invalid combination error, got: %v", err) + } +} diff --git a/internal/tiger/common/cpu_memory.go b/internal/tiger/common/cpu_memory.go index d11cfe68..03846807 100644 --- a/internal/tiger/common/cpu_memory.go +++ b/internal/tiger/common/cpu_memory.go @@ -13,12 +13,12 @@ type CPUMemoryConfig struct { MemoryGBs int // Memory in GB } -func (c *CPUMemoryConfig) Matches(cpuMillis, memoryGBs string) (string, string, bool) { +func (c *CPUMemoryConfig) Matches(cpuMillis, memoryGBs string) bool { if c.Shared { if (cpuMillis == "shared" && memoryGBs == "shared") || (cpuMillis == "shared" && memoryGBs == "") || (cpuMillis == "" && memoryGBs == "shared") { - return "shared", "shared", true + return true } } @@ -28,10 +28,34 @@ func (c *CPUMemoryConfig) Matches(cpuMillis, memoryGBs string) (string, string, if (cpuMillis == cpuMillisStr && memoryGBs == memoryGBsStr) || (cpuMillis == cpuMillisStr && memoryGBs == "") || (cpuMillis == "" && memoryGBs == memoryGBsStr) { - return cpuMillisStr, memoryGBsStr, true + return true } - return "", "", false + return false +} + +func (c *CPUMemoryConfig) CPUMillisString() *string { + if c == nil { + return nil + } + + str := "shared" + if !c.Shared { + str = strconv.Itoa(c.CPUMillis) + } + return &str +} + +func (c *CPUMemoryConfig) MemoryGBsString() *string { + if c == nil { + return nil + } + + str := "shared" + if !c.Shared { + str = strconv.Itoa(c.MemoryGBs) + } + return &str } func (c *CPUMemoryConfig) String() string { @@ -62,6 +86,18 @@ func GetAllowedCPUMemoryConfigs() CPUMemoryConfigs { } } +// GetAllowedResizeCPUMemoryConfigs returns the allowed CPU/Memory configurations for resize operations (excludes shared) +func GetAllowedResizeCPUMemoryConfigs() CPUMemoryConfigs { + all := GetAllowedCPUMemoryConfigs() + filtered := make(CPUMemoryConfigs, 0, len(all)-1) + for _, config := range all { + if !config.Shared { + filtered = append(filtered, config) + } + } + return filtered +} + // Strings returns a slice of user-friendly strings of allowed CPU/Memory combinations func (c CPUMemoryConfigs) Strings() []string { strs := make([]string, 0, len(c)) @@ -77,21 +113,21 @@ func (c CPUMemoryConfigs) String() string { } // ValidateAndNormalizeCPUMemory validates CPU/Memory values and applies auto-configuration logic -func ValidateAndNormalizeCPUMemory(cpuMillis, memoryGBs string) (*string, *string, error) { +func ValidateAndNormalizeCPUMemory(cpuMillis, memoryGBs string) (*CPUMemoryConfig, error) { // Return nil for omitted CPU/memory so that values are omitted from the API request if cpuMillis == "" && memoryGBs == "" { - return nil, nil, nil + return nil, nil } configs := GetAllowedCPUMemoryConfigs() for _, config := range configs { - if cpuStr, memoryStr, ok := config.Matches(cpuMillis, memoryGBs); ok { - return &cpuStr, &memoryStr, nil + if config.Matches(cpuMillis, memoryGBs) { + return &config, nil } } // If no match, provide helpful error - return nil, nil, fmt.Errorf("invalid CPU/Memory combination. Allowed combinations: %s", configs) + return nil, fmt.Errorf("invalid CPU/Memory combination. Allowed combinations: %s", configs) } // ParseCPUMemory parses a CPU/memory combination string (e.g., "2 CPU/8GB") diff --git a/internal/tiger/common/cpu_memory_test.go b/internal/tiger/common/cpu_memory_test.go index 8904d316..5afb1e9f 100644 --- a/internal/tiger/common/cpu_memory_test.go +++ b/internal/tiger/common/cpu_memory_test.go @@ -4,53 +4,51 @@ import "testing" func TestValidateAndNormalizeCPUMemory(t *testing.T) { testCases := []struct { - name string - cpuMillis string - memoryGbs string - expectError bool - expectNil bool - expectedCPU string - expectedMem string + name string + cpuMillis string + memoryGbs string + expectError bool + expectNil bool + expectShared bool + expectedCPU int + expectedMem int }{ { name: "Valid combination both set (1 CPU, 4GB)", cpuMillis: "1000", memoryGbs: "4", expectError: false, - expectedCPU: "1000", - expectedMem: "4", + expectedCPU: 1000, + expectedMem: 4, }, { name: "Valid combination both set (0.5 CPU, 2GB)", cpuMillis: "500", memoryGbs: "2", expectError: false, - expectedCPU: "500", - expectedMem: "2", + expectedCPU: 500, + expectedMem: 2, }, { - name: "Valid shared/shared combination", - cpuMillis: "shared", - memoryGbs: "shared", - expectError: false, - expectedCPU: "shared", - expectedMem: "shared", + name: "Valid shared/shared combination", + cpuMillis: "shared", + memoryGbs: "shared", + expectError: false, + expectShared: true, }, { - name: "CPU shared, memory empty (auto-configure to shared)", - cpuMillis: "shared", - memoryGbs: "", - expectError: false, - expectedCPU: "shared", - expectedMem: "shared", + name: "CPU shared, memory empty (auto-configure to shared)", + cpuMillis: "shared", + memoryGbs: "", + expectError: false, + expectShared: true, }, { - name: "CPU empty, memory shared (auto-configure to shared)", - cpuMillis: "", - memoryGbs: "shared", - expectError: false, - expectedCPU: "shared", - expectedMem: "shared", + name: "CPU empty, memory shared (auto-configure to shared)", + cpuMillis: "", + memoryGbs: "shared", + expectError: false, + expectShared: true, }, { name: "Invalid combination both set (1 CPU, 8GB)", @@ -63,16 +61,16 @@ func TestValidateAndNormalizeCPUMemory(t *testing.T) { cpuMillis: "2000", memoryGbs: "", expectError: false, - expectedCPU: "2000", - expectedMem: "8", + expectedCPU: 2000, + expectedMem: 8, }, { name: "Memory only auto-configure CPU (16GB -> 4 CPU)", cpuMillis: "", memoryGbs: "16", expectError: false, - expectedCPU: "4000", - expectedMem: "16", + expectedCPU: 4000, + expectedMem: 16, }, { name: "Invalid CPU only", @@ -97,7 +95,7 @@ func TestValidateAndNormalizeCPUMemory(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - cpu, memory, err := ValidateAndNormalizeCPUMemory(tc.cpuMillis, tc.memoryGbs) + config, err := ValidateAndNormalizeCPUMemory(tc.cpuMillis, tc.memoryGbs) if tc.expectError { if err == nil { @@ -112,23 +110,30 @@ func TestValidateAndNormalizeCPUMemory(t *testing.T) { } if tc.expectNil { - if cpu != nil || memory != nil { - t.Errorf("Expected nil pointers, got cpu=%v, memory=%v", cpu, memory) + if config != nil { + t.Errorf("Expected nil config, got %+v", config) } return } - if cpu == nil || memory == nil { - t.Errorf("Expected non-nil pointers, got cpu=%v, memory=%v", cpu, memory) + if config == nil { + t.Errorf("Expected non-nil config, got nil") + return + } + + if tc.expectShared { + if !config.Shared { + t.Errorf("Expected shared config, got %+v", config) + } return } - if *cpu != tc.expectedCPU { - t.Errorf("Expected CPU %s, got %s", tc.expectedCPU, *cpu) + if config.CPUMillis != tc.expectedCPU { + t.Errorf("Expected CPU %d, got %d", tc.expectedCPU, config.CPUMillis) } - if *memory != tc.expectedMem { - t.Errorf("Expected memory %s, got %s", tc.expectedMem, *memory) + if config.MemoryGBs != tc.expectedMem { + t.Errorf("Expected memory %d, got %d", tc.expectedMem, config.MemoryGBs) } }) } diff --git a/internal/tiger/common/wait.go b/internal/tiger/common/wait.go index 9df5c24f..eef1101b 100644 --- a/internal/tiger/common/wait.go +++ b/internal/tiger/common/wait.go @@ -16,6 +16,12 @@ type WaitHandler interface { // to the spinner while waiting for a service to reach some state. Message() string + // InitialCheck returns true if we don't need to begin the waiting/polling + // process, and false if we should. It also returns an error, which is + // either immediately returned from WaitForService or temporarily shown + // next to the spinner depending on the first return value. + InitialCheck() (bool, error) + // Check returns true if we're done waiting/polling, and false if we should // continue. It also returns an error, which is either immediately returned // from WaitForService or temporarily shown next to the spinner depending @@ -37,6 +43,10 @@ func WaitForService(ctx context.Context, args WaitForServiceArgs) error { ctx, cancel := context.WithTimeout(ctx, args.Timeout) defer cancel() + if done, err := args.Handler.InitialCheck(); done { + return err + } + ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -83,6 +93,11 @@ func (h *StatusWaitHandler) Message() string { return fmt.Sprintf("Service status: %s", util.DerefStr(h.Service.Status)) } +func (h *StatusWaitHandler) InitialCheck() (bool, error) { + // Check initial service status + return h.checkServiceStatus(h.Service) +} + func (h *StatusWaitHandler) Check(resp *api.GetProjectsProjectIdServicesServiceIdResponse) (bool, error) { switch resp.StatusCode() { case 200: @@ -93,15 +108,8 @@ func (h *StatusWaitHandler) Check(resp *api.GetProjectsProjectIdServicesServiceI // Update the passed-in service's status, so it's correct when output after waiting. h.Service.Status = resp.JSON200.Status - status := util.DerefStr(resp.JSON200.Status) - switch status { - case h.TargetStatus: - return true, nil - case "FAILED", "ERROR": - return true, fmt.Errorf("service failed with status: %s", status) - default: - return false, nil - } + // Check returned service status + return h.checkServiceStatus(resp.JSON200) case 404: // Can happen if user deletes service while it's still provisioning return true, errors.New("service not found") @@ -114,6 +122,18 @@ func (h *StatusWaitHandler) Check(resp *api.GetProjectsProjectIdServicesServiceI } } +func (h *StatusWaitHandler) checkServiceStatus(service *api.Service) (bool, error) { + status := util.DerefStr(service.Status) + switch status { + case h.TargetStatus: + return true, nil + case "FAILED", "ERROR": + return true, fmt.Errorf("service failed with status: %s", status) + default: + return false, nil + } +} + type DeletionWaitHandler struct { ServiceID string } @@ -122,6 +142,10 @@ func (h *DeletionWaitHandler) Message() string { return fmt.Sprintf("Waiting for service '%s' to be deleted", h.ServiceID) } +func (h *DeletionWaitHandler) InitialCheck() (bool, error) { + return false, nil +} + func (h *DeletionWaitHandler) Check(resp *api.GetProjectsProjectIdServicesServiceIdResponse) (bool, error) { switch resp.StatusCode() { case 200: diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index 94009ae7..512315d8 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "time" "github.com/google/jsonschema-go/jsonschema" @@ -150,10 +151,14 @@ func (s *Server) handleDBExecuteQuery(ctx context.Context, req *mcp.CallToolRequ return nil, DBExecuteQueryOutput{}, fmt.Errorf("failed to get service details: %w", err) } - if serviceResp.StatusCode() != 200 { + if serviceResp.StatusCode() != http.StatusOK { return nil, DBExecuteQueryOutput{}, serviceResp.JSON4XX } + if serviceResp.JSON200 == nil { + return nil, DBExecuteQueryOutput{}, fmt.Errorf("empty response from API") + } + service := *serviceResp.JSON200 // Build connection string with password diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index 223337b4..78587966 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "time" "github.com/google/jsonschema-go/jsonschema" @@ -329,6 +330,38 @@ func (ServiceStopOutput) Schema() *jsonschema.Schema { return util.Must(jsonschema.For[ServiceStopOutput](nil)) } +// ServiceResizeInput represents input for service_resize +type ServiceResizeInput struct { + ServiceID string `json:"service_id"` + CPUMemory string `json:"cpu_memory"` + Wait bool `json:"wait,omitempty"` +} + +func (ServiceResizeInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[ServiceResizeInput](nil)) + setServiceIDSchemaProperties(schema) + + schema.Properties["cpu_memory"].Description = "CPU and memory allocation combination. Choose from the available configurations." + schema.Properties["cpu_memory"].Enum = util.AnySlice(common.GetAllowedResizeCPUMemoryConfigs().Strings()) + + schema.Properties["wait"].Description = "Whether to wait for the service to be done resizing before returning. Default is false (recommended). Only set to true if your next steps require connecting to or querying this database. When true, waits up to 10 minutes." + schema.Properties["wait"].Default = util.Must(json.Marshal(false)) + schema.Properties["wait"].Examples = []any{false, true} + + return schema +} + +// ServiceResizeOutput represents output for service_resize +type ServiceResizeOutput struct { + Status string `json:"status" jsonschema:"Current service status after resize operation"` + Resources *ResourceInfo `json:"resources,omitempty"` + Message string `json:"message"` +} + +func (ServiceResizeOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceResizeOutput](nil)) +} + // registerServiceTools registers service management tools with comprehensive schemas and descriptions func (s *Server) registerServiceTools() { // service_list @@ -451,6 +484,25 @@ This operation stops a service that is currently running. The service will trans Title: "Stop Database Service", }, }, s.handleServiceStop) + + // service_resize + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "service_resize", + Title: "Resize Database Service", + Description: `Resize a database service by changing its CPU and memory allocation. + +This tool changes the compute resources allocated to your database service. The service +may be temporarily unavailable during the resize operation. + +WARNING: Creates billable resource changes. Increasing resources will increase costs.`, + InputSchema: ServiceResizeInput{}.Schema(), + OutputSchema: ServiceResizeOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + DestructiveHint: util.Ptr(false), // Not destructive, just modifies resources + IdempotentHint: true, // Can resize to same size multiple times + Title: "Resize Database Service", + }, + }, s.handleServiceResize) } // handleServiceList handles the service_list MCP tool @@ -473,7 +525,7 @@ func (s *Server) handleServiceList(ctx context.Context, req *mcp.CallToolRequest } // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return nil, ServiceListOutput{}, resp.JSON4XX } @@ -515,10 +567,14 @@ func (s *Server) handleServiceGet(ctx context.Context, req *mcp.CallToolRequest, } // Handle API response - if resp.StatusCode() != 200 { + if resp.StatusCode() != http.StatusOK { return nil, ServiceGetOutput{}, resp.JSON4XX } + if resp.JSON200 == nil { + return nil, ServiceGetOutput{}, fmt.Errorf("empty response from API") + } + output := ServiceGetOutput{ Service: s.convertToServiceDetail(*resp.JSON200, input.WithPassword), } @@ -583,10 +639,14 @@ func (s *Server) handleServiceCreate(ctx context.Context, req *mcp.CallToolReque } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return nil, ServiceCreateOutput{}, resp.JSON4XX } + if resp.JSON202 == nil { + return nil, ServiceCreateOutput{}, fmt.Errorf("empty response from API") + } + service := *resp.JSON202 serviceID := util.Deref(service.ServiceId) @@ -705,10 +765,14 @@ func (s *Server) handleServiceFork(ctx context.Context, req *mcp.CallToolRequest } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return nil, ServiceForkOutput{}, resp.JSON4XX } + if resp.JSON202 == nil { + return nil, ServiceForkOutput{}, fmt.Errorf("empty response from API") + } + service := *resp.JSON202 serviceID := util.Deref(service.ServiceId) @@ -792,14 +856,14 @@ func (s *Server) handleServiceUpdatePassword(ctx context.Context, req *mcp.CallT } // Handle API response - if resp.StatusCode() != 200 && resp.StatusCode() != 204 { + if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return nil, ServiceUpdatePasswordOutput{}, resp.JSON4XX } // Get service details for password storage (similar to CLI implementation) var passwordStorage *common.PasswordStorageResult serviceResp, err := cfg.Client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, cfg.ProjectID, input.ServiceID) - if err == nil && serviceResp.StatusCode() == 200 && serviceResp.JSON200 != nil { + if err == nil && serviceResp.StatusCode() == http.StatusOK && serviceResp.JSON200 != nil { // Save the new password using the shared util function result, err := common.SavePasswordWithResult(api.Service(*serviceResp.JSON200), input.Password, "tsdbadmin") passwordStorage = &result @@ -840,10 +904,14 @@ func (s *Server) handleServiceStart(ctx context.Context, req *mcp.CallToolReques } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return nil, ServiceStartOutput{}, resp.JSON4XX } + if resp.JSON202 == nil { + return nil, ServiceStartOutput{}, fmt.Errorf("empty response from API") + } + service := *resp.JSON202 // If wait is explicitly requested, wait for service to be ready @@ -897,10 +965,14 @@ func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest } // Handle API response - if resp.StatusCode() != 202 { + if resp.StatusCode() != http.StatusAccepted { return nil, ServiceStopOutput{}, resp.JSON4XX } + if resp.JSON202 == nil { + return nil, ServiceStopOutput{}, fmt.Errorf("empty response from API") + } + service := *resp.JSON202 // If wait is explicitly requested, wait for service to be paused @@ -931,3 +1003,80 @@ func (s *Server) handleServiceStop(ctx context.Context, req *mcp.CallToolRequest return nil, output, nil } + +// handleServiceResize handles the service_resize MCP tool +func (s *Server) handleServiceResize(ctx context.Context, req *mcp.CallToolRequest, input ServiceResizeInput) (*mcp.CallToolResult, ServiceResizeOutput, error) { + // Load config and API client + cfg, err := common.LoadConfig(ctx) + if err != nil { + return nil, ServiceResizeOutput{}, err + } + + logging.Debug("MCP: Resizing service", + zap.String("project_id", cfg.ProjectID), + zap.String("service_id", input.ServiceID), + zap.String("cpu_memory", input.CPUMemory), + ) + + // Parse CPU/Memory combination + cpuMillis, memoryGBs, err := common.ParseCPUMemory(input.CPUMemory) + if err != nil { + return nil, ServiceResizeOutput{}, fmt.Errorf("invalid CPU/Memory specification: %w", err) + } + + // Prepare resize request + resizeReq := api.ResizeInput{ + CpuMillis: cpuMillis, + MemoryGbs: memoryGBs, + } + + // Make API call to resize service + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := cfg.Client.PostProjectsProjectIdServicesServiceIdResizeWithResponse(ctx, cfg.ProjectID, input.ServiceID, resizeReq) + if err != nil { + return nil, ServiceResizeOutput{}, fmt.Errorf("failed to resize service: %w", err) + } + + // Handle API response + if resp.StatusCode() != http.StatusAccepted { + return nil, ServiceResizeOutput{}, resp.JSON4XX + } + + if resp.JSON202 == nil { + return nil, ServiceResizeOutput{}, fmt.Errorf("empty response from API") + } + + service := *resp.JSON202 + + // If wait is requested, wait for resize to complete + message := "Resize request accepted. The service may still be resizing." + if input.Wait { + if err := common.WaitForService(ctx, common.WaitForServiceArgs{ + Client: cfg.Client, + ProjectID: cfg.ProjectID, + ServiceID: input.ServiceID, + Handler: &common.StatusWaitHandler{ + TargetStatus: "READY", + Service: &service, + }, + Timeout: waitTimeout, + TimeoutMsg: "service may still be resizing", + }); err != nil { + message = fmt.Sprintf("Error: %s", err.Error()) + } else { + message = "Service resized successfully!" + } + } + + // Return status, resources, and message (after wait so status is accurate) + detail := s.convertToServiceDetail(service, false) + output := ServiceResizeOutput{ + Status: detail.Status, + Resources: detail.Resources, + Message: message, + } + + return nil, output, nil +} diff --git a/openapi.yaml b/openapi.yaml index 7d053998..cef97e1b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,9 +11,7 @@ info: name: Tiger Data Support url: https://www.tigerdata.com/contact servers: - - url: http://localhost:8080 - description: Local development server - - url: https://api.tigerdata.com/public/v1 + - url: https://console.cloud.timescale.com/public/api/v1 description: API server for Tiger Cloud tags: @@ -436,7 +434,7 @@ paths: tags: - Services summary: Resize a Service - description: Changes the CPU and memory allocation for a specific service within a project. + description: Changes the CPU and memory allocation for a specific service within a project. This is an asynchronous operation. parameters: - $ref: '#/components/parameters/ProjectId' - $ref: '#/components/parameters/ServiceId' @@ -449,6 +447,10 @@ paths: responses: '202': description: Resize request has been accepted and is in progress. + content: + application/json: + schema: + $ref: '#/components/schemas/Service' '4XX': $ref: '#/components/responses/ClientError' @@ -1211,17 +1213,13 @@ components: - memory_gbs properties: cpu_millis: - type: integer - description: The new CPU allocation in milli-cores (e.g., 1000 for 1 vCPU). - example: 1000 + type: string + description: The new CPU allocation in milli-cores. + example: "1000" memory_gbs: - type: integer + type: string description: The new memory allocation in gigabytes. - example: 4 - nodes: - type: integer - description: The new number of nodes in the replica set. - example: 2 + example: "4" UpdatePasswordInput: type: object required: diff --git a/specs/spec.md b/specs/spec.md index 4cf2a9bc..1cb18506 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -283,14 +283,20 @@ tiger service delete svc-12345 --confirm --no-wait # Delete service with custom wait timeout tiger service delete svc-12345 --confirm --wait-timeout 15m -# Resize service -tiger service resize svc-12345 --cpu 4 --memory 16GB +# Resize service (specify both CPU and memory) +tiger service resize svc-12345 --cpu 2000 --memory 8 + +# Resize service (specify only CPU, memory auto-configured) +tiger service resize svc-12345 --cpu 2000 + +# Resize service (specify only memory, CPU auto-configured) +tiger service resize svc-12345 --memory 8 # Resize service without waiting -tiger service resize svc-12345 --cpu 4 --memory 16GB --no-wait +tiger service resize svc-12345 --cpu 2000 --memory 8 --no-wait # Resize service with custom timeout -tiger service resize svc-12345 --cpu 4 --memory 16GB --wait-timeout 15m +tiger service resize svc-12345 --cpu 2000 --memory 8 --wait-timeout 15m # Start/stop service tiger service start svc-12345 @@ -372,14 +378,6 @@ tiger service create --name "my-service" --memory 8 # Creates a production service with 4 CPU and 16GB memory tiger service create --name "prod-db" --cpu 4 --memory 16 --environment PROD - -```bash - -# Resize with only CPU -tiger service resize svc-12345 --cpu 4 - -# Resize with only memory -tiger service resize svc-12345 --memory 16 ``` **Note:** A future command like `tiger service list-types` or `tiger service list-configurations` should be added to programmatically discover available service types, CPU/memory configurations, and regions without requiring users to reference documentation. diff --git a/specs/spec_mcp.md b/specs/spec_mcp.md index 193c5b61..0bc1e403 100644 --- a/specs/spec_mcp.md +++ b/specs/spec_mcp.md @@ -23,6 +23,7 @@ For the initial v0 release, implement these essential tools first: - `service_fork` - Fork existing services - `service_start` - Start stopped services - `service_stop` - Stop running services +- `service_resize` - Resize service CPU and memory allocation - `service_delete` - Delete services (with confirmation, 24-hour safe delete) - Maybe not v0 - `service_update_password` - Update service master password @@ -242,14 +243,23 @@ Restart a service. **Returns:** Operation status. #### `service_resize` -Resize service resources. +Resize a database service by changing its CPU and memory allocation. **Parameters:** - `service_id` (string, required): Service ID to resize -- `cpu` (string, optional): New CPU allocation -- `memory` (string, optional): New memory allocation - -**Returns:** Resize operation status. +- `cpu_memory` (string, required): CPU and memory allocation combination. Choose from: + - `"0.5 CPU/2 GB"` - 0.5 CPU cores, 2GB memory + - `"1 CPU/4 GB"` - 1 CPU core, 4GB memory + - `"2 CPU/8 GB"` - 2 CPU cores, 8GB memory + - `"4 CPU/16 GB"` - 4 CPU cores, 16GB memory + - `"8 CPU/32 GB"` - 8 CPU cores, 32GB memory + - `"16 CPU/64 GB"` - 16 CPU cores, 64GB memory + - `"32 CPU/128 GB"` - 32 CPU cores, 128GB memory +- `wait` (boolean, optional): Whether to wait for resize to complete before returning (waits up to 10 minutes). Default is false (recommended) - only set to true if your next steps require connecting to or querying this database. + +**Returns:** Operation status with current service status, updated resources, and message. + +**Note:** The service may be temporarily unavailable during the resize operation. Increasing resources will increase costs. #### `service_enable_pooler` Enable connection pooling for a service.