From 2f64387477ef278b8737d98737ef9ef872dcddaf Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Thu, 25 Sep 2025 15:11:26 -0500 Subject: [PATCH 1/2] feat: add a --env flag to service create to write pg env vars to file --- internal/tiger/cmd/service.go | 102 +++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 6b0d8929..9be456b9 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "time" @@ -214,6 +215,76 @@ func buildServiceListCmd() *cobra.Command { return cmd } +// writeEnvFile writes PostgreSQL connection environment variables to the specified file +func writeEnvFile(client *api.ClientWithResponses, projectID, serviceID, password, envFilePath string) error { + // Get the current service details to ensure we have endpoint information + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service details: %w", err) + } + + if resp.StatusCode() != 200 || resp.JSON200 == nil { + return fmt.Errorf("could not retrieve service details") + } + + service := *resp.JSON200 + + // Extract connection details from service + var host, port string = "", "" + + // Use connection pooler endpoint if available, fallback to direct endpoint + if service.ConnectionPooler != nil && service.ConnectionPooler.Endpoint != nil && service.ConnectionPooler.Endpoint.Host != nil { + host = *service.ConnectionPooler.Endpoint.Host + if service.ConnectionPooler.Endpoint.Port != nil { + port = fmt.Sprintf("%d", *service.ConnectionPooler.Endpoint.Port) + } else { + return fmt.Errorf("service connection pooler port is not available") + } + } else if service.Endpoint != nil && service.Endpoint.Host != nil { + host = *service.Endpoint.Host + if service.Endpoint.Port != nil { + port = fmt.Sprintf("%d", *service.Endpoint.Port) + } else { + return fmt.Errorf("service endpoint port is not available") + } + } + + // Validate that we have the required connection details + if host == "" { + return fmt.Errorf("service host is not available") + } + if port == "" { + return fmt.Errorf("service port is not available") + } + + // Create environment variables content + envContent := fmt.Sprintf(` +PGHOST=%s +PGDATABASE=tsdb +PGPORT=%s +PGUSER=tsdbadmin +PGPASSWORD=%s +PGSSLMODE=require +`, host, port, password) + + // Open file for appending, create if doesn't exist + file, err := os.OpenFile(envFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open env file '%s': %w", envFilePath, err) + } + defer file.Close() + + // Write environment variables + if _, err := file.WriteString(envContent); err != nil { + return fmt.Errorf("failed to write to env file '%s': %w", envFilePath, err) + } + + return nil +} + // serviceCreateCmd represents the create command under service func buildServiceCreateCmd() *cobra.Command { var createServiceName string @@ -225,19 +296,20 @@ func buildServiceCreateCmd() *cobra.Command { var createNoWait bool var createWaitTimeout time.Duration var createNoSetDefault bool + var createEnvFile string cmd := &cobra.Command{ Use: "create", Short: "Create a new database service", Long: `Create a new database service in the current project. -By default, the newly created service will be set as your default service for future +By default, the newly created service will be set as your default service for future commands. Use --no-set-default to prevent this behavior. Examples: # Create a TimescaleDB service with all defaults (0.5 CPU, 2GB, us-east-1, auto-generated name) tiger service create - + # Create a TimescaleDB service with custom name tiger service create --name my-db @@ -262,6 +334,12 @@ Examples: # Create service with custom wait timeout tiger service create --name patient-db --type timescaledb --cpu 2000 --memory 8 --replicas 2 --wait-timeout 1h + # Create service and write connection variables to .env file + tiger service create --name my-db --env .env + + # Create service and write connection variables to custom file + tiger service create --name my-db --env .env.dev + 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 @@ -390,13 +468,30 @@ Note: You can specify both CPU and memory together, or specify only one (the oth // Handle wait behavior if createNoWait { + // Check if env file is requested + if cmd.Flags().Changed("env") { + fmt.Fprintf(cmd.OutOrStdout(), "⚠️ Warning: Cannot write .env file with --no-wait since service endpoints are not available until service is ready.\n") + } fmt.Fprintf(cmd.OutOrStdout(), "⏳ Service is being created. Use 'tiger service list' to check status.\n") return nil } // Wait for service to be ready fmt.Fprintf(cmd.OutOrStdout(), "⏳ Waiting for service to be ready (wait timeout: %v)...\n", createWaitTimeout) - return waitForServiceReady(client, projectID, serviceID, createWaitTimeout, cmd) + if err := waitForServiceReady(client, projectID, serviceID, createWaitTimeout, cmd); err != nil { + return err + } + + // Write env file if requested, after service is ready + if cmd.Flags().Changed("env") { + if err := writeEnvFile(client, projectID, serviceID, initialPassword, createEnvFile); err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "⚠️ Warning: Failed to write .env file: %v\n", err) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "📄 PostgreSQL connection variables written to %s\n", createEnvFile) + } + } + + return nil case 400: return fmt.Errorf("invalid request parameters") @@ -422,6 +517,7 @@ Note: You can specify both CPU and memory together, or specify only one (the oth cmd.Flags().BoolVar(&createNoWait, "no-wait", false, "Don't wait for operation to complete") cmd.Flags().DurationVar(&createWaitTimeout, "wait-timeout", 30*time.Minute, "Wait timeout duration (e.g., 30m, 1h30m, 90s)") cmd.Flags().BoolVar(&createNoSetDefault, "no-set-default", false, "Don't set this service as the default service") + cmd.Flags().StringVar(&createEnvFile, "env", "", "Path to .env file to write connection variables") return cmd } From 62dd6e61f00e3f6416853649816ec41bfccde562 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Thu, 25 Sep 2025 15:21:02 -0500 Subject: [PATCH 2/2] test: add tests for --env file on service create command --- internal/tiger/cmd/service_test.go | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/internal/tiger/cmd/service_test.go b/internal/tiger/cmd/service_test.go index ee7340b7..5dc1504f 100644 --- a/internal/tiger/cmd/service_test.go +++ b/internal/tiger/cmd/service_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -1352,3 +1353,144 @@ func TestServiceCreate_NoSetDefaultFlag(t *testing.T) { t.Error("Expected help text to mention default service behavior") } } + +func TestServiceCreate_EnvFlag(t *testing.T) { + // Test that the --env flag is recognized and doesn't cause parsing errors + output, err, _ := executeServiceCommand("service", "create", "--help") + if err != nil { + t.Fatalf("Help command should not fail: %v", err) + } + + // Verify the flag appears in help output + if !strings.Contains(output, "--env string") { + t.Error("Expected --env flag to appear in help output") + } + + // Verify the flag description + if !strings.Contains(output, "Path to .env file to write connection variables") { + t.Error("Expected --env flag description to appear in help output") + } + + // Verify examples show env flag usage + if !strings.Contains(output, "tiger service create --name my-db --env .env") { + t.Error("Expected help text to show .env example") + } + if !strings.Contains(output, "tiger service create --name my-db --env .env.dev") { + t.Error("Expected help text to show custom env file example") + } +} + +func TestServiceCreate_EnvFlagRequiresArgument(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config with project ID + cfg := &config.Config{ + APIURL: "https://api.tigerdata.com/public/v1", + ProjectID: "test-project-123", + ConfigDir: tmpDir, + } + err := cfg.Save() + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetAPIKey := getAPIKeyForService + getAPIKeyForService = func() (string, error) { + return "test-api-key", nil + } + defer func() { getAPIKeyForService = originalGetAPIKey }() + + // Execute service create command with --env flag but no argument + _, err, _ = executeServiceCommand("service", "create", "--name", "test-service", "--env") + if err == nil { + t.Fatal("Expected error when --env flag is used without argument") + } + + if !strings.Contains(err.Error(), "flag needs an argument: --env") { + t.Errorf("Expected error about missing argument for --env flag, got: %v", err) + } +} + +func TestServiceCreate_EnvFlagWithNoWait(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config with project ID + cfg := &config.Config{ + APIURL: "http://localhost:9999", // Mock URL to prevent actual API calls + ProjectID: "test-project-123", + ConfigDir: tmpDir, + } + err := cfg.Save() + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Mock authentication + originalGetAPIKey := getAPIKeyForService + getAPIKeyForService = func() (string, error) { + return "test-api-key", nil + } + defer func() { getAPIKeyForService = originalGetAPIKey }() + + // Execute service create command with --env and --no-wait flags + output, err, _ := executeServiceCommand("service", "create", + "--name", "test-service", + "--type", "postgres", + "--region", "us-east-1", + "--env", ".env", + "--no-wait") + + // Should fail due to network error (expected in tests) + if err == nil { + t.Fatal("Expected network error due to mock URL") + } + + // The test mainly verifies that using --env with --no-wait doesn't cause flag parsing errors + // The actual warning would only appear if service creation succeeded, which won't happen with mock URL + if strings.Contains(err.Error(), "flag") || strings.Contains(err.Error(), "env") { + // Only fail if it's a flag parsing error, not network error + if strings.Contains(err.Error(), "flag needs an argument") { + t.Errorf("Flag parsing should work correctly with --env and --no-wait, got: %v", err) + } + } + + // Should contain the service creation attempt message + if !strings.Contains(output, "Creating service 'test-service'") { + t.Errorf("Expected service creation attempt message, got output: %s", output) + } +} + +func TestWriteEnvFile_MissingServiceDetails(t *testing.T) { + tmpDir := setupServiceTest(t) + + // Set up config + cfg := &config.Config{ + APIURL: "http://localhost:9999", // Non-existent server + ProjectID: "test-project-123", + ConfigDir: tmpDir, + } + err := cfg.Save() + if err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + // Create API client + client, err := api.NewTigerClient("test-api-key") + if err != nil { + t.Fatalf("Failed to create API client: %v", err) + } + + // Test writeEnvFile with non-existent service + envPath := filepath.Join(tmpDir, "test.env") + err = writeEnvFile(client, "test-project-123", "nonexistent-service", "test-password", envPath) + + // Should fail because service doesn't exist + if err == nil { + t.Fatal("Expected error when service doesn't exist") + } + + if !strings.Contains(err.Error(), "failed to get service details") { + t.Errorf("Expected error about failed service details retrieval, got: %v", err) + } +}