diff --git a/AGENTS.md b/AGENTS.md index eb27eda2..2b247edd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ PlanetScale is a serverless database platform for **MySQL** (via Vitess), **Post On Vitess/MySQL, schema changes ship via **deploy requests**: online, non-blocking migrations you review and then deploy. -Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace` (including `keyspace create-external`), `branch vtctld move-tables`, `connect`, `password`. `pscale workflow` is deprecated; use `pscale branch vtctld move-tables` to move tables. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. +Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace` (including `keyspace create-external`), `branch vtctld move-tables`, `connect`, `password`. `pscale workflow` is deprecated; use `pscale branch vtctld move-tables` to move tables. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, `branch extensions enable`/`disable`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. When a database is "weird" (slow, erroring, locked, bloated): @@ -551,6 +551,14 @@ pscale branch parameters list --org --format json --na # Extensions available on the cluster image (not CREATE EXTENSION state) pscale branch extensions list --org --format json +# Toggle an extension with "can_enable": true (not CREATE EXTENSION). This queues a +# branch change that may restart the database; warn the user first. Track it with +# resize status, or pass --wait (default timeout 10m; tune with --wait-timeout). +# Postgres only; Neki uses `branch config-profile extensions enable|disable`. +pscale branch extensions enable --org --format json +pscale branch extensions enable --org --format json --wait +pscale branch extensions disable --org --format json + # Default postgres role (read-only; reset-default rotates the password) pscale role default --org --format json pscale role reset-default --org --format json --force diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index 9932100a..f8f1c61e 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -3,6 +3,8 @@ package branch import ( "encoding/json" "fmt" + "strings" + "time" "github.com/planetscale/cli/internal/cmdutil" ps "github.com/planetscale/cli/internal/planetscale" @@ -10,13 +12,14 @@ import ( "github.com/spf13/cobra" ) -// ExtensionsCmd lists extensions available on a Postgres branch's cluster image. +// ExtensionsCmd lists and toggles extensions on a Postgres branch. func ExtensionsCmd(ch *cmdutil.Helper) *cobra.Command { long := `List extensions available on a Postgres branch's cluster image. This is the catalog of extensions the image can load, not the result of -CREATE EXTENSION. There is no CLI command to enable an extension; preload -libraries are configured with 'pscale branch resize --parameters'.` +CREATE EXTENSION. Enable and disable queue an asynchronous branch change +request; only extensions marked "can enable" can be toggled. Toggling an +extension may restart the database.` run := func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -36,13 +39,7 @@ libraries are configured with 'pscale branch resize --parameters'.` Branch: branch, }) if err != nil { - switch cmdutil.ErrCode(err) { - case ps.ErrNotFound: - return fmt.Errorf("database %s or branch %s does not exist in organization %s", - printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) - default: - return cmdutil.HandleError(err) - } + return extensionsError(ch, err, database, branch) } end() @@ -59,29 +56,162 @@ libraries are configured with 'pscale branch resize --parameters'.` Use: "extensions ", Short: "List extensions available on a Postgres branch", Long: long, - Args: cmdutil.RequiredArgs("database", "branch"), - RunE: run, + // Exact args so a mistyped subcommand (e.g. "enabel db main vector") + // errors instead of listing extensions for a database named "enabel". + Args: cmdutil.ExactArgs("database", "branch"), + RunE: run, } listCmd := &cobra.Command{ Use: "list ", Short: "List extensions available on a Postgres branch", Long: long, - Args: cmdutil.RequiredArgs("database", "branch"), + Args: cmdutil.ExactArgs("database", "branch"), Aliases: []string{"ls"}, RunE: run, } - cmd.AddCommand(listCmd) + cmd.AddCommand(listCmd, extensionToggleCmd(ch, true), extensionToggleCmd(ch, false)) + + return cmd +} + +func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { + var flags struct { + wait bool + waitTimeout time.Duration + } + + verb := "enable" + if !enable { + verb = "disable" + } + cmd := &cobra.Command{ + Use: verb + " ", + Short: strings.ToUpper(verb[:1]) + verb[1:] + " an extension on a Postgres branch", + Long: strings.ToUpper(verb[:1]) + verb[1:] + ` an extension on a Postgres branch. + +This queues an asynchronous branch change request and may restart the +database. Only extensions marked "can enable" in 'pscale branch extensions +list' can be toggled.`, + Args: cmdutil.ExactArgs("database", "branch", "extension"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, name := args[0], args[1], args[2] + client, err := ch.Client() + if err != nil { + return err + } + + db, err := client.Databases.Get(ctx, &ps.GetDatabaseRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("database %s does not exist in organization %s", printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + switch db.Kind { + case ps.DatabaseEnginePostgres: + case ps.DatabaseEngineNeki: + return fmt.Errorf("extensions on Neki databases are set per configuration profile; use %s", printer.BoldBlue("pscale branch config-profile extensions "+verb)) + default: + return fmt.Errorf("extensions are only available for PostgreSQL databases; %s is %s", printer.BoldBlue(database), printer.BoldBlue(string(db.Kind))) + } + + extensions, err := client.PostgresBranches.ListExtensions(ctx, &ps.ListPostgresExtensionsRequest{ + Organization: ch.Config.Organization, Database: database, Branch: branch, + }) + if err != nil { + return extensionsError(ch, err, database, branch) + } + var extension *ps.PostgresExtension + selection := []string{} + for _, candidate := range extensions { + if candidate.Name == name { + extension = candidate + } else if candidate.Enabled != nil && *candidate.Enabled { + selection = append(selection, candidate.Name) + } + } + if extension == nil { + return fmt.Errorf("extension %s does not exist on branch %s", name, branch) + } + if !extension.CanEnable { + return fmt.Errorf("extension %s cannot be enabled or disabled", name) + } + if extension.Enabled == nil { + return fmt.Errorf("enabled state is unavailable for extension %s", name) + } + if *extension.Enabled == enable { + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Extension %s is already %sd on branch %s.\n", printer.BoldBlue(name), verb, printer.BoldBlue(branch)) + return nil + } + return ch.Printer.PrintResource(map[string]string{"result": "no_change", "extension": name, "branch": branch}) + } + if enable { + selection = append(selection, name) + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Requesting to %s extension %s on branch %s...", verb, printer.BoldBlue(name), printer.BoldBlue(branch))) + defer end() + change, err := client.PostgresBranches.Resize(ctx, &ps.ResizePostgresBranchRequest{ + Organization: ch.Config.Organization, Database: database, Branch: branch, + Extensions: &selection, + }) + if err != nil { + return extensionsError(ch, err, database, branch) + } + end() + if change == nil { + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Branch %s already matches the requested configuration.\n", printer.BoldBlue(branch)) + return nil + } + return ch.Printer.PrintResource(map[string]string{"result": "no_change", "extension": name, "branch": branch}) + } + if flags.wait { + change, err = waitForChange(ctx, ch, client, database, branch, change, flags.waitTimeout) + if err != nil { + return err + } + } + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Change to %s extension %s on branch %s %s (state: %s).\n", verb, printer.BoldBlue(name), printer.BoldBlue(branch), changeVerb(change.State), printer.BoldBlue(change.State)) + // Once the change has finished, any restart already happened. + if !change.Finished() { + ch.Printer.Println("Note: this change may restart the database.") + } + return nil + } + return ch.Printer.PrintResource(toPostgresBranchResize(change)) + }, + } + + cmd.Flags().BoolVar(&flags.wait, "wait", false, "Wait for the change request to complete before returning.") + cmd.Flags().DurationVar(&flags.waitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for the change request to complete with --wait.") return cmd } +func extensionsError(ch *cmdutil.Helper, err error, database, branch string) error { + if cmdutil.ErrCode(err) == ps.ErrNotFound { + return fmt.Errorf("database %s or branch %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + } + return cmdutil.HandleError(err) +} + type postgresExtension struct { - Name string `header:"name" json:"name"` - Loader string `header:"loader" json:"loader"` - Available bool `header:"available" json:"available"` - UnavailableReason string `header:"unavailable,n/a" json:"unavailable_reason"` - URL string `header:"url,n/a" json:"url"` + Name string `header:"name" json:"name"` + Enabled string `header:"enabled" json:"enabled"` + CanEnable bool `header:"can enable" json:"can_enable"` + URL string `header:"url,n/a" json:"url"` orig *ps.PostgresExtension } @@ -89,13 +219,19 @@ type postgresExtension struct { func toPostgresExtensions(extensions []*ps.PostgresExtension) []*postgresExtension { out := make([]*postgresExtension, 0, len(extensions)) for _, ext := range extensions { + enabled := "n/a" + if ext.Enabled != nil { + enabled = "No" + if *ext.Enabled { + enabled = "Yes" + } + } out = append(out, &postgresExtension{ - Name: ext.Name, - Loader: ext.Loader, - Available: ext.Available, - UnavailableReason: ext.UnavailableReason, - URL: ext.URL, - orig: ext, + Name: ext.Name, + Enabled: enabled, + CanEnable: ext.CanEnable, + URL: ext.URL, + orig: ext, }) } return out diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index dfc3e9d4..945fc119 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -3,6 +3,8 @@ package branch import ( "bytes" "context" + "encoding/json" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -31,8 +33,9 @@ func TestBranch_ExtensionsCmd(t *testing.T) { c.Assert(req.Database, qt.Equals, db) c.Assert(req.Branch, qt.Equals, branch) return []*ps.PostgresExtension{ - {Name: "vector", Loader: "shared_preload_libraries", Available: true, URL: "https://github.com/pgvector/pgvector"}, - {Name: "pg_stat_statements", Loader: "shared_preload_libraries", Available: false, UnavailableReason: "container_upgrade_required"}, + {Name: "vector", Enabled: extensionEnabled(true), CanEnable: true, URL: "https://github.com/pgvector/pgvector"}, + {Name: "pg_stat_statements", Enabled: extensionEnabled(false), CanEnable: false}, + {Name: "hstore", CanEnable: false}, }, nil }, } @@ -53,6 +56,13 @@ func TestBranch_ExtensionsCmd(t *testing.T) { c.Assert(pgSvc.ListExtensionsFnInvoked, qt.IsTrue) c.Assert(buf.String(), qt.Contains, "vector") c.Assert(buf.String(), qt.Contains, "pg_stat_statements") + var output []map[string]any + c.Assert(json.Unmarshal(buf.Bytes(), &output), qt.IsNil) + _, hasLoader := output[0]["loader"] + c.Assert(hasLoader, qt.IsFalse) + c.Assert(output[1]["enabled"], qt.Equals, false) + _, hasEnabled := output[2]["enabled"] + c.Assert(hasEnabled, qt.IsFalse) } func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { @@ -66,7 +76,7 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(ctx context.Context, req *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { return []*ps.PostgresExtension{ - {Name: "vector", Available: true}, + {Name: "vector", CanEnable: true}, }, nil }, } @@ -87,3 +97,207 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { c.Assert(pgSvc.ListExtensionsFnInvoked, qt.IsTrue) c.Assert(buf.String(), qt.Contains, "vector") } + +func TestBranch_ExtensionsCmd_CreateExtensionHasNoEnabledState(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + p.SetResourceOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{ + {Name: "pg_stat_statements", Enabled: extensionEnabled(false), URL: "https://example.com/statements"}, + {Name: "hstore", URL: "https://example.com/hstore"}, + }, nil + }, + } + ch := &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { + return &ps.Client{PostgresBranches: pgSvc}, nil + }} + cmd := ExtensionsCmd(ch) + cmd.SetArgs([]string{"list", "postgres-db", "main"}) + c.Assert(cmd.Execute(), qt.IsNil) + for _, line := range strings.Split(buf.String(), "\n") { + fields := strings.Fields(line) + if len(fields) > 1 && fields[0] == "hstore" { + c.Assert(fields[1], qt.Equals, "n/a") + return + } + } + t.Fatalf("hstore row missing from output: %s", buf.String()) +} + +func testExtensionsHelper(p *printer.Printer, kind ps.DatabaseEngine, pgSvc *mock.PostgresBranchesService) *cmdutil.Helper { + dbSvc := &mock.DatabaseService{ + GetFn: func(_ context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) { + return &ps.Database{Name: req.Database, Kind: kind}, nil + }, + } + return &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { + return &ps.Client{Databases: dbSvc, PostgresBranches: pgSvc}, nil + }} +} + +func extensionEnabled(value bool) *bool { return &value } + +func TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { + for _, tc := range []struct { + verb string + enabled bool + expected []string + }{ + {verb: "enable", expected: []string{"pg_stat_statements", "pg_strict", "vector"}}, + {verb: "disable", enabled: true, expected: []string{"pg_stat_statements", "pg_strict"}}, + } { + t.Run(tc.verb, func(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{ + {Name: "pg_stat_statements", Enabled: extensionEnabled(true)}, + {Name: "vector", Enabled: extensionEnabled(tc.enabled), CanEnable: true}, + {Name: "pg_strict", Enabled: extensionEnabled(true), CanEnable: true}, + {Name: "pg_cron", Enabled: extensionEnabled(false), CanEnable: true}, + }, nil + }, + ResizeFn: func(_ context.Context, req *ps.ResizePostgresBranchRequest) (*ps.PostgresBranchClusterResizeRequest, error) { + c.Assert(req.Organization, qt.Equals, "planetscale") + c.Assert(req.Database, qt.Equals, "postgres-db") + c.Assert(req.Branch, qt.Equals, "main") + c.Assert(req.Parameters, qt.IsNil) + c.Assert(req.Extensions, qt.IsNotNil) + c.Assert(*req.Extensions, qt.DeepEquals, tc.expected) + return &ps.PostgresBranchClusterResizeRequest{ID: "change-id", State: "queued"}, nil + }, + } + ch := testExtensionsHelper(p, "postgresql", pgSvc) + cmd := ExtensionsCmd(ch) + cmd.SetArgs([]string{tc.verb, "postgres-db", "main", "vector"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(pgSvc.ResizeFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "change-id") + }) + } +} + +func TestBranch_ExtensionsToggleRejectsWhenCannotEnable(t *testing.T) { + for _, verb := range []string{"enable", "disable"} { + t.Run(verb, func(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{{Name: "hstore"}}, nil + }, + } + ch := testExtensionsHelper(p, "postgresql", pgSvc) + cmd := ExtensionsCmd(ch) + cmd.SetArgs([]string{verb, "postgres-db", "main", "hstore"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, "extension hstore cannot be enabled or disabled") + c.Assert(pgSvc.ResizeFnInvoked, qt.IsFalse) + }) + } +} + +func TestBranch_ExtensionsToggleSkipsAlreadyEnabled(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{{Name: "vector", Enabled: extensionEnabled(true), CanEnable: true}}, nil + }, + } + ch := testExtensionsHelper(p, "postgresql", pgSvc) + cmd := ExtensionsCmd(ch) + cmd.SetArgs([]string{"enable", "postgres-db", "main", "vector"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(pgSvc.ResizeFnInvoked, qt.IsFalse) + c.Assert(buf.String(), qt.Contains, "no_change") +} + +func TestBranch_ExtensionsToggleRejectsOtherEngines(t *testing.T) { + for _, tc := range []struct { + kind ps.DatabaseEngine + err string + }{ + {kind: "mysql", err: "extensions are only available for PostgreSQL databases.*"}, + {kind: "neki", err: "extensions on Neki databases are set per configuration profile.*config-profile extensions enable.*"}, + } { + t.Run(string(tc.kind), func(t *testing.T) { + c := qt.New(t) + format := printer.JSON + p := printer.NewPrinter(&format) + pgSvc := &mock.PostgresBranchesService{} + cmd := ExtensionsCmd(testExtensionsHelper(p, tc.kind, pgSvc)) + cmd.SetArgs([]string{"enable", "db", "main", "vector"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, tc.err) + c.Assert(pgSvc.ListExtensionsFnInvoked, qt.IsFalse) + }) + } +} + +func TestBranch_ExtensionsToggleWait(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{{Name: "vector", Enabled: extensionEnabled(false), CanEnable: true}}, nil + }, + ResizeFn: func(_ context.Context, _ *ps.ResizePostgresBranchRequest) (*ps.PostgresBranchClusterResizeRequest, error) { + return &ps.PostgresBranchClusterResizeRequest{ID: "change-id", State: "completed"}, nil + }, + } + cmd := ExtensionsCmd(testExtensionsHelper(p, "postgresql", pgSvc)) + cmd.SetArgs([]string{"enable", "postgres-db", "main", "vector", "--wait"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(buf.String(), qt.Contains, "completed") + c.Assert(buf.String(), qt.Not(qt.Contains), "may restart") +} + +func TestBranch_ExtensionsToggleWarnsAboutRestart(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + pgSvc := &mock.PostgresBranchesService{ + ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { + return []*ps.PostgresExtension{{Name: "vector", Enabled: extensionEnabled(false), CanEnable: true}}, nil + }, + ResizeFn: func(_ context.Context, _ *ps.ResizePostgresBranchRequest) (*ps.PostgresBranchClusterResizeRequest, error) { + return &ps.PostgresBranchClusterResizeRequest{ID: "change-id", State: "queued"}, nil + }, + } + cmd := ExtensionsCmd(testExtensionsHelper(p, "postgresql", pgSvc)) + cmd.SetArgs([]string{"enable", "postgres-db", "main", "vector"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(buf.String(), qt.Contains, "may restart the database") +} + +func TestBranch_ExtensionsCmd_RejectsMistypedSubcommand(t *testing.T) { + c := qt.New(t) + format := printer.JSON + p := printer.NewPrinter(&format) + pgSvc := &mock.PostgresBranchesService{} + cmd := ExtensionsCmd(testExtensionsHelper(p, "postgresql", pgSvc)) + cmd.SetArgs([]string{"enabel", "postgres-db", "main", "vector"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + c.Assert(cmd.Execute(), qt.ErrorMatches, "accepts 2 arg\\(s\\), received 4") + c.Assert(pgSvc.ListExtensionsFnInvoked, qt.IsFalse) +} diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index 726186e8..055689b4 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -144,6 +144,15 @@ func GlobalJSONError(err error) JSONErrorResponse { "Re-run the same command with --force after approval", } + // A Postgres branch takes one change request at a time; the fix is to + // wait for the current one, not to re-authenticate. + case strings.Contains(lower, "resize in progress"): + status = "action_required" + nextSteps = []string{ + "Wait for the current change to finish, then retry", + "pscale branch resize status --org --format json", + } + // Resource lookups that failed: discovery commands are the way forward. case strings.Contains(msg, "does not exist"): code = "NOT_FOUND" diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index 59fb5196..f2069285 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "strings" "testing" "github.com/planetscale/cli/internal/planetscale" @@ -189,6 +190,22 @@ func TestGlobalJSONErrorSchemaSnapshotNotReady(t *testing.T) { } } +func TestGlobalJSONErrorResizeInProgress(t *testing.T) { + resp := GlobalJSONError(errors.New("Database branch has a cluster resize in progress")) + + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if len(resp.NextSteps) != 2 || !strings.Contains(resp.NextSteps[1], "pscale branch resize status") { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } + for _, step := range resp.NextSteps { + if step == AgentAuthCheckCmd() || step == AgentAuthLoginCmd() { + t.Fatalf("resize in progress should not suggest auth, got %#v", resp.NextSteps) + } + } +} + func TestGlobalJSONErrorPreservesOtherAPICodes(t *testing.T) { resp := GlobalJSONError(&planetscale.Error{ APICode: "unprocessable", diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go index d2580c1b..7815c309 100644 --- a/internal/planetscale/postgres_branches.go +++ b/internal/planetscale/postgres_branches.go @@ -117,6 +117,9 @@ type ResizePostgresBranchRequest struct { // e.g. {"pgconf": {"max_connections": "200"}}. Use the values returned by // ListParameters. Nil leaves parameters unchanged. Parameters map[string]map[string]string `json:"parameters,omitempty"` + // Extensions replaces the set of enabled extensions. Nil leaves them + // unchanged. + Extensions *[]string `json:"extensions,omitempty"` } // PostgresBranchClusterResizeRequest represents an asynchronous Postgres branch @@ -241,15 +244,21 @@ type ListPostgresExtensionsRequest struct { // PostgresExtension is an extension defined on the branch's cluster image. // This is the catalog of what the image can load, not CREATE EXTENSION state. type PostgresExtension struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - Internal bool `json:"internal"` - Loader string `json:"loader"` - URL string `json:"url"` - Available bool `json:"available"` - UnavailableReason string `json:"unavailable_reason"` - Parameters []*PostgresParameter `json:"parameters"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Internal bool `json:"internal"` + URL string `json:"url"` + Enabled *bool `json:"enabled,omitempty"` + CanEnable bool `json:"can_enable"` + Requirements *PostgresExtensionRequirements `json:"requirements"` + Parameters []*PostgresParameter `json:"parameters"` +} + +// PostgresExtensionRequirements describes what an extension needs before it +// can be enabled. +type PostgresExtensionRequirements struct { + PostgresImageVersion *string `json:"postgres_image_version"` } // PostgresBranchSchemaRequest encapsulates the request to get the schema of a Postgres branch. diff --git a/internal/planetscale/postgres_branches_test.go b/internal/planetscale/postgres_branches_test.go index ee578501..ba2780ff 100644 --- a/internal/planetscale/postgres_branches_test.go +++ b/internal/planetscale/postgres_branches_test.go @@ -595,7 +595,7 @@ func TestPostgresBranches_ListExtensions(t *testing.T) { c.Assert(r.Method, qt.Equals, http.MethodGet) c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/postgres-test-db/branches/postgres-test-branch/extensions") w.WriteHeader(200) - out := `[{"type":"PostgresClusterExtension","name":"vector","description":"

vector

","internal":false,"loader":"shared_preload_libraries","url":"https://github.com/pgvector/pgvector","available":true,"unavailable_reason":"","parameters":[]}]` + out := `[{"type":"PostgresClusterExtension","name":"vector","description":"

vector

","internal":false,"url":"https://github.com/pgvector/pgvector","enabled":true,"can_enable":true,"requirements":{"postgres_image_version":null},"parameters":[]},{"type":"PostgresClusterExtension","name":"hstore","can_enable":false,"parameters":[]}]` _, err := w.Write([]byte(out)) c.Assert(err, qt.IsNil) })) @@ -610,11 +610,15 @@ func TestPostgresBranches_ListExtensions(t *testing.T) { }) c.Assert(err, qt.IsNil) - c.Assert(extensions, qt.HasLen, 1) + c.Assert(extensions, qt.HasLen, 2) c.Assert(extensions[0].Name, qt.Equals, "vector") - c.Assert(extensions[0].Loader, qt.Equals, "shared_preload_libraries") - c.Assert(extensions[0].Available, qt.IsTrue) + c.Assert(extensions[0].Enabled, qt.IsNotNil) + c.Assert(*extensions[0].Enabled, qt.IsTrue) + c.Assert(extensions[0].CanEnable, qt.IsTrue) + c.Assert(extensions[0].Requirements.PostgresImageVersion, qt.IsNil) c.Assert(extensions[0].URL, qt.Equals, "https://github.com/pgvector/pgvector") + c.Assert(extensions[1].Name, qt.Equals, "hstore") + c.Assert(extensions[1].Enabled, qt.IsNil) } func TestPostgresBranches_ListParametersWithFilters(t *testing.T) {