From dbc5e8ffa60577af13e5632c1953ce27a345620f Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 12:16:34 +0200 Subject: [PATCH 1/6] Add Postgres extension toggle commands --- AGENTS.md | 5 + internal/cmd/branch/extensions.go | 145 +++++++++++++++++++++- internal/cmd/branch/extensions_test.go | 90 ++++++++++++++ internal/planetscale/postgres_branches.go | 1 + 4 files changed, 237 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb27eda2..73b0376e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -550,6 +550,11 @@ 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 +pscale branch extensions enable --org --format json +pscale branch extensions disable --org --format json + +# Toggle commands queue a branch change; track it with resize status. +# They apply only to preloadable, customer-managed extensions, not CREATE EXTENSION. # Default postgres role (read-only; reset-default rotates the password) pscale role default --org --format json diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index 9932100a..f2ec2b8c 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -3,6 +3,7 @@ package branch import ( "encoding/json" "fmt" + "strings" "github.com/planetscale/cli/internal/cmdutil" ps "github.com/planetscale/cli/internal/planetscale" @@ -10,13 +11,13 @@ import ( "github.com/spf13/cobra" ) -// ExtensionsCmd lists extensions available on a Postgres branch's cluster image. +// ExtensionsCmd lists and toggles preloadable 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 change preload libraries through an +asynchronous branch change request.` run := func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -71,11 +72,147 @@ libraries are configured with 'pscale branch resize --parameters'.` 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 { + verb := "enable" + if !enable { + verb = "disable" + } + return &cobra.Command{ + Use: verb + " ", + Short: strings.ToUpper(verb[:1]) + verb[1:] + " a preloadable extension on a Postgres branch", + Args: cmdutil.ExactArgs("database", "branch", "extension"), + RunE: func(cmd *cobra.Command, args []string) error { + database, branch, name := args[0], args[1], args[2] + client, err := ch.Client() + if err != nil { + return err + } + + extensions, err := client.PostgresBranches.ListExtensions(cmd.Context(), &ps.ListPostgresExtensionsRequest{ + Organization: ch.Config.Organization, Database: database, Branch: branch, + }) + if err != nil { + return cmdutil.HandleError(err) + } + var extension *ps.PostgresExtension + for _, candidate := range extensions { + if candidate.Name == name { + extension = candidate + break + } + } + if extension == nil { + return fmt.Errorf("extension %s does not exist on branch %s", name, branch) + } + if extension.Internal || len(extension.Requirements) > 0 || extension.UnavailableReason != "" || + (extension.Loader != "shared_preload_libraries" && extension.Loader != "session_preload_libraries") { + return fmt.Errorf("extension %s cannot be enabled or disabled", name) + } + + includeInternal := false + parameters, err := client.PostgresBranches.ListParameters(cmd.Context(), &ps.ListPostgresParametersRequest{ + Organization: ch.Config.Organization, Database: database, Branch: branch, Internal: &includeInternal, + }) + if err != nil { + return cmdutil.HandleError(err) + } + var loader *ps.PostgresParameter + for _, parameter := range parameters { + if parameter.Namespace == "pgconf" && parameter.Name == extension.Loader { + loader = parameter + break + } + } + if loader == nil { + return fmt.Errorf("extension %s cannot be enabled or disabled", name) + } + current := loader.Value + if current == nil { + current = loader.DefaultValue + } + selection, err := extensionNames(current) + if err != nil { + return fmt.Errorf("cannot read %s: %w", extension.Loader, err) + } + updated := make([]string, 0, len(selection)+1) + found := false + for _, selected := range selection { + if selected == name { + found = true + if !enable { + continue + } + } + updated = append(updated, selected) + } + if enable && !found { + updated = append(updated, name) + } + if found == 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}) + } + + change, err := client.PostgresBranches.Resize(cmd.Context(), &ps.ResizePostgresBranchRequest{ + Organization: ch.Config.Organization, Database: database, Branch: branch, + Parameters: map[string]map[string]string{"pgconf": {extension.Loader: strings.Join(updated, ",")}}, + }) + if err != nil { + return cmdutil.HandleError(err) + } + 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 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)) + return nil + } + return ch.Printer.PrintResource(toPostgresBranchResize(change)) + }, + } +} + +func extensionNames(value any) ([]string, error) { + var names []string + switch value := value.(type) { + case nil: + return nil, nil + case string: + names = strings.Split(value, ",") + case []string: + names = value + case []any: + for _, item := range value { + name, ok := item.(string) + if !ok { + return nil, fmt.Errorf("unexpected library value %T", item) + } + names = append(names, name) + } + default: + return nil, fmt.Errorf("unexpected library list %T", value) + } + selection := make([]string, 0, len(names)) + for _, name := range names { + if name = strings.TrimSpace(name); name != "" { + selection = append(selection, name) + } + } + return selection, nil +} + type postgresExtension struct { Name string `header:"name" json:"name"` Loader string `header:"loader" json:"loader"` diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index dfc3e9d4..ae0bec82 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -87,3 +87,93 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { c.Assert(pgSvc.ListExtensionsFnInvoked, qt.IsTrue) c.Assert(buf.String(), qt.Contains, "vector") } + +func TestBranch_ExtensionsTogglePreservesOtherLibraries(t *testing.T) { + for _, tc := range []struct { + verb string + current any + defaultValue any + expected string + }{ + {verb: "enable", current: "pg_stat_statements, pg_strict", expected: "pg_stat_statements,pg_strict,vector"}, + {verb: "disable", current: "pg_stat_statements, vector, pg_strict", expected: "pg_stat_statements,pg_strict"}, + {verb: "enable", defaultValue: []any{"pg_stat_statements"}, expected: "pg_stat_statements,vector"}, + } { + 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: "vector", Loader: "shared_preload_libraries"}}, nil + }, + ListParametersFn: func(_ context.Context, req *ps.ListPostgresParametersRequest) ([]*ps.PostgresParameter, error) { + c.Assert(req.Internal, qt.IsNotNil) + c.Assert(*req.Internal, qt.IsFalse) + return []*ps.PostgresParameter{{Namespace: "pgconf", Name: "shared_preload_libraries", Value: tc.current, DefaultValue: tc.defaultValue}}, 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.DeepEquals, map[string]map[string]string{"pgconf": {"shared_preload_libraries": tc.expected}}) + return &ps.PostgresBranchClusterResizeRequest{ID: "change-id", State: "queued"}, 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{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_ExtensionsToggleRejectsNonPreloadable(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", Loader: "create_extension"}}, 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{"enable", "postgres-db", "main", "hstore"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, "extension hstore cannot be enabled or disabled") + c.Assert(pgSvc.ListParametersFnInvoked, 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", Loader: "shared_preload_libraries"}}, nil + }, + ListParametersFn: func(_ context.Context, _ *ps.ListPostgresParametersRequest) ([]*ps.PostgresParameter, error) { + return []*ps.PostgresParameter{{Namespace: "pgconf", Name: "shared_preload_libraries", Value: "vector,pg_stat_statements"}}, 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{"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") +} diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go index d2580c1b..c08e4dfe 100644 --- a/internal/planetscale/postgres_branches.go +++ b/internal/planetscale/postgres_branches.go @@ -249,6 +249,7 @@ type PostgresExtension struct { URL string `json:"url"` Available bool `json:"available"` UnavailableReason string `json:"unavailable_reason"` + Requirements map[string]any `json:"requirements"` Parameters []*PostgresParameter `json:"parameters"` } From d502c599f80f0945437547e9b3e841e6626d542c Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 12:35:18 +0200 Subject: [PATCH 2/6] Toggle extensions with can_enable and the extensions set The extensions catalog no longer returns available/unavailable_reason, so use can_enable and enabled from the API and send the enabled set on the branch change request instead of editing preload libraries directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- internal/cmd/branch/extensions.go | 127 +++++------------- internal/cmd/branch/extensions_test.go | 79 +++++------ internal/planetscale/postgres_branches.go | 29 ++-- .../planetscale/postgres_branches_test.go | 6 +- 5 files changed, 96 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73b0376e..f3d103e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,7 +554,7 @@ pscale branch extensions enable --org --fo pscale branch extensions disable --org --format json # Toggle commands queue a branch change; track it with resize status. -# They apply only to preloadable, customer-managed extensions, not CREATE EXTENSION. +# Only extensions with "can_enable": true can be toggled. This is not CREATE EXTENSION. # Default postgres role (read-only; reset-default rotates the password) pscale role default --org --format json diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index f2ec2b8c..234ccfaa 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -11,13 +11,13 @@ import ( "github.com/spf13/cobra" ) -// ExtensionsCmd lists and toggles preloadable extensions on a Postgres branch. +// 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. Enable and disable change preload libraries through an -asynchronous branch change request.` +CREATE EXTENSION. Enable and disable queue an asynchronous branch change +request; only extensions marked "can enable" can be toggled.` run := func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -37,13 +37,7 @@ asynchronous branch change request.` 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() @@ -84,7 +78,7 @@ func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { } return &cobra.Command{ Use: verb + " ", - Short: strings.ToUpper(verb[:1]) + verb[1:] + " a preloadable extension on a Postgres branch", + Short: strings.ToUpper(verb[:1]) + verb[1:] + " an extension on a Postgres branch", Args: cmdutil.ExactArgs("database", "branch", "extension"), RunE: func(cmd *cobra.Command, args []string) error { database, branch, name := args[0], args[1], args[2] @@ -97,76 +91,40 @@ func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { Organization: ch.Config.Organization, Database: database, Branch: branch, }) if err != nil { - return cmdutil.HandleError(err) + return extensionsError(ch, err, database, branch) } var extension *ps.PostgresExtension + selection := []string{} for _, candidate := range extensions { if candidate.Name == name { extension = candidate - break + } else if 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.Internal || len(extension.Requirements) > 0 || extension.UnavailableReason != "" || - (extension.Loader != "shared_preload_libraries" && extension.Loader != "session_preload_libraries") { - return fmt.Errorf("extension %s cannot be enabled or disabled", name) - } - - includeInternal := false - parameters, err := client.PostgresBranches.ListParameters(cmd.Context(), &ps.ListPostgresParametersRequest{ - Organization: ch.Config.Organization, Database: database, Branch: branch, Internal: &includeInternal, - }) - if err != nil { - return cmdutil.HandleError(err) - } - var loader *ps.PostgresParameter - for _, parameter := range parameters { - if parameter.Namespace == "pgconf" && parameter.Name == extension.Loader { - loader = parameter - break - } - } - if loader == nil { + if !extension.CanEnable { return fmt.Errorf("extension %s cannot be enabled or disabled", name) } - current := loader.Value - if current == nil { - current = loader.DefaultValue - } - selection, err := extensionNames(current) - if err != nil { - return fmt.Errorf("cannot read %s: %w", extension.Loader, err) - } - updated := make([]string, 0, len(selection)+1) - found := false - for _, selected := range selection { - if selected == name { - found = true - if !enable { - continue - } - } - updated = append(updated, selected) - } - if enable && !found { - updated = append(updated, name) - } - if found == enable { + 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) + } change, err := client.PostgresBranches.Resize(cmd.Context(), &ps.ResizePostgresBranchRequest{ Organization: ch.Config.Organization, Database: database, Branch: branch, - Parameters: map[string]map[string]string{"pgconf": {extension.Loader: strings.Join(updated, ",")}}, + Extensions: &selection, }) if err != nil { - return cmdutil.HandleError(err) + return extensionsError(ch, err, database, branch) } if change == nil { if ch.Printer.Format() == printer.Human { @@ -184,41 +142,20 @@ func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { } } -func extensionNames(value any) ([]string, error) { - var names []string - switch value := value.(type) { - case nil: - return nil, nil - case string: - names = strings.Split(value, ",") - case []string: - names = value - case []any: - for _, item := range value { - name, ok := item.(string) - if !ok { - return nil, fmt.Errorf("unexpected library value %T", item) - } - names = append(names, name) - } - default: - return nil, fmt.Errorf("unexpected library list %T", value) - } - selection := make([]string, 0, len(names)) - for _, name := range names { - if name = strings.TrimSpace(name); name != "" { - selection = append(selection, name) - } +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 selection, nil + 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 bool `header:"enabled" json:"enabled"` + CanEnable bool `header:"can enable" json:"can_enable"` + Loader string `header:"loader,n/a" json:"loader"` + URL string `header:"url,n/a" json:"url"` orig *ps.PostgresExtension } @@ -227,12 +164,12 @@ func toPostgresExtensions(extensions []*ps.PostgresExtension) []*postgresExtensi out := make([]*postgresExtension, 0, len(extensions)) for _, ext := range extensions { 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: ext.Enabled, + CanEnable: ext.CanEnable, + Loader: ext.Loader, + URL: ext.URL, + orig: ext, }) } return out diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index ae0bec82..077e8737 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -31,8 +31,8 @@ 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", Loader: "shared_preload_libraries", Enabled: true, CanEnable: true, URL: "https://github.com/pgvector/pgvector"}, + {Name: "pg_stat_statements", Loader: "shared_preload_libraries", CanEnable: false}, }, nil }, } @@ -66,7 +66,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 }, } @@ -88,16 +88,14 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { c.Assert(buf.String(), qt.Contains, "vector") } -func TestBranch_ExtensionsTogglePreservesOtherLibraries(t *testing.T) { +func TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { for _, tc := range []struct { - verb string - current any - defaultValue any - expected string + verb string + enabled bool + expected []string }{ - {verb: "enable", current: "pg_stat_statements, pg_strict", expected: "pg_stat_statements,pg_strict,vector"}, - {verb: "disable", current: "pg_stat_statements, vector, pg_strict", expected: "pg_stat_statements,pg_strict"}, - {verb: "enable", defaultValue: []any{"pg_stat_statements"}, expected: "pg_stat_statements,vector"}, + {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) @@ -107,18 +105,20 @@ func TestBranch_ExtensionsTogglePreservesOtherLibraries(t *testing.T) { p.SetResourceOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "vector", Loader: "shared_preload_libraries"}}, nil - }, - ListParametersFn: func(_ context.Context, req *ps.ListPostgresParametersRequest) ([]*ps.PostgresParameter, error) { - c.Assert(req.Internal, qt.IsNotNil) - c.Assert(*req.Internal, qt.IsFalse) - return []*ps.PostgresParameter{{Namespace: "pgconf", Name: "shared_preload_libraries", Value: tc.current, DefaultValue: tc.defaultValue}}, nil + return []*ps.PostgresExtension{ + {Name: "pg_stat_statements", Enabled: true}, + {Name: "vector", Enabled: tc.enabled, CanEnable: true}, + {Name: "pg_strict", Enabled: true, CanEnable: true}, + {Name: "pg_cron", 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.DeepEquals, map[string]map[string]string{"pgconf": {"shared_preload_libraries": tc.expected}}) + 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 }, } @@ -134,24 +134,28 @@ func TestBranch_ExtensionsTogglePreservesOtherLibraries(t *testing.T) { } } -func TestBranch_ExtensionsToggleRejectsNonPreloadable(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", Loader: "create_extension"}}, nil - }, +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", Enabled: verb == "disable"}}, 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{verb, "postgres-db", "main", "hstore"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, "extension hstore cannot be enabled or disabled") + c.Assert(pgSvc.ResizeFnInvoked, qt.IsFalse) + }) } - 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{"enable", "postgres-db", "main", "hstore"}) - c.Assert(cmd.Execute(), qt.ErrorMatches, "extension hstore cannot be enabled or disabled") - c.Assert(pgSvc.ListParametersFnInvoked, qt.IsFalse) } func TestBranch_ExtensionsToggleSkipsAlreadyEnabled(t *testing.T) { @@ -162,10 +166,7 @@ func TestBranch_ExtensionsToggleSkipsAlreadyEnabled(t *testing.T) { p.SetResourceOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "vector", Loader: "shared_preload_libraries"}}, nil - }, - ListParametersFn: func(_ context.Context, _ *ps.ListPostgresParametersRequest) ([]*ps.PostgresParameter, error) { - return []*ps.PostgresParameter{{Namespace: "pgconf", Name: "shared_preload_libraries", Value: "vector,pg_stat_statements"}}, nil + return []*ps.PostgresExtension{{Name: "vector", Enabled: true, CanEnable: true}}, nil }, } ch := &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go index c08e4dfe..4cab71b1 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,16 +244,22 @@ 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"` - Requirements map[string]any `json:"requirements"` - Parameters []*PostgresParameter `json:"parameters"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Internal bool `json:"internal"` + Loader string `json:"loader"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + 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..cd5a0aa2 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,"loader":"shared_preload_libraries","url":"https://github.com/pgvector/pgvector","enabled":true,"can_enable":true,"requirements":{"postgres_image_version":null},"parameters":[]}]` _, err := w.Write([]byte(out)) c.Assert(err, qt.IsNil) })) @@ -613,7 +613,9 @@ func TestPostgresBranches_ListExtensions(t *testing.T) { c.Assert(extensions, qt.HasLen, 1) 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.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") } From 44654f7a0004a433ea16d4428d3d99f2a4aa51dc Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 12:41:51 +0200 Subject: [PATCH 3/6] Remove loader from Postgres extension output --- internal/cmd/branch/extensions.go | 2 -- internal/cmd/branch/extensions_test.go | 9 +++++++-- internal/planetscale/postgres_branches.go | 1 - internal/planetscale/postgres_branches_test.go | 3 +-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index 234ccfaa..76a01474 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -154,7 +154,6 @@ type postgresExtension struct { Name string `header:"name" json:"name"` Enabled bool `header:"enabled" json:"enabled"` CanEnable bool `header:"can enable" json:"can_enable"` - Loader string `header:"loader,n/a" json:"loader"` URL string `header:"url,n/a" json:"url"` orig *ps.PostgresExtension @@ -167,7 +166,6 @@ func toPostgresExtensions(extensions []*ps.PostgresExtension) []*postgresExtensi Name: ext.Name, Enabled: ext.Enabled, CanEnable: ext.CanEnable, - Loader: ext.Loader, URL: ext.URL, orig: ext, }) diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index 077e8737..df2b36be 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -3,6 +3,7 @@ package branch import ( "bytes" "context" + "encoding/json" "testing" qt "github.com/frankban/quicktest" @@ -31,8 +32,8 @@ 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", Enabled: true, CanEnable: true, URL: "https://github.com/pgvector/pgvector"}, - {Name: "pg_stat_statements", Loader: "shared_preload_libraries", CanEnable: false}, + {Name: "vector", Enabled: true, CanEnable: true, URL: "https://github.com/pgvector/pgvector"}, + {Name: "pg_stat_statements", CanEnable: false}, }, nil }, } @@ -53,6 +54,10 @@ 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) } func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go index 4cab71b1..a7547e54 100644 --- a/internal/planetscale/postgres_branches.go +++ b/internal/planetscale/postgres_branches.go @@ -248,7 +248,6 @@ type PostgresExtension struct { Name string `json:"name"` Description string `json:"description"` Internal bool `json:"internal"` - Loader string `json:"loader"` URL string `json:"url"` Enabled bool `json:"enabled"` CanEnable bool `json:"can_enable"` diff --git a/internal/planetscale/postgres_branches_test.go b/internal/planetscale/postgres_branches_test.go index cd5a0aa2..1dd786b6 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","enabled":true,"can_enable":true,"requirements":{"postgres_image_version":null},"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":[]}]` _, err := w.Write([]byte(out)) c.Assert(err, qt.IsNil) })) @@ -612,7 +612,6 @@ func TestPostgresBranches_ListExtensions(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(extensions, qt.HasLen, 1) c.Assert(extensions[0].Name, qt.Equals, "vector") - c.Assert(extensions[0].Loader, qt.Equals, "shared_preload_libraries") c.Assert(extensions[0].Enabled, qt.IsTrue) c.Assert(extensions[0].CanEnable, qt.IsTrue) c.Assert(extensions[0].Requirements.PostgresImageVersion, qt.IsNil) From 631df0b6dfad763fe7d5746ebf826f76a24d4685 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 12:53:52 +0200 Subject: [PATCH 4/6] Warn about restarts and add --wait to extension toggles Toggling an extension can restart the database, so say so in the help, human output, and agent guide. Reject MySQL and point Neki at the config-profile commands, and add --wait like branch resize. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 9 ++- internal/cmd/branch/extensions.go | 62 +++++++++++++++++-- internal/cmd/branch/extensions_test.go | 85 +++++++++++++++++++++++--- 3 files changed, 139 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3d103e3..01e3f322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -550,12 +550,15 @@ 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 -# Toggle commands queue a branch change; track it with resize status. -# Only extensions with "can_enable": true can be toggled. This is not CREATE EXTENSION. - # 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 76a01474..f6b3f2d1 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/planetscale/cli/internal/cmdutil" ps "github.com/planetscale/cli/internal/planetscale" @@ -17,7 +18,8 @@ func ExtensionsCmd(ch *cmdutil.Helper) *cobra.Command { This is the catalog of extensions the image can load, not the result of CREATE EXTENSION. Enable and disable queue an asynchronous branch change -request; only extensions marked "can enable" can be toggled.` +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() @@ -72,22 +74,53 @@ request; only extensions marked "can enable" can be toggled.` } func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { + var flags struct { + wait bool + waitTimeout time.Duration + } + verb := "enable" if !enable { verb = "disable" } - return &cobra.Command{ + cmd := &cobra.Command{ Use: verb + " ", Short: strings.ToUpper(verb[:1]) + verb[1:] + " an extension on a Postgres branch", - Args: cmdutil.ExactArgs("database", "branch", "extension"), + 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 } - extensions, err := client.PostgresBranches.ListExtensions(cmd.Context(), &ps.ListPostgresExtensionsRequest{ + 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 { @@ -119,13 +152,16 @@ func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { selection = append(selection, name) } - change, err := client.PostgresBranches.Resize(cmd.Context(), &ps.ResizePostgresBranchRequest{ + 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)) @@ -133,13 +169,29 @@ func extensionToggleCmd(ch *cmdutil.Helper, enable bool) *cobra.Command { } 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 { diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index df2b36be..bf88f047 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -93,6 +93,17 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { c.Assert(buf.String(), qt.Contains, "vector") } +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 TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { for _, tc := range []struct { verb string @@ -127,9 +138,7 @@ func TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { return &ps.PostgresBranchClusterResizeRequest{ID: "change-id", State: "queued"}, nil }, } - ch := &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { - return &ps.Client{PostgresBranches: pgSvc}, nil - }} + ch := testExtensionsHelper(p, "postgresql", pgSvc) cmd := ExtensionsCmd(ch) cmd.SetArgs([]string{tc.verb, "postgres-db", "main", "vector"}) c.Assert(cmd.Execute(), qt.IsNil) @@ -152,9 +161,7 @@ func TestBranch_ExtensionsToggleRejectsWhenCannotEnable(t *testing.T) { return []*ps.PostgresExtension{{Name: "hstore", Enabled: verb == "disable"}}, nil }, } - ch := &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { - return &ps.Client{PostgresBranches: pgSvc}, 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") @@ -174,12 +181,72 @@ func TestBranch_ExtensionsToggleSkipsAlreadyEnabled(t *testing.T) { return []*ps.PostgresExtension{{Name: "vector", Enabled: true, CanEnable: true}}, nil }, } - ch := &cmdutil.Helper{Printer: p, Config: &config.Config{Organization: "planetscale"}, Client: func() (*ps.Client, error) { - return &ps.Client{PostgresBranches: pgSvc}, 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", 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", 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") +} From 87fdfcade50b599eb1e384982d8c33abb7df26c7 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 13:39:02 +0200 Subject: [PATCH 5/6] Preserve unknown Postgres extension state --- internal/cmd/branch/extensions.go | 18 ++++-- internal/cmd/branch/extensions_test.go | 58 +++++++++++++++---- internal/planetscale/postgres_branches.go | 2 +- .../planetscale/postgres_branches_test.go | 9 ++- 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index f6b3f2d1..a6f17d1a 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -131,7 +131,7 @@ list' can be toggled.`, for _, candidate := range extensions { if candidate.Name == name { extension = candidate - } else if candidate.Enabled { + } else if candidate.Enabled != nil && *candidate.Enabled { selection = append(selection, candidate.Name) } } @@ -141,7 +141,10 @@ list' can be toggled.`, if !extension.CanEnable { return fmt.Errorf("extension %s cannot be enabled or disabled", name) } - if extension.Enabled == enable { + 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 @@ -204,7 +207,7 @@ func extensionsError(ch *cmdutil.Helper, err error, database, branch string) err type postgresExtension struct { Name string `header:"name" json:"name"` - Enabled bool `header:"enabled" json:"enabled"` + Enabled string `header:"enabled" json:"enabled"` CanEnable bool `header:"can enable" json:"can_enable"` URL string `header:"url,n/a" json:"url"` @@ -214,9 +217,16 @@ 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, - Enabled: ext.Enabled, + Enabled: enabled, CanEnable: ext.CanEnable, URL: ext.URL, orig: ext, diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index bf88f047..889a5764 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -32,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", Enabled: true, CanEnable: true, URL: "https://github.com/pgvector/pgvector"}, - {Name: "pg_stat_statements", CanEnable: false}, + {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 }, } @@ -58,6 +60,9 @@ func TestBranch_ExtensionsCmd(t *testing.T) { 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) { @@ -93,6 +98,37 @@ func TestBranch_ExtensionsCmd_ListSubcommand(t *testing.T) { 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) { @@ -104,6 +140,8 @@ func testExtensionsHelper(p *printer.Printer, kind ps.DatabaseEngine, pgSvc *moc }} } +func extensionEnabled(value bool) *bool { return &value } + func TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { for _, tc := range []struct { verb string @@ -122,10 +160,10 @@ func TestBranch_ExtensionsTogglePreservesOtherExtensions(t *testing.T) { pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { return []*ps.PostgresExtension{ - {Name: "pg_stat_statements", Enabled: true}, - {Name: "vector", Enabled: tc.enabled, CanEnable: true}, - {Name: "pg_strict", Enabled: true, CanEnable: true}, - {Name: "pg_cron", CanEnable: true}, + {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) { @@ -158,7 +196,7 @@ func TestBranch_ExtensionsToggleRejectsWhenCannotEnable(t *testing.T) { p.SetResourceOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "hstore", Enabled: verb == "disable"}}, nil + return []*ps.PostgresExtension{{Name: "hstore"}}, nil }, } ch := testExtensionsHelper(p, "postgresql", pgSvc) @@ -178,7 +216,7 @@ func TestBranch_ExtensionsToggleSkipsAlreadyEnabled(t *testing.T) { p.SetResourceOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "vector", Enabled: true, CanEnable: true}}, nil + return []*ps.PostgresExtension{{Name: "vector", Enabled: extensionEnabled(true), CanEnable: true}}, nil }, } ch := testExtensionsHelper(p, "postgresql", pgSvc) @@ -218,7 +256,7 @@ func TestBranch_ExtensionsToggleWait(t *testing.T) { p.SetHumanOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "vector", CanEnable: true}}, nil + 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 @@ -239,7 +277,7 @@ func TestBranch_ExtensionsToggleWarnsAboutRestart(t *testing.T) { p.SetHumanOutput(&buf) pgSvc := &mock.PostgresBranchesService{ ListExtensionsFn: func(_ context.Context, _ *ps.ListPostgresExtensionsRequest) ([]*ps.PostgresExtension, error) { - return []*ps.PostgresExtension{{Name: "vector", CanEnable: true}}, nil + 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 diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go index a7547e54..7815c309 100644 --- a/internal/planetscale/postgres_branches.go +++ b/internal/planetscale/postgres_branches.go @@ -249,7 +249,7 @@ type PostgresExtension struct { Description string `json:"description"` Internal bool `json:"internal"` URL string `json:"url"` - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled,omitempty"` CanEnable bool `json:"can_enable"` Requirements *PostgresExtensionRequirements `json:"requirements"` Parameters []*PostgresParameter `json:"parameters"` diff --git a/internal/planetscale/postgres_branches_test.go b/internal/planetscale/postgres_branches_test.go index 1dd786b6..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,"url":"https://github.com/pgvector/pgvector","enabled":true,"can_enable":true,"requirements":{"postgres_image_version":null},"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,12 +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].Enabled, 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) { From e86bc7a06f0aaffdfc2c3e86132add43d6db0ee1 Mon Sep 17 00:00:00 2001 From: Ayrton Date: Mon, 5 Oct 2026 13:42:21 +0200 Subject: [PATCH 6/6] Catch mistyped extension subcommands and explain busy branches A typo like "extensions enabel db main vector" used to list extensions for a database named "enabel"; the parent command now takes exactly two args. When a branch already has a change in progress, point at resize status instead of auth check. Also list the toggles as Postgres-only in the agent guide. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- internal/cmd/branch/extensions.go | 8 +++++--- internal/cmd/branch/extensions_test.go | 13 +++++++++++++ internal/cmdutil/json_error.go | 9 +++++++++ internal/cmdutil/json_error_test.go | 17 +++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 01e3f322..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): diff --git a/internal/cmd/branch/extensions.go b/internal/cmd/branch/extensions.go index a6f17d1a..f8f1c61e 100644 --- a/internal/cmd/branch/extensions.go +++ b/internal/cmd/branch/extensions.go @@ -56,15 +56,17 @@ extension may restart the database.` 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, } diff --git a/internal/cmd/branch/extensions_test.go b/internal/cmd/branch/extensions_test.go index 889a5764..945fc119 100644 --- a/internal/cmd/branch/extensions_test.go +++ b/internal/cmd/branch/extensions_test.go @@ -288,3 +288,16 @@ func TestBranch_ExtensionsToggleWarnsAboutRestart(t *testing.T) { 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",