Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,8 +786,10 @@ pscale branch config-profile default <database> <branch> --org <org> --format js

# Create or update a profile
pscale branch config-profile create <database> <branch> <profile> --org <org> --cluster-size <size> --replicas 2 --format json
pscale branch config-profile create <database> <branch> <profile> --org <org> --read-only-replicas 2 --format json
pscale branch config-profile create <database> <branch> <profile> --org <org> --min-storage 10737418240 --max-storage 107374182400 --storage-autoscaling --format json
pscale branch config-profile update <database> <branch> <profile> --org <org> --cluster-size <size> --replicas 2 --format json
pscale branch config-profile update <database> <branch> <profile> --org <org> --read-only-replicas 0 --format json
pscale branch config-profile update <database> <branch> <profile> --org <org> --parameters pgconf.max_connections=200 --format json
pscale branch config-profile update <database> <branch> <profile> --org <org> --extensions=hll --parameters pgconf.hll.force_groupagg=on --format json
pscale branch config-profile update <database> <branch> <profile> --org <org> --extensions= --format json
Expand All @@ -814,6 +816,7 @@ pscale branch config-profile delete <database> <branch> <profile> --org <org> --
```

- Create and update flags are optional in the API request unless explicitly supplied. Repeat `--parameters namespace.name=value` to update multiple settings together.
- `--read-only-replicas` sets the total number of read-only replicas per shard in the profile (0-8, excluding the primary and separate from `--replicas`). Creation defaults to zero; omit it on update to leave the count unchanged, or pass `--read-only-replicas 0` to remove them. Profile output includes `read_only_replicas`; change JSON includes that count and `previous_read_only_replicas`.
- `--extensions` replaces the enabled extension set. Omit it to leave extensions unchanged, or pass `--extensions=` to disable all customer-managed extensions. Do not combine it with preload library parameters.
- Storage flags are `--min-storage` and `--max-storage` in bytes, `--storage-autoscaling`, `--storage-iops`, and `--storage-throughput` (MiB/s). `list` and `show` include the current storage configuration.
- `parameters` accepts `--namespace`, `--extension`, and `--internal` filters.
Expand Down
92 changes: 52 additions & 40 deletions internal/cmd/configprofile/config_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,39 +26,41 @@ func ConfigProfileCmd(ch *cmdutil.Helper) *cobra.Command {
}

type profileDisplay struct {
Name string `header:"name" json:"name"`
State string `header:"state" json:"state"`
Default bool `header:"default" json:"default"`
Architecture string `header:"architecture" json:"architecture"`
ClusterSize string `header:"cluster size" json:"cluster_size"`
Replicas int `header:"replicas" json:"replicas"`
Shards int `header:"shards" json:"shards"`
PostgresVersion string `header:"postgres version" json:"postgres_version"`
MinStorage string `header:"min storage" json:"min_storage"`
MaxStorage string `header:"max storage" json:"max_storage"`
Autoscaling string `header:"storage autoscaling" json:"storage_autoscaling"`
CreatedAt string `header:"created at" json:"created_at"`
orig *ps.NekiShardConfigurationProfile
Name string `header:"name" json:"name"`
State string `header:"state" json:"state"`
Default bool `header:"default" json:"default"`
Architecture string `header:"architecture" json:"architecture"`
ClusterSize string `header:"cluster size" json:"cluster_size"`
Replicas int `header:"replicas" json:"replicas"`
ReadOnlyReplicas int `header:"read-only replicas" json:"read_only_replicas"`
Shards int `header:"shards" json:"shards"`
PostgresVersion string `header:"postgres version" json:"postgres_version"`
MinStorage string `header:"min storage" json:"min_storage"`
MaxStorage string `header:"max storage" json:"max_storage"`
Autoscaling string `header:"storage autoscaling" json:"storage_autoscaling"`
CreatedAt string `header:"created at" json:"created_at"`
orig *ps.NekiShardConfigurationProfile
}

func (p *profileDisplay) MarshalJSON() ([]byte, error) { return json.Marshal(p.orig) }
func (p *profileDisplay) MarshalCSVValue() interface{} { return []*profileDisplay{p} }

func toProfile(p *ps.NekiShardConfigurationProfile) *profileDisplay {
return &profileDisplay{
Name: p.Name,
State: p.State,
Default: p.Default,
Architecture: p.Architecture,
ClusterSize: p.ClusterSize,
Replicas: p.Replicas,
Shards: p.Shards,
PostgresVersion: fmt.Sprintf("%d.%d", p.PostgresMajorVersion, p.PostgresMinorVersion),
MinStorage: storageBytes(p.Storage, func(s *ps.NekiStorage) *int64 { return s.MinimumStorageBytes }),
MaxStorage: storageBytes(p.Storage, func(s *ps.NekiStorage) *int64 { return s.MaximumStorageBytes }),
Autoscaling: storageAutoscaling(p.Storage),
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
orig: p,
Name: p.Name,
State: p.State,
Default: p.Default,
Architecture: p.Architecture,
ClusterSize: p.ClusterSize,
Replicas: p.Replicas,
ReadOnlyReplicas: p.ReadOnlyReplicas,
Shards: p.Shards,
PostgresVersion: fmt.Sprintf("%d.%d", p.PostgresMajorVersion, p.PostgresMinorVersion),
MinStorage: storageBytes(p.Storage, func(s *ps.NekiStorage) *int64 { return s.MinimumStorageBytes }),
MaxStorage: storageBytes(p.Storage, func(s *ps.NekiStorage) *int64 { return s.MaximumStorageBytes }),
Autoscaling: storageAutoscaling(p.Storage),
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
orig: p,
}
}

Expand Down Expand Up @@ -145,27 +147,29 @@ func toExtensions(extensions []*ps.NekiExtension) []*extensionDisplay {
}

type changeDisplay struct {
ID string `header:"id" json:"id"`
State string `header:"state" json:"state"`
Name string `header:"name" json:"name"`
ClusterSize string `header:"cluster size" json:"cluster_size"`
Replicas int `header:"replicas" json:"replicas"`
CreatedAt string `header:"created at" json:"created_at"`
orig *ps.NekiShardConfigurationProfileChange
ID string `header:"id" json:"id"`
State string `header:"state" json:"state"`
Name string `header:"name" json:"name"`
ClusterSize string `header:"cluster size" json:"cluster_size"`
Replicas int `header:"replicas" json:"replicas"`
ReadOnlyReplicas int `header:"read-only replicas" json:"read_only_replicas"`
CreatedAt string `header:"created at" json:"created_at"`
orig *ps.NekiShardConfigurationProfileChange
}

func (c *changeDisplay) MarshalJSON() ([]byte, error) { return json.Marshal(c.orig) }
func (c *changeDisplay) MarshalCSVValue() interface{} { return []*changeDisplay{c} }

func toChange(c *ps.NekiShardConfigurationProfileChange) *changeDisplay {
return &changeDisplay{
ID: c.ID,
State: c.State,
Name: c.Name,
ClusterSize: c.ClusterSize,
Replicas: c.Replicas,
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
orig: c,
ID: c.ID,
State: c.State,
Name: c.Name,
ClusterSize: c.ClusterSize,
Replicas: c.Replicas,
ReadOnlyReplicas: c.ReadOnlyReplicas,
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02 15:04:05"),
orig: c,
}
}

Expand Down Expand Up @@ -198,6 +202,14 @@ func changeDifferences(change *ps.NekiShardConfigurationProfileChange) []changeD
After: strconv.Itoa(change.Replicas),
})
}
if change.PreviousReadOnlyReplicas != change.ReadOnlyReplicas {
differences = append(differences, changeDifference{
Field: "Read-only replicas",
Before: strconv.Itoa(change.PreviousReadOnlyReplicas),
After: strconv.Itoa(change.ReadOnlyReplicas),
})
}

differences = appendStorageDifferences(differences, change.PreviousStorage, change.Storage)

parameterKeys := make(map[string]struct{})
Expand Down
139 changes: 131 additions & 8 deletions internal/cmd/configprofile/config_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package configprofile
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"strings"
"testing"
Expand Down Expand Up @@ -38,6 +39,7 @@ func testProfile() *ps.NekiShardConfigurationProfile {
Default: true,
Metal: true,
Replicas: 2,
ReadOnlyReplicas: 3,
PostgresMajorVersion: 17,
PostgresMinorVersion: 6,
Shards: 3,
Expand Down Expand Up @@ -69,6 +71,7 @@ func TestConfigProfileListCmd(t *testing.T) {
"default": true,
"metal": true,
"replicas": 2,
"read_only_replicas": 3,
"postgres_major_version": 17,
"postgres_minor_version": 6,
"shards": 3,
Expand Down Expand Up @@ -98,6 +101,7 @@ func TestConfigProfileListHumanHeadersAndUTC(t *testing.T) {
cmd.SetArgs([]string{"app", "main"})
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(out.String(), qt.Contains, "CLUSTER SIZE")
c.Assert(out.String(), qt.Contains, "READ-ONLY REPLICAS")
c.Assert(out.String(), qt.Contains, "POSTGRES VERSION")
c.Assert(out.String(), qt.Contains, "MIN STORAGE")
c.Assert(out.String(), qt.Contains, "MAX STORAGE")
Expand Down Expand Up @@ -128,6 +132,58 @@ func TestConfigProfileCreateCmdOnlySendsChangedOptionalFlags(t *testing.T) {
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
}

func TestConfigProfileReadOnlyReplicas(t *testing.T) {
for _, operation := range []string{"create", "update"} {
for _, tt := range []struct {
name string
args []string
want *int
}{
{name: "omitted"},
{name: "zero", args: []string{"--read-only-replicas", "0"}, want: intPtr(0)},
{name: "positive", args: []string{"--read-only-replicas", "2"}, want: intPtr(2)},
} {
t.Run(operation+"/"+tt.name, func(t *testing.T) {
c := qt.New(t)
var out bytes.Buffer
check := func(got *int) {
c.Assert(got, qt.DeepEquals, tt.want)
}
svc := &mock.NekiShardConfigurationProfilesService{
CreateFn: func(_ context.Context, req *ps.CreateNekiShardConfigurationProfileRequest) (*ps.NekiShardConfigurationProfile, error) {
check(req.ReadOnlyReplicas)
c.Assert(req.Replicas, qt.IsNil)
return testProfile(), nil
},
UpdateFn: func(_ context.Context, req *ps.UpdateNekiShardConfigurationProfileRequest) (*ps.NekiShardConfigurationProfile, error) {
check(req.ReadOnlyReplicas)
if tt.want != nil {
c.Assert(req.Replicas, qt.IsNil)
}
return testProfile(), nil
},
}
ch := configProfileTestHelper(svc, &out)
cmd := CreateCmd(ch)
args := []string{"app", "main", "metal"}
if operation == "update" {
cmd = UpdateCmd(ch)
if tt.want == nil {
args = append(args, "--replicas", "2")
}
}
cmd.SetArgs(append(args, tt.args...))
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(svc.CreateFnInvoked, qt.Equals, operation == "create")
c.Assert(svc.UpdateFnInvoked, qt.Equals, operation == "update")
var profile ps.NekiShardConfigurationProfile
c.Assert(json.Unmarshal(out.Bytes(), &profile), qt.IsNil)
c.Assert(profile.ReadOnlyReplicas, qt.Equals, 3)
})
}
}
}

func TestConfigProfileCreateCmdSendsStorage(t *testing.T) {
c := qt.New(t)
var out bytes.Buffer
Expand Down Expand Up @@ -387,14 +443,16 @@ func TestConfigProfileChangesShowHumanDisplaysDifferences(t *testing.T) {
Change: "change-1",
})
return &ps.NekiShardConfigurationProfileChange{
ID: "change-1",
State: "applying",
Name: "metal-next",
PreviousName: "metal",
ClusterSize: "M1-20",
PreviousClusterSize: "M1-10",
Replicas: 3,
PreviousReplicas: 2,
ID: "change-1",
State: "applying",
Name: "metal-next",
PreviousName: "metal",
ClusterSize: "M1-20",
PreviousClusterSize: "M1-10",
Replicas: 3,
PreviousReplicas: 2,
ReadOnlyReplicas: 0,
PreviousReadOnlyReplicas: 2,
Storage: &ps.NekiStorage{
MinimumStorageBytes: int64Ptr(21474836480),
StorageAutoscaling: boolPtr(false),
Expand Down Expand Up @@ -431,13 +489,77 @@ func TestConfigProfileChangesShowHumanDisplaysDifferences(t *testing.T) {
c.Assert(out.String(), qt.Contains, "Name: metal → metal-next")
c.Assert(out.String(), qt.Contains, "Cluster size: M1-10 → M1-20")
c.Assert(out.String(), qt.Contains, "Replicas: 2 → 3")
c.Assert(out.String(), qt.Contains, "READ-ONLY REPLICAS")
c.Assert(out.String(), qt.Contains, "Read-only replicas: 2 → 0")
c.Assert(out.String(), qt.Contains, "Min storage: 10737418240 → 21474836480")
c.Assert(out.String(), qt.Contains, "Storage autoscaling: true → false")
c.Assert(out.String(), qt.Contains, "Parameter pgconf.log_rotation_size: (default) → 100MB")
c.Assert(out.String(), qt.Contains, "Parameter pgconf.max_connections: 100 → 200")
c.Assert(out.String(), qt.Contains, "Parameter pgconf.statement_timeout: 10s → (default)")
}

func TestConfigProfileReadOnlyReplicaOutput(t *testing.T) {
change := &ps.NekiShardConfigurationProfileChange{ReadOnlyReplicas: 0, PreviousReadOnlyReplicas: 3}
for _, tt := range []struct {
name string
resource interface{}
want float64
}{
{name: "profile", resource: toProfile(testProfile()), want: 3},
{name: "change", resource: toChange(change), want: 0},
} {
t.Run(tt.name, func(t *testing.T) {
c := qt.New(t)
var out bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&out)
c.Assert(p.PrintResource(tt.resource), qt.IsNil)
var body map[string]interface{}
c.Assert(json.Unmarshal(out.Bytes(), &body), qt.IsNil)
c.Assert(body["read_only_replicas"], qt.Equals, tt.want)
if tt.name == "change" {
c.Assert(body["previous_read_only_replicas"], qt.Equals, float64(3))
}

out.Reset()
format = printer.CSV
c.Assert(p.PrintResource(tt.resource), qt.IsNil)
rows, err := csv.NewReader(&out).ReadAll()
c.Assert(err, qt.IsNil)
c.Assert(rows, qt.HasLen, 2)
column := -1
for i, header := range rows[0] {
if header == "ReadOnlyReplicas" {
column = i
}
}
c.Assert(column >= 0, qt.IsTrue)
c.Assert(rows[1][column], qt.Equals, formatValue(tt.want))
})
}
}

func TestConfigProfileReadOnlyReplicaDifferences(t *testing.T) {
for _, tt := range []struct {
name string
before, after int
want []changeDifference
}{
{name: "unchanged", before: 2, after: 2, want: []changeDifference{}},
{name: "add", before: 0, after: 2, want: []changeDifference{{Field: "Read-only replicas", Before: "0", After: "2"}}},
{name: "remove", before: 2, after: 0, want: []changeDifference{{Field: "Read-only replicas", Before: "2", After: "0"}}},
} {
t.Run(tt.name, func(t *testing.T) {
c := qt.New(t)
c.Assert(changeDifferences(&ps.NekiShardConfigurationProfileChange{
ReadOnlyReplicas: tt.after,
PreviousReadOnlyReplicas: tt.before,
}), qt.DeepEquals, tt.want)
})
}
}

func TestConfigProfileMaintenanceCmd(t *testing.T) {
c := qt.New(t)
var out bytes.Buffer
Expand Down Expand Up @@ -607,5 +729,6 @@ func TestConfigProfileCmdSubcommands(t *testing.T) {
}
}

func intPtr(v int) *int { return &v }
func int64Ptr(v int64) *int64 { return &v }
func boolPtr(v bool) *bool { return &v }
11 changes: 7 additions & 4 deletions internal/cmd/configprofile/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import (

func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
var flags struct {
clusterSize string
replicas int
major, minor string
storage storageFlags
clusterSize string
replicas int
readOnlyReplicas int
major, minor string
storage storageFlags
}
cmd := &cobra.Command{
Use: "create <database> <branch> <name>",
Expand All @@ -40,6 +41,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
Name: name,
ClusterSize: stringPointerIfChanged(cmd, "cluster-size", cmdutil.ToSizeSKUName(flags.clusterSize)),
Replicas: intPointerIfChanged(cmd, "replicas", flags.replicas),
ReadOnlyReplicas: intPointerIfChanged(cmd, "read-only-replicas", flags.readOnlyReplicas),
PostgresMajorVersion: stringPointerIfChanged(cmd, "postgres-major-version", flags.major),
PostgresMinorVersion: stringPointerIfChanged(cmd, "postgres-minor-version", flags.minor),
Storage: storageFromFlags(cmd, flags.storage),
Expand All @@ -57,6 +59,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
}
cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "Cluster size for shards in the profile")
cmd.Flags().IntVar(&flags.replicas, "replicas", 0, "Number of replicas for shards in the profile")
cmd.Flags().IntVar(&flags.readOnlyReplicas, "read-only-replicas", 0, "Number of read-only replicas per shard (0-8; defaults to 0)")
cmd.Flags().StringVar(&flags.major, "postgres-major-version", "", "PostgreSQL major version")
cmd.Flags().StringVar(&flags.minor, "postgres-minor-version", "", "PostgreSQL minor version")
bindStorageFlags(cmd, &flags.storage)
Expand Down
Loading
Loading