Skip to content
Draft
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
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,29 @@ pscale --org <org> database list --format json
pscale sql <database> <branch> --org <org> --format json --query "SELECT 1"
```

## Cloudflare-billed databases

To create a database billed to a Cloudflare account, Cloudflare must mint an HMAC billing proof. Pass all three flags together:

```bash
pscale database create <database> --org <org> --format json \
--cloudflare-account-id <cloudflare_account_id> \
--cloudflare-timestamp <unix_timestamp> \
--cloudflare-signature <hmac_hex>
```

The CLI does not mint the signature. Incomplete Cloudflare flags fail before the API is called.

## Flags

| Flag | Purpose |
|------|---------|
| `--format json` | JSON on stdout |
| `--org <org>` | Organization (on resource subcommands only) |
| `--api-url` | Non-production API base URL — pass on every command when not using production |
| `--cloudflare-account-id` | With timestamp + signature on `database create`: bill the DB to Cloudflare |
| `--cloudflare-timestamp` | Unix timestamp for the Cloudflare billing signature |
| `--cloudflare-signature` | HMAC proof of Cloudflare billing intent |

## JSON errors

Expand Down
33 changes: 26 additions & 7 deletions internal/cmd/database/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
createReq := &ps.CreateDatabaseRequest{}

var flags struct {
clusterSize string
engine string
wait bool
replicas *int
majorVersion string
minStorage int64
maxStorage int64
clusterSize string
engine string
wait bool
replicas *int
majorVersion string
minStorage int64
maxStorage int64
cloudflareAccountID string
cloudflareTimestamp string
cloudflareSignature string
}

cmd := &cobra.Command{
Expand Down Expand Up @@ -67,6 +70,18 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
}
}

cloudflareAccountID := flags.cloudflareAccountID
cloudflareTimestamp := flags.cloudflareTimestamp
cloudflareSignature := flags.cloudflareSignature
if cloudflareAccountID != "" || cloudflareTimestamp != "" || cloudflareSignature != "" {
if cloudflareAccountID == "" || cloudflareTimestamp == "" || cloudflareSignature == "" {
return fmt.Errorf("--cloudflare-account-id, --cloudflare-timestamp, and --cloudflare-signature are all required when billing to Cloudflare")
}
createReq.CloudflareAccountID = cloudflareAccountID
createReq.CloudflareTimestamp = cloudflareTimestamp
createReq.CloudflareSignature = cloudflareSignature
}

client, err := ch.Client()
if err != nil {
return err
Expand Down Expand Up @@ -139,6 +154,10 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
cmd.Flags().Int64Var(&flags.minStorage, "min-storage", 0, "Minimum storage size in bytes")
cmd.Flags().Int64Var(&flags.maxStorage, "max-storage", 0, "Maximum storage size in bytes for autoscaling")

cmd.Flags().StringVar(&flags.cloudflareAccountID, "cloudflare-account-id", "", "Cloudflare account ID to bill this database to. Requires --cloudflare-timestamp and --cloudflare-signature.")
cmd.Flags().StringVar(&flags.cloudflareTimestamp, "cloudflare-timestamp", "", "Unix timestamp for the Cloudflare billing signature. Requires --cloudflare-account-id and --cloudflare-signature.")
cmd.Flags().StringVar(&flags.cloudflareSignature, "cloudflare-signature", "", "HMAC signature proving Cloudflare billing intent. Requires --cloudflare-account-id and --cloudflare-timestamp.")

cmd.Flags().BoolVar(&flags.wait, "wait", false, "Wait until the database is ready")

return cmd
Expand Down
98 changes: 98 additions & 0 deletions internal/cmd/database/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,101 @@ func TestDatabase_CreateCmdPostgresWithMajorVersion(t *testing.T) {
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, res)
}

func TestDatabase_CreateCmdCloudflareBilling(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

org := "planetscale"
db := "planetscale"

res := &ps.Database{Name: "foo"}

svc := &mock.DatabaseService{
CreateFn: func(ctx context.Context, req *ps.CreateDatabaseRequest) (*ps.Database, error) {
c.Assert(req.Organization, qt.Equals, org)
c.Assert(req.Name, qt.Equals, db)
c.Assert(req.CloudflareAccountID, qt.Equals, "cf_account_123")
c.Assert(req.CloudflareTimestamp, qt.Equals, "1710000000")
c.Assert(req.CloudflareSignature, qt.Equals, "abc123sig")

return res, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: org,
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Databases: svc,
Organizations: &mock.OrganizationsService{
GetFn: func(ctx context.Context, request *ps.GetOrganizationRequest) (*ps.Organization, error) {
return &ps.Organization{
RemainingFreeDatabases: 1,
Name: request.Organization,
}, nil
},
},
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{
db,
"--region", "us-east",
"--cloudflare-account-id", "cf_account_123",
"--cloudflare-timestamp", "1710000000",
"--cloudflare-signature", "abc123sig",
})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, res)
}

func TestDatabase_CreateCmdCloudflareBillingIncomplete(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

org := "planetscale"
db := "planetscale"

svc := &mock.DatabaseService{
CreateFn: func(ctx context.Context, req *ps.CreateDatabaseRequest) (*ps.Database, error) {
c.Fatalf("Create should not be called with incomplete Cloudflare flags")
return nil, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: org,
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Databases: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{db, "--cloudflare-account-id", "cf_account_123"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, ".*cloudflare-account-id, --cloudflare-timestamp, and --cloudflare-signature are all required.*")
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}
21 changes: 12 additions & 9 deletions internal/planetscale/databases.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ type StorageConfig struct {

// CreateDatabaseRequest encapsulates the request for creating a new database.
type CreateDatabaseRequest struct {
Organization string
Name string `json:"name"`
Notes string `json:"notes,omitempty"`
Region string `json:"region,omitempty"`
ClusterSize string `json:"cluster_size,omitempty"`
Kind DatabaseEngine `json:"kind,omitempty"`
Replicas *int `json:"replicas,omitempty"`
MajorVersion string `json:"major_version,omitempty"`
Storage *StorageConfig `json:"storage,omitempty"`
Organization string
Name string `json:"name"`
Notes string `json:"notes,omitempty"`
Region string `json:"region,omitempty"`
ClusterSize string `json:"cluster_size,omitempty"`
Kind DatabaseEngine `json:"kind,omitempty"`
Replicas *int `json:"replicas,omitempty"`
MajorVersion string `json:"major_version,omitempty"`
Storage *StorageConfig `json:"storage,omitempty"`
CloudflareAccountID string `json:"cloudflare_account_id,omitempty"`
CloudflareTimestamp string `json:"cloudflare_timestamp,omitempty"`
CloudflareSignature string `json:"cloudflare_signature,omitempty"`
}

// DatabaseRequest encapsulates the request for getting a single database.
Expand Down
34 changes: 34 additions & 0 deletions internal/planetscale/databases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ func TestDatabases_Create(t *testing.T) {
c.Assert(db, qt.DeepEquals, want)
}

func TestDatabases_CreateCloudflareBilling(t *testing.T) {
c := qt.New(t)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
var body map[string]any
err := json.NewDecoder(r.Body).Decode(&body)
c.Assert(err, qt.IsNil)
c.Assert(body["cloudflare_account_id"], qt.Equals, "cf_account_123")
c.Assert(body["cloudflare_timestamp"], qt.Equals, "1710000000")
c.Assert(body["cloudflare_signature"], qt.Equals, "abc123sig")

out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": { "slug": "us-west", "display_name": "US West" },"state":"ready"}`
_, err = w.Write([]byte(out))
c.Assert(err, qt.IsNil)
}))

client, err := NewClient(WithBaseURL(ts.URL))
c.Assert(err, qt.IsNil)

ctx := context.Background()
db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{
Organization: "my-org",
Region: "us-west",
Name: "planetscale-go-test-db",
CloudflareAccountID: "cf_account_123",
CloudflareTimestamp: "1710000000",
CloudflareSignature: "abc123sig",
})

c.Assert(err, qt.IsNil)
c.Assert(db.Name, qt.Equals, "planetscale-go-test-db")
}

func TestDatabases_CreatePostgres(t *testing.T) {
c := qt.New(t)

Expand Down