diff --git a/AGENTS.md b/AGENTS.md index f91e5ba2..551d370e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,19 @@ pscale --org database list --format json pscale sql --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 --org --format json \ + --cloudflare-account-id \ + --cloudflare-timestamp \ + --cloudflare-signature +``` + +The CLI does not mint the signature. Incomplete Cloudflare flags fail before the API is called. + ## Flags | Flag | Purpose | @@ -96,6 +109,9 @@ pscale --org database list --format json | `--format json` | JSON on stdout | | `--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 diff --git a/internal/cmd/database/create.go b/internal/cmd/database/create.go index 8b57c8d9..722aaa99 100644 --- a/internal/cmd/database/create.go +++ b/internal/cmd/database/create.go @@ -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{ @@ -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 @@ -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 diff --git a/internal/cmd/database/create_test.go b/internal/cmd/database/create_test.go index 86c49123..ef4268fe 100644 --- a/internal/cmd/database/create_test.go +++ b/internal/cmd/database/create_test.go @@ -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) +} diff --git a/internal/planetscale/databases.go b/internal/planetscale/databases.go index f8313aae..04ca9821 100644 --- a/internal/planetscale/databases.go +++ b/internal/planetscale/databases.go @@ -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. diff --git a/internal/planetscale/databases_test.go b/internal/planetscale/databases_test.go index 003a6f7c..fc408001 100644 --- a/internal/planetscale/databases_test.go +++ b/internal/planetscale/databases_test.go @@ -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)