diff --git a/AGENTS.md b/AGENTS.md index 19974e8d4..23780e00f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # PlanetScale CLI — agent guide +> **Developing the CLI?** The API client is vendored at `internal/planetscale/`; +> this repo **no longer depends on `planetscale-go`**. Read `doc/api-client.md` +> before touching API-facing code. The rest of this file is about *using* `pscale`. + For **any** automated agent or script using `pscale`. Always pass **`--format json`**. Substitute placeholders from the user's request or from prior command output (`org list`, `database list`, `branch list`). If you only have the installed `pscale` binary, start here: diff --git a/doc/api-client.md b/doc/api-client.md new file mode 100644 index 000000000..a90aa7f58 --- /dev/null +++ b/doc/api-client.md @@ -0,0 +1,43 @@ +# API client (`internal/planetscale`) + +**The CLI no longer depends on `github.com/planetscale/planetscale-go`.** + +As of July 2026, the Go API client that used to live in the planetscale-go +repository is vendored into this repo at `internal/planetscale/` and is +maintained here. The import path is: + +```go +import ps "github.com/planetscale/cli/internal/planetscale" +``` + +## Why + +Every API change used to require two PRs and a release dance: change +planetscale-go, tag a release, bump `go.mod` here, then write the CLI +feature. The CLI was the only internal consumer of planetscale-go, and the +published OpenAPI spec covers under half of the endpoints the CLI uses +(none of the vtctld/workflow/data-import surface), so generating a client +from the spec was not an option. Copying the client in removes the +cross-repo step entirely. + +## What this means when adding or changing an endpoint + +- Add the endpoint to api-bb, then add the method/types directly in + `internal/planetscale/` in the same CLI PR. There is no SDK release to + wait for and no version to bump. +- Do **not** add `github.com/planetscale/planetscale-go` back to `go.mod`, + and do not update the planetscale-go repo as a prerequisite for CLI work. + A test (`TestNoPlanetscaleGoDependency` in `internal/planetscale/`) fails + the build if the module shows up in `go.mod` or in any import. +- Service interfaces (e.g. `DatabasesService`) live next to their + implementations; hand-written mocks are in `internal/mock/` and must be + updated when an interface changes, same as before. +- The client's `User-Agent` is `pscale-cli/`, set by the CLI at + startup via `planetscale.WithUserAgent` (see `internal/cmd/root.go`). + There is no separate library version anymore. + +## The planetscale-go repository + +The public planetscale-go SDK still exists for external users, but this +repo does not consume it and changes there do not affect the CLI. Its +future (maintenance mode, archive, etc.) is tracked separately. diff --git a/go.mod b/go.mod index ebc04f15c..2c6e4eb67 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/termenv v0.16.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/planetscale/planetscale-go v0.177.0 github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4 github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index c3147c644..dea61f733 100644 --- a/go.sum +++ b/go.sum @@ -183,8 +183,6 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e h1:MZ8D+Z3m2vvqGZLvoQfpaGg/j1fNDr4j03s3PRz4rVY= github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e/go.mod h1:hwAsSPQdvPa3WcfKfzTXxtEq/HlqwLjQasfO6QbGo4Q= -github.com/planetscale/planetscale-go v0.177.0 h1:jVE8uInjGayJ7RLaqwBK7VPgu96U/99dwV2MbbuRywA= -github.com/planetscale/planetscale-go v0.177.0/go.mod h1:paQCI5SgquuoewvMQM7R+r1XJO868bdP6/ihGidYRM0= github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4 h1:Xv5pj20Rhfty1Tv0OVcidg4ez4PvGrpKvb6rvUwQgDs= github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4/go.mod h1:M52h5IWxAcbdQ1hSZrLAGQC4ZXslxEsK/Wh9nu3wdWs= github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7 h1:aRd6vdE1fyuSI4RVj7oCr8lFmgqXvpnPUmN85VbZCp8= diff --git a/internal/cmd/auditlog/auditlogs.go b/internal/cmd/auditlog/auditlogs.go index 9601a2aca..afeb9e9ec 100644 --- a/internal/cmd/auditlog/auditlogs.go +++ b/internal/cmd/auditlog/auditlogs.go @@ -12,7 +12,7 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // AuditLogCmd handles audit logs. diff --git a/internal/cmd/auditlog/list.go b/internal/cmd/auditlog/list.go index 1511512b5..62e57e8dc 100644 --- a/internal/cmd/auditlog/list.go +++ b/internal/cmd/auditlog/list.go @@ -5,8 +5,8 @@ import ( "net/url" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/auditlog/list_test.go b/internal/cmd/auditlog/list_test.go index 645857253..53d26e020 100644 --- a/internal/cmd/auditlog/list_test.go +++ b/internal/cmd/auditlog/list_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index d9a6079a6..9e565342b 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -10,8 +10,8 @@ import ( psauth "github.com/planetscale/cli/internal/auth" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/fatih/color" "github.com/spf13/cobra" diff --git a/internal/cmd/backup/backup.go b/internal/cmd/backup/backup.go index 9d9b8935f..618ff5835 100644 --- a/internal/cmd/backup/backup.go +++ b/internal/cmd/backup/backup.go @@ -9,7 +9,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // BackupCmd handles branch backups. diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go index 19d610f0e..f3975b96a 100644 --- a/internal/cmd/backup/create.go +++ b/internal/cmd/backup/create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go index cb3ba0d07..83510e106 100644 --- a/internal/cmd/backup/create_test.go +++ b/internal/cmd/backup/create_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/backup/delete.go b/internal/cmd/backup/delete.go index 8c1d4809e..ce6e825ff 100644 --- a/internal/cmd/backup/delete.go +++ b/internal/cmd/backup/delete.go @@ -8,7 +8,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" diff --git a/internal/cmd/backup/delete_test.go b/internal/cmd/backup/delete_test.go index 3e24c2532..4f4499a83 100644 --- a/internal/cmd/backup/delete_test.go +++ b/internal/cmd/backup/delete_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/backup/list.go b/internal/cmd/backup/list.go index 27c10855e..425aaac93 100644 --- a/internal/cmd/backup/list.go +++ b/internal/cmd/backup/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/backup/list_test.go b/internal/cmd/backup/list_test.go index ebc925069..666828e12 100644 --- a/internal/cmd/backup/list_test.go +++ b/internal/cmd/backup/list_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/backup/restore.go b/internal/cmd/backup/restore.go index f0ae323ac..61560fa7c 100644 --- a/internal/cmd/backup/restore.go +++ b/internal/cmd/backup/restore.go @@ -7,7 +7,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/backup/restore_test.go b/internal/cmd/backup/restore_test.go index f7441ea48..8574a76b2 100644 --- a/internal/cmd/backup/restore_test.go +++ b/internal/cmd/backup/restore_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/backup/show.go b/internal/cmd/backup/show.go index efe1e968c..073b6f87f 100644 --- a/internal/cmd/backup/show.go +++ b/internal/cmd/backup/show.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/backup/show_test.go b/internal/cmd/backup/show_test.go index 5d3c77ade..b0bd9a00a 100644 --- a/internal/cmd/backup/show_test.go +++ b/internal/cmd/backup/show_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/branch/branch.go b/internal/cmd/branch/branch.go index 1ea73a2e3..cb178a9c3 100644 --- a/internal/cmd/branch/branch.go +++ b/internal/cmd/branch/branch.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmd/branch/vtctld" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/connections/actions.go b/internal/cmd/branch/connections/actions.go index 4e457c08a..8f91fb407 100644 --- a/internal/cmd/branch/connections/actions.go +++ b/internal/cmd/branch/connections/actions.go @@ -7,8 +7,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" live "github.com/planetscale/cli/internal/connections" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) type actionResult struct { diff --git a/internal/cmd/branch/connections/engine_flags.go b/internal/cmd/branch/connections/engine_flags.go index f4e06b393..43d0dffb2 100644 --- a/internal/cmd/branch/connections/engine_flags.go +++ b/internal/cmd/branch/connections/engine_flags.go @@ -3,7 +3,7 @@ package connections import ( "errors" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // ValidateEngineFlags rejects flags that only apply to another database engine. diff --git a/internal/cmd/branch/connections/top.go b/internal/cmd/branch/connections/top.go index 440784527..4569fc219 100644 --- a/internal/cmd/branch/connections/top.go +++ b/internal/cmd/branch/connections/top.go @@ -15,9 +15,9 @@ import ( live "github.com/planetscale/cli/internal/connections" "github.com/planetscale/cli/internal/connections/history" "github.com/planetscale/cli/internal/connections/tui" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/promptutil" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/connections/top_test.go b/internal/cmd/branch/connections/top_test.go index 77be6288e..03547b091 100644 --- a/internal/cmd/branch/connections/top_test.go +++ b/internal/cmd/branch/connections/top_test.go @@ -19,8 +19,8 @@ import ( "github.com/planetscale/cli/internal/config" live "github.com/planetscale/cli/internal/connections" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/connections_commands.go b/internal/cmd/branch/connections_commands.go index c196070b1..c0ada4d78 100644 --- a/internal/cmd/branch/connections_commands.go +++ b/internal/cmd/branch/connections_commands.go @@ -8,7 +8,7 @@ import ( "github.com/planetscale/cli/internal/cmd/branch/connections" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/connections_test.go b/internal/cmd/branch/connections_test.go index a880e6ba8..32cd58650 100644 --- a/internal/cmd/branch/connections_test.go +++ b/internal/cmd/branch/connections_test.go @@ -18,8 +18,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/create.go b/internal/cmd/branch/create.go index baf7edc38..70056e54b 100644 --- a/internal/cmd/branch/create.go +++ b/internal/cmd/branch/create.go @@ -7,8 +7,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/create_test.go b/internal/cmd/branch/create_test.go index 5b5012fb6..aed62ebd5 100644 --- a/internal/cmd/branch/create_test.go +++ b/internal/cmd/branch/create_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranch_CreateCmd(t *testing.T) { diff --git a/internal/cmd/branch/delete.go b/internal/cmd/branch/delete.go index 60e938a5b..803551785 100644 --- a/internal/cmd/branch/delete.go +++ b/internal/cmd/branch/delete.go @@ -6,8 +6,8 @@ import ( "os" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" diff --git a/internal/cmd/branch/delete_test.go b/internal/cmd/branch/delete_test.go index 2b4c7f296..917baa89b 100644 --- a/internal/cmd/branch/delete_test.go +++ b/internal/cmd/branch/delete_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranch_DeleteCmd(t *testing.T) { diff --git a/internal/cmd/branch/demote.go b/internal/cmd/branch/demote.go index 1bfee31b5..4fb813aa0 100644 --- a/internal/cmd/branch/demote.go +++ b/internal/cmd/branch/demote.go @@ -7,7 +7,7 @@ import ( "github.com/planetscale/cli/internal/printer" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func DemoteCmd(ch *cmdutil.Helper) *cobra.Command { diff --git a/internal/cmd/branch/demote_test.go b/internal/cmd/branch/demote_test.go index 24ae93107..f1f39aad5 100644 --- a/internal/cmd/branch/demote_test.go +++ b/internal/cmd/branch/demote_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBranch_DemoteCmd(t *testing.T) { diff --git a/internal/cmd/branch/diff.go b/internal/cmd/branch/diff.go index 8e6c0578f..cfd9adf9e 100644 --- a/internal/cmd/branch/diff.go +++ b/internal/cmd/branch/diff.go @@ -7,8 +7,8 @@ import ( "github.com/fatih/color" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/diff_test.go b/internal/cmd/branch/diff_test.go index 7d334770e..336c8d350 100644 --- a/internal/cmd/branch/diff_test.go +++ b/internal/cmd/branch/diff_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/mock" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranchDiffCmd(t *testing.T) { diff --git a/internal/cmd/branch/infra.go b/internal/cmd/branch/infra.go index b1331a6c4..3563cb405 100644 --- a/internal/cmd/branch/infra.go +++ b/internal/cmd/branch/infra.go @@ -7,8 +7,8 @@ import ( "github.com/dustin/go-humanize" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/lint.go b/internal/cmd/branch/lint.go index d0aa03a8b..bac987ae6 100644 --- a/internal/cmd/branch/lint.go +++ b/internal/cmd/branch/lint.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/lint_test.go b/internal/cmd/branch/lint_test.go index b47a01a8d..8c65927c5 100644 --- a/internal/cmd/branch/lint_test.go +++ b/internal/cmd/branch/lint_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBranch_LintCmd(t *testing.T) { diff --git a/internal/cmd/branch/list.go b/internal/cmd/branch/list.go index ee77ae49b..c4f6d6ec3 100644 --- a/internal/cmd/branch/list.go +++ b/internal/cmd/branch/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/branch/list_test.go b/internal/cmd/branch/list_test.go index c904c3321..9d6981f4f 100644 --- a/internal/cmd/branch/list_test.go +++ b/internal/cmd/branch/list_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranch_ListCmd(t *testing.T) { diff --git a/internal/cmd/branch/processlist.go b/internal/cmd/branch/processlist.go index 8b6e33490..820484035 100644 --- a/internal/cmd/branch/processlist.go +++ b/internal/cmd/branch/processlist.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmd/branch/connections" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/processlist_test.go b/internal/cmd/branch/processlist_test.go index b27337aaf..fb8097f45 100644 --- a/internal/cmd/branch/processlist_test.go +++ b/internal/cmd/branch/processlist_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func processlistTestHelper(org, baseURL string, format printer.Format, buf *bytes.Buffer) *cmdutil.Helper { diff --git a/internal/cmd/branch/promote.go b/internal/cmd/branch/promote.go index 5294b8ff8..99b629f08 100644 --- a/internal/cmd/branch/promote.go +++ b/internal/cmd/branch/promote.go @@ -7,7 +7,7 @@ import ( "github.com/planetscale/cli/internal/printer" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func PromoteCmd(ch *cmdutil.Helper) *cobra.Command { diff --git a/internal/cmd/branch/promote_test.go b/internal/cmd/branch/promote_test.go index b5dc04d90..4791ce844 100644 --- a/internal/cmd/branch/promote_test.go +++ b/internal/cmd/branch/promote_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBranch_PromoteCmd(t *testing.T) { diff --git a/internal/cmd/branch/query_patterns.go b/internal/cmd/branch/query_patterns.go index 8a5f7436c..ba53c34ca 100644 --- a/internal/cmd/branch/query_patterns.go +++ b/internal/cmd/branch/query_patterns.go @@ -7,8 +7,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/query_patterns_test.go b/internal/cmd/branch/query_patterns_test.go index cf3745f12..1bd9ae3ef 100644 --- a/internal/cmd/branch/query_patterns_test.go +++ b/internal/cmd/branch/query_patterns_test.go @@ -16,8 +16,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) const queryPatternsCSV = "normalized_sql,query_count\nselect ?,10\n" diff --git a/internal/cmd/branch/refresh_schema.go b/internal/cmd/branch/refresh_schema.go index ae72b2d1c..2efc225fb 100644 --- a/internal/cmd/branch/refresh_schema.go +++ b/internal/cmd/branch/refresh_schema.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/refresh_schema_test.go b/internal/cmd/branch/refresh_schema_test.go index df2f45d25..612448cf8 100644 --- a/internal/cmd/branch/refresh_schema_test.go +++ b/internal/cmd/branch/refresh_schema_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestSnapshot_CreateCmd(t *testing.T) { diff --git a/internal/cmd/branch/resize.go b/internal/cmd/branch/resize.go index 74de19292..a3f254808 100644 --- a/internal/cmd/branch/resize.go +++ b/internal/cmd/branch/resize.go @@ -6,8 +6,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/routing_rules.go b/internal/cmd/branch/routing_rules.go index bc1d7854d..0327ed65a 100644 --- a/internal/cmd/branch/routing_rules.go +++ b/internal/cmd/branch/routing_rules.go @@ -7,8 +7,8 @@ import ( "os" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/safe_migrations.go b/internal/cmd/branch/safe_migrations.go index 19d8191cd..b9670c5b3 100644 --- a/internal/cmd/branch/safe_migrations.go +++ b/internal/cmd/branch/safe_migrations.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" diff --git a/internal/cmd/branch/safe_migrations_test.go b/internal/cmd/branch/safe_migrations_test.go index b78daa3f7..a8fdbccf1 100644 --- a/internal/cmd/branch/safe_migrations_test.go +++ b/internal/cmd/branch/safe_migrations_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBranch_EnableSafeMigrationsCmd(t *testing.T) { diff --git a/internal/cmd/branch/schema.go b/internal/cmd/branch/schema.go index cf1cb3093..0c1f90bf4 100644 --- a/internal/cmd/branch/schema.go +++ b/internal/cmd/branch/schema.go @@ -8,8 +8,8 @@ import ( "github.com/fatih/color" "github.com/pkg/browser" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/schema_test.go b/internal/cmd/branch/schema_test.go index 5dcbc0de4..044f4b2a7 100644 --- a/internal/cmd/branch/schema_test.go +++ b/internal/cmd/branch/schema_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranchSchemaCmd(t *testing.T) { diff --git a/internal/cmd/branch/show.go b/internal/cmd/branch/show.go index ece2d2631..777370f1f 100644 --- a/internal/cmd/branch/show.go +++ b/internal/cmd/branch/show.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/branch/show_test.go b/internal/cmd/branch/show_test.go index 0d25f81c5..5050e3ccb 100644 --- a/internal/cmd/branch/show_test.go +++ b/internal/cmd/branch/show_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestBranch_ShowCmd(t *testing.T) { diff --git a/internal/cmd/branch/switch.go b/internal/cmd/branch/switch.go index 60781aec9..64002a0e2 100644 --- a/internal/cmd/branch/switch.go +++ b/internal/cmd/branch/switch.go @@ -5,8 +5,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/keyspaces.go b/internal/cmd/branch/vtctld/keyspaces.go index 4f9de954d..e383752f2 100644 --- a/internal/cmd/branch/vtctld/keyspaces.go +++ b/internal/cmd/branch/vtctld/keyspaces.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/keyspaces_test.go b/internal/cmd/branch/vtctld/keyspaces_test.go index 402443e3e..66db505de 100644 --- a/internal/cmd/branch/vtctld/keyspaces_test.go +++ b/internal/cmd/branch/vtctld/keyspaces_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestListKeyspaces(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/list_tablets.go b/internal/cmd/branch/vtctld/list_tablets.go index 61276afd4..333a04045 100644 --- a/internal/cmd/branch/vtctld/list_tablets.go +++ b/internal/cmd/branch/vtctld/list_tablets.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/list_tablets_test.go b/internal/cmd/branch/vtctld/list_tablets_test.go index fdabb59ea..16f9ce71c 100644 --- a/internal/cmd/branch/vtctld/list_tablets_test.go +++ b/internal/cmd/branch/vtctld/list_tablets_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestListTablets(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/lookup_vindex.go b/internal/cmd/branch/vtctld/lookup_vindex.go index 1ae9244e4..dd8fcc52e 100644 --- a/internal/cmd/branch/vtctld/lookup_vindex.go +++ b/internal/cmd/branch/vtctld/lookup_vindex.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/lookup_vindex_test.go b/internal/cmd/branch/vtctld/lookup_vindex_test.go index c34b8185d..d8e361dec 100644 --- a/internal/cmd/branch/vtctld/lookup_vindex_test.go +++ b/internal/cmd/branch/vtctld/lookup_vindex_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestLookupVindexCreate(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/materialize.go b/internal/cmd/branch/vtctld/materialize.go index b2b09b115..265ed679b 100644 --- a/internal/cmd/branch/vtctld/materialize.go +++ b/internal/cmd/branch/vtctld/materialize.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/materialize_test.go b/internal/cmd/branch/vtctld/materialize_test.go index def70641a..9027be331 100644 --- a/internal/cmd/branch/vtctld/materialize_test.go +++ b/internal/cmd/branch/vtctld/materialize_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestMaterializeCreate(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/move_tables.go b/internal/cmd/branch/vtctld/move_tables.go index 6de459c94..9e0c9da7e 100644 --- a/internal/cmd/branch/vtctld/move_tables.go +++ b/internal/cmd/branch/vtctld/move_tables.go @@ -8,8 +8,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/move_tables_test.go b/internal/cmd/branch/vtctld/move_tables_test.go index ad1bccb45..5278311fe 100644 --- a/internal/cmd/branch/vtctld/move_tables_test.go +++ b/internal/cmd/branch/vtctld/move_tables_test.go @@ -12,8 +12,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func moveTablesTestHelper(org string, moveTables ps.MoveTablesService, vtctld ps.VtctldService, buf *bytes.Buffer) *cmdutil.Helper { diff --git a/internal/cmd/branch/vtctld/planned_reparent.go b/internal/cmd/branch/vtctld/planned_reparent.go index 577c4fe58..3723a5df9 100644 --- a/internal/cmd/branch/vtctld/planned_reparent.go +++ b/internal/cmd/branch/vtctld/planned_reparent.go @@ -8,7 +8,7 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/progress_test.go b/internal/cmd/branch/vtctld/progress_test.go index 4b4fe7752..c90748184 100644 --- a/internal/cmd/branch/vtctld/progress_test.go +++ b/internal/cmd/branch/vtctld/progress_test.go @@ -12,8 +12,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func setNonTTYProgress(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/refresh_state_by_shard.go b/internal/cmd/branch/vtctld/refresh_state_by_shard.go index 1154e74cd..9e51cdcbb 100644 --- a/internal/cmd/branch/vtctld/refresh_state_by_shard.go +++ b/internal/cmd/branch/vtctld/refresh_state_by_shard.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/refresh_state_by_shard_test.go b/internal/cmd/branch/vtctld/refresh_state_by_shard_test.go index fa887c2d6..12a26eb96 100644 --- a/internal/cmd/branch/vtctld/refresh_state_by_shard_test.go +++ b/internal/cmd/branch/vtctld/refresh_state_by_shard_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestRefreshStateByShard(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/routing_rules.go b/internal/cmd/branch/vtctld/routing_rules.go index 8aea11728..03419d145 100644 --- a/internal/cmd/branch/vtctld/routing_rules.go +++ b/internal/cmd/branch/vtctld/routing_rules.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/routing_rules_test.go b/internal/cmd/branch/vtctld/routing_rules_test.go index 5a802a8e5..c36710fcd 100644 --- a/internal/cmd/branch/vtctld/routing_rules_test.go +++ b/internal/cmd/branch/vtctld/routing_rules_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestGetRoutingRules(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/set_shard_tablet_control.go b/internal/cmd/branch/vtctld/set_shard_tablet_control.go index 95ab37c95..8bfe69c6f 100644 --- a/internal/cmd/branch/vtctld/set_shard_tablet_control.go +++ b/internal/cmd/branch/vtctld/set_shard_tablet_control.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/set_shard_tablet_control_test.go b/internal/cmd/branch/vtctld/set_shard_tablet_control_test.go index 69098e1e6..9ccfc5120 100644 --- a/internal/cmd/branch/vtctld/set_shard_tablet_control_test.go +++ b/internal/cmd/branch/vtctld/set_shard_tablet_control_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestSetShardTabletControl(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/shard.go b/internal/cmd/branch/vtctld/shard.go index 6b5398085..d316a2656 100644 --- a/internal/cmd/branch/vtctld/shard.go +++ b/internal/cmd/branch/vtctld/shard.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/shard_test.go b/internal/cmd/branch/vtctld/shard_test.go index 7ed2ba7a0..41683c8a0 100644 --- a/internal/cmd/branch/vtctld/shard_test.go +++ b/internal/cmd/branch/vtctld/shard_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestGetShard(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/start_workflow.go b/internal/cmd/branch/vtctld/start_workflow.go index bdc118de4..0be5422c7 100644 --- a/internal/cmd/branch/vtctld/start_workflow.go +++ b/internal/cmd/branch/vtctld/start_workflow.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/start_workflow_test.go b/internal/cmd/branch/vtctld/start_workflow_test.go index 897cb2f42..486baa78f 100644 --- a/internal/cmd/branch/vtctld/start_workflow_test.go +++ b/internal/cmd/branch/vtctld/start_workflow_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestStartWorkflow(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/stop_workflow.go b/internal/cmd/branch/vtctld/stop_workflow.go index 1e375b57a..575d19bce 100644 --- a/internal/cmd/branch/vtctld/stop_workflow.go +++ b/internal/cmd/branch/vtctld/stop_workflow.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/stop_workflow_test.go b/internal/cmd/branch/vtctld/stop_workflow_test.go index f1b038fbd..28213e26d 100644 --- a/internal/cmd/branch/vtctld/stop_workflow_test.go +++ b/internal/cmd/branch/vtctld/stop_workflow_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestStopWorkflow(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/throttler.go b/internal/cmd/branch/vtctld/throttler.go index b3ce48399..ed7228d92 100644 --- a/internal/cmd/branch/vtctld/throttler.go +++ b/internal/cmd/branch/vtctld/throttler.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/throttler_test.go b/internal/cmd/branch/vtctld/throttler_test.go index 5ef5c5923..e2d734e6e 100644 --- a/internal/cmd/branch/vtctld/throttler_test.go +++ b/internal/cmd/branch/vtctld/throttler_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func newThrottlerTestHelper(org string, svc *mock.VtctldService) (*cmdutil.Helper, *bytes.Buffer) { diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go index 6a2b83301..116b70e9b 100644 --- a/internal/cmd/branch/vtctld/vdiff.go +++ b/internal/cmd/branch/vtctld/vdiff.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/vdiff_test.go b/internal/cmd/branch/vtctld/vdiff_test.go index 679071db5..ecb50a71e 100644 --- a/internal/cmd/branch/vtctld/vdiff_test.go +++ b/internal/cmd/branch/vtctld/vdiff_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestVDiffCreate(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/workflows.go b/internal/cmd/branch/vtctld/workflows.go index 345765b18..9aa672541 100644 --- a/internal/cmd/branch/vtctld/workflows.go +++ b/internal/cmd/branch/vtctld/workflows.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/branch/vtctld/workflows_test.go b/internal/cmd/branch/vtctld/workflows_test.go index 8dc7851c5..5b239add4 100644 --- a/internal/cmd/branch/vtctld/workflows_test.go +++ b/internal/cmd/branch/vtctld/workflows_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestListWorkflows(t *testing.T) { diff --git a/internal/cmd/connect/connect.go b/internal/cmd/connect/connect.go index 61a3abd78..ff9ec2e64 100644 --- a/internal/cmd/connect/connect.go +++ b/internal/cmd/connect/connect.go @@ -14,10 +14,10 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/passwordutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/promptutil" "github.com/planetscale/cli/internal/proxyutil" - "github.com/planetscale/planetscale-go/planetscale" "github.com/mattn/go-shellwords" "github.com/spf13/cobra" diff --git a/internal/cmd/database/create.go b/internal/cmd/database/create.go index db3d059a7..8b57c8d9d 100644 --- a/internal/cmd/database/create.go +++ b/internal/cmd/database/create.go @@ -9,7 +9,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/database/create_test.go b/internal/cmd/database/create_test.go index 4ae4bdc54..86c491235 100644 --- a/internal/cmd/database/create_test.go +++ b/internal/cmd/database/create_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/database/database.go b/internal/cmd/database/database.go index c60f21a05..bf9cfdebb 100644 --- a/internal/cmd/database/database.go +++ b/internal/cmd/database/database.go @@ -7,7 +7,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // DatabaseCmd encapsulates the commands for creating a database diff --git a/internal/cmd/database/delete.go b/internal/cmd/database/delete.go index 20f0d3bd4..2dafa6fc5 100644 --- a/internal/cmd/database/delete.go +++ b/internal/cmd/database/delete.go @@ -8,7 +8,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" diff --git a/internal/cmd/database/delete_test.go b/internal/cmd/database/delete_test.go index a10dbc451..3394536a7 100644 --- a/internal/cmd/database/delete_test.go +++ b/internal/cmd/database/delete_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/database/dump.go b/internal/cmd/database/dump.go index 8e40cb369..5eac58b1f 100644 --- a/internal/cmd/database/dump.go +++ b/internal/cmd/database/dump.go @@ -14,9 +14,9 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/dumper" "github.com/planetscale/cli/internal/passwordutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/proxyutil" - ps "github.com/planetscale/planetscale-go/planetscale" _ "github.com/go-sql-driver/mysql" diff --git a/internal/cmd/database/list.go b/internal/cmd/database/list.go index 68d1ce63d..742fadb80 100644 --- a/internal/cmd/database/list.go +++ b/internal/cmd/database/list.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/database/list_test.go b/internal/cmd/database/list_test.go index 8a6a020ce..da01f6fc7 100644 --- a/internal/cmd/database/list_test.go +++ b/internal/cmd/database/list_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/database/restore.go b/internal/cmd/database/restore.go index 84823ad9f..78c959abe 100644 --- a/internal/cmd/database/restore.go +++ b/internal/cmd/database/restore.go @@ -10,9 +10,9 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/dumper" "github.com/planetscale/cli/internal/passwordutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/proxyutil" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" diff --git a/internal/cmd/database/show.go b/internal/cmd/database/show.go index aaae04e4e..c21759266 100644 --- a/internal/cmd/database/show.go +++ b/internal/cmd/database/show.go @@ -6,7 +6,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/database/show_test.go b/internal/cmd/database/show_test.go index 8b357e508..942f90636 100644 --- a/internal/cmd/database/show_test.go +++ b/internal/cmd/database/show_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/dataimports/cancel.go b/internal/cmd/dataimports/cancel.go index 1318c3414..8db4e2a87 100644 --- a/internal/cmd/dataimports/cancel.go +++ b/internal/cmd/dataimports/cancel.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/cancel_test.go b/internal/cmd/dataimports/cancel_test.go index 444468286..297059415 100644 --- a/internal/cmd/dataimports/cancel_test.go +++ b/internal/cmd/dataimports/cancel_test.go @@ -12,8 +12,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestImports_Cancel_FailsIfNoImport(t *testing.T) { diff --git a/internal/cmd/dataimports/detach.go b/internal/cmd/dataimports/detach.go index 54cbe1d59..77b2902e7 100644 --- a/internal/cmd/dataimports/detach.go +++ b/internal/cmd/dataimports/detach.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/get.go b/internal/cmd/dataimports/get.go index 7ef50ab9f..74625a72f 100644 --- a/internal/cmd/dataimports/get.go +++ b/internal/cmd/dataimports/get.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/lint.go b/internal/cmd/dataimports/lint.go index 75871a6fc..902ab3f01 100644 --- a/internal/cmd/dataimports/lint.go +++ b/internal/cmd/dataimports/lint.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/lint_test.go b/internal/cmd/dataimports/lint_test.go index dce932698..f0192bbf2 100644 --- a/internal/cmd/dataimports/lint_test.go +++ b/internal/cmd/dataimports/lint_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestImports_LintDatabase_Success(t *testing.T) { diff --git a/internal/cmd/dataimports/makeplanetscaleprimary.go b/internal/cmd/dataimports/makeplanetscaleprimary.go index ee9722e14..6f514d068 100644 --- a/internal/cmd/dataimports/makeplanetscaleprimary.go +++ b/internal/cmd/dataimports/makeplanetscaleprimary.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/makeplanetscaleprimary_test.go b/internal/cmd/dataimports/makeplanetscaleprimary_test.go index 65d303939..731742ee8 100644 --- a/internal/cmd/dataimports/makeplanetscaleprimary_test.go +++ b/internal/cmd/dataimports/makeplanetscaleprimary_test.go @@ -12,8 +12,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestImports_MakePrimary_FailsIfNoImport(t *testing.T) { diff --git a/internal/cmd/dataimports/makeplanetscalereplica.go b/internal/cmd/dataimports/makeplanetscalereplica.go index 113181507..e56b5c177 100644 --- a/internal/cmd/dataimports/makeplanetscalereplica.go +++ b/internal/cmd/dataimports/makeplanetscalereplica.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/makeplanetscalereplica_test.go b/internal/cmd/dataimports/makeplanetscalereplica_test.go index 1cce22a70..20e3db0a0 100644 --- a/internal/cmd/dataimports/makeplanetscalereplica_test.go +++ b/internal/cmd/dataimports/makeplanetscalereplica_test.go @@ -12,8 +12,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestImports_MakeReplica_FailsIfNoImport(t *testing.T) { diff --git a/internal/cmd/dataimports/printer.go b/internal/cmd/dataimports/printer.go index 4a7810048..c4f036f1a 100644 --- a/internal/cmd/dataimports/printer.go +++ b/internal/cmd/dataimports/printer.go @@ -3,8 +3,8 @@ package dataimports import ( "strings" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func PrintDataImport(p *printer.Printer, di ps.DataImport) { diff --git a/internal/cmd/dataimports/start.go b/internal/cmd/dataimports/start.go index fed240ae6..8777cd9ba 100644 --- a/internal/cmd/dataimports/start.go +++ b/internal/cmd/dataimports/start.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/dataimports/start_test.go b/internal/cmd/dataimports/start_test.go index 7e461887e..08ad70a96 100644 --- a/internal/cmd/dataimports/start_test.go +++ b/internal/cmd/dataimports/start_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestStart_LintDatabase_Success(t *testing.T) { diff --git a/internal/cmd/deployrequest/apply.go b/internal/cmd/deployrequest/apply.go index 1d849ecab..769f31ad5 100644 --- a/internal/cmd/deployrequest/apply.go +++ b/internal/cmd/deployrequest/apply.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/apply_test.go b/internal/cmd/deployrequest/apply_test.go index 5ba97b33a..29f6a1aca 100644 --- a/internal/cmd/deployrequest/apply_test.go +++ b/internal/cmd/deployrequest/apply_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_ApplyCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/cancel.go b/internal/cmd/deployrequest/cancel.go index 0cae0de28..6326e5f6c 100644 --- a/internal/cmd/deployrequest/cancel.go +++ b/internal/cmd/deployrequest/cancel.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/cancel_test.go b/internal/cmd/deployrequest/cancel_test.go index f4554e54d..7f44767d5 100644 --- a/internal/cmd/deployrequest/cancel_test.go +++ b/internal/cmd/deployrequest/cancel_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_CancelCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/close.go b/internal/cmd/deployrequest/close.go index 9c0b66d78..5d46e8d42 100644 --- a/internal/cmd/deployrequest/close.go +++ b/internal/cmd/deployrequest/close.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/close_test.go b/internal/cmd/deployrequest/close_test.go index 87f23aa2d..3e1b1a531 100644 --- a/internal/cmd/deployrequest/close_test.go +++ b/internal/cmd/deployrequest/close_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_CloseCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/create.go b/internal/cmd/deployrequest/create.go index 9e22a216c..cc4bdf351 100644 --- a/internal/cmd/deployrequest/create.go +++ b/internal/cmd/deployrequest/create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/create_test.go b/internal/cmd/deployrequest/create_test.go index 63e4830d6..eaa06efa7 100644 --- a/internal/cmd/deployrequest/create_test.go +++ b/internal/cmd/deployrequest/create_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_CreateCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/deploy.go b/internal/cmd/deployrequest/deploy.go index 416bcfc45..863103fc6 100644 --- a/internal/cmd/deployrequest/deploy.go +++ b/internal/cmd/deployrequest/deploy.go @@ -8,8 +8,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/deploy_test.go b/internal/cmd/deployrequest/deploy_test.go index 2cef6cf94..bbaf3650a 100644 --- a/internal/cmd/deployrequest/deploy_test.go +++ b/internal/cmd/deployrequest/deploy_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_DeployCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/diff.go b/internal/cmd/deployrequest/diff.go index 5c7ff4dad..e882678f3 100644 --- a/internal/cmd/deployrequest/diff.go +++ b/internal/cmd/deployrequest/diff.go @@ -9,8 +9,8 @@ import ( "github.com/fatih/color" "github.com/pkg/browser" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/diff_test.go b/internal/cmd/deployrequest/diff_test.go index 498ef4f68..6e000d9d0 100644 --- a/internal/cmd/deployrequest/diff_test.go +++ b/internal/cmd/deployrequest/diff_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_DiffCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/dr.go b/internal/cmd/deployrequest/dr.go index 61f4ea5d6..99e792fc4 100644 --- a/internal/cmd/deployrequest/dr.go +++ b/internal/cmd/deployrequest/dr.go @@ -6,7 +6,7 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/edit.go b/internal/cmd/deployrequest/edit.go index 803b88205..c9ff66c04 100644 --- a/internal/cmd/deployrequest/edit.go +++ b/internal/cmd/deployrequest/edit.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/edit_test.go b/internal/cmd/deployrequest/edit_test.go index 76ea1a0c2..bc2663f87 100644 --- a/internal/cmd/deployrequest/edit_test.go +++ b/internal/cmd/deployrequest/edit_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_EditCmdEnableAutoApply(t *testing.T) { diff --git a/internal/cmd/deployrequest/list.go b/internal/cmd/deployrequest/list.go index 5190ac55f..eba4c9526 100644 --- a/internal/cmd/deployrequest/list.go +++ b/internal/cmd/deployrequest/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/deployrequest/list_test.go b/internal/cmd/deployrequest/list_test.go index 8d6bc5f36..1622e2f59 100644 --- a/internal/cmd/deployrequest/list_test.go +++ b/internal/cmd/deployrequest/list_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_ListCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/revert.go b/internal/cmd/deployrequest/revert.go index f22189831..e1ee2d80c 100644 --- a/internal/cmd/deployrequest/revert.go +++ b/internal/cmd/deployrequest/revert.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/revert_test.go b/internal/cmd/deployrequest/revert_test.go index 6dcfa15f5..7c8aec5db 100644 --- a/internal/cmd/deployrequest/revert_test.go +++ b/internal/cmd/deployrequest/revert_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_RevertCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/review.go b/internal/cmd/deployrequest/review.go index 703e8a1e6..e6e418758 100644 --- a/internal/cmd/deployrequest/review.go +++ b/internal/cmd/deployrequest/review.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/review_test.go b/internal/cmd/deployrequest/review_test.go index 80c91600e..58081d8bf 100644 --- a/internal/cmd/deployrequest/review_test.go +++ b/internal/cmd/deployrequest/review_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_ReviewCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/show.go b/internal/cmd/deployrequest/show.go index 5f26ce8d8..32ac631cd 100644 --- a/internal/cmd/deployrequest/show.go +++ b/internal/cmd/deployrequest/show.go @@ -6,8 +6,8 @@ import ( "github.com/pkg/browser" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/show_test.go b/internal/cmd/deployrequest/show_test.go index e2c53884a..76add8249 100644 --- a/internal/cmd/deployrequest/show_test.go +++ b/internal/cmd/deployrequest/show_test.go @@ -14,7 +14,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_ShowCmd(t *testing.T) { diff --git a/internal/cmd/deployrequest/skiprevert.go b/internal/cmd/deployrequest/skiprevert.go index 378675922..7c695a9a1 100644 --- a/internal/cmd/deployrequest/skiprevert.go +++ b/internal/cmd/deployrequest/skiprevert.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/deployrequest/skiprevert_test.go b/internal/cmd/deployrequest/skiprevert_test.go index ab3936817..ea873718f 100644 --- a/internal/cmd/deployrequest/skiprevert_test.go +++ b/internal/cmd/deployrequest/skiprevert_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestDeployRequest_SkipRevertCmd(t *testing.T) { diff --git a/internal/cmd/importcmd/d1.go b/internal/cmd/importcmd/d1.go index 2d8f90586..9d26acc76 100644 --- a/internal/cmd/importcmd/d1.go +++ b/internal/cmd/importcmd/d1.go @@ -5,7 +5,7 @@ import ( "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/import/d1" diff --git a/internal/cmd/importcmd/d1_complete_test.go b/internal/cmd/importcmd/d1_complete_test.go index d45642966..8871113a8 100644 --- a/internal/cmd/importcmd/d1_complete_test.go +++ b/internal/cmd/importcmd/d1_complete_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" diff --git a/internal/cmd/importcmd/d1_test.go b/internal/cmd/importcmd/d1_test.go index b7e3f80d1..8a7aa5338 100644 --- a/internal/cmd/importcmd/d1_test.go +++ b/internal/cmd/importcmd/d1_test.go @@ -13,8 +13,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/import/d1" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func newD1TestHelper(t *testing.T) (*cmdutil.Helper, *bytes.Buffer) { diff --git a/internal/cmd/keyspace/create.go b/internal/cmd/keyspace/create.go index dde3b2251..c86b41dd3 100644 --- a/internal/cmd/keyspace/create.go +++ b/internal/cmd/keyspace/create.go @@ -7,8 +7,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/create_test.go b/internal/cmd/keyspace/create_test.go index 750725ce2..c8dc665df 100644 --- a/internal/cmd/keyspace/create_test.go +++ b/internal/cmd/keyspace/create_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_CreateCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/keyspace.go b/internal/cmd/keyspace/keyspace.go index edfb6aa43..70fdda323 100644 --- a/internal/cmd/keyspace/keyspace.go +++ b/internal/cmd/keyspace/keyspace.go @@ -4,7 +4,7 @@ import ( "encoding/json" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/list.go b/internal/cmd/keyspace/list.go index cc37d4f29..4931c8464 100644 --- a/internal/cmd/keyspace/list.go +++ b/internal/cmd/keyspace/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/list_test.go b/internal/cmd/keyspace/list_test.go index a0cbbf7be..ceb3eb417 100644 --- a/internal/cmd/keyspace/list_test.go +++ b/internal/cmd/keyspace/list_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_ListCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/resize.go b/internal/cmd/keyspace/resize.go index ed7b642ab..365e4c3c4 100644 --- a/internal/cmd/keyspace/resize.go +++ b/internal/cmd/keyspace/resize.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/resize_cancel.go b/internal/cmd/keyspace/resize_cancel.go index 2289350f4..1c3128751 100644 --- a/internal/cmd/keyspace/resize_cancel.go +++ b/internal/cmd/keyspace/resize_cancel.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/resize_cancel_test.go b/internal/cmd/keyspace/resize_cancel_test.go index 1c0e63ea9..807d900ff 100644 --- a/internal/cmd/keyspace/resize_cancel_test.go +++ b/internal/cmd/keyspace/resize_cancel_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_ResizeCancelCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/resize_status.go b/internal/cmd/keyspace/resize_status.go index f408d0dfe..4ee3dbee8 100644 --- a/internal/cmd/keyspace/resize_status.go +++ b/internal/cmd/keyspace/resize_status.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/resize_status_test.go b/internal/cmd/keyspace/resize_status_test.go index dddf58b51..fe14a557d 100644 --- a/internal/cmd/keyspace/resize_status_test.go +++ b/internal/cmd/keyspace/resize_status_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_ResizeStatusCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/resize_test.go b/internal/cmd/keyspace/resize_test.go index dcf1620a0..2558e6219 100644 --- a/internal/cmd/keyspace/resize_test.go +++ b/internal/cmd/keyspace/resize_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_ResizeCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/rollout_status.go b/internal/cmd/keyspace/rollout_status.go index f304a152c..4d4a4bf03 100644 --- a/internal/cmd/keyspace/rollout_status.go +++ b/internal/cmd/keyspace/rollout_status.go @@ -6,8 +6,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/rollout_status_test.go b/internal/cmd/keyspace/rollout_status_test.go index b9c83e275..4a447a034 100644 --- a/internal/cmd/keyspace/rollout_status_test.go +++ b/internal/cmd/keyspace/rollout_status_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_RolloutStatusCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/settings.go b/internal/cmd/keyspace/settings.go index f1169c4b6..11a9b4d85 100644 --- a/internal/cmd/keyspace/settings.go +++ b/internal/cmd/keyspace/settings.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/settings_test.go b/internal/cmd/keyspace/settings_test.go index be702bcec..f71ccb55a 100644 --- a/internal/cmd/keyspace/settings_test.go +++ b/internal/cmd/keyspace/settings_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_SettingsCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/show.go b/internal/cmd/keyspace/show.go index 9d1d97bc2..d8a9fce26 100644 --- a/internal/cmd/keyspace/show.go +++ b/internal/cmd/keyspace/show.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/show_test.go b/internal/cmd/keyspace/show_test.go index 44cdaf806..75e680f8f 100644 --- a/internal/cmd/keyspace/show_test.go +++ b/internal/cmd/keyspace/show_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_ShowCmd(t *testing.T) { diff --git a/internal/cmd/keyspace/update_settings.go b/internal/cmd/keyspace/update_settings.go index 7290325fe..e92bef54d 100644 --- a/internal/cmd/keyspace/update_settings.go +++ b/internal/cmd/keyspace/update_settings.go @@ -6,8 +6,8 @@ import ( "github.com/charmbracelet/huh" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/update_settings_test.go b/internal/cmd/keyspace/update_settings_test.go index 7253b005b..fe2cccbf1 100644 --- a/internal/cmd/keyspace/update_settings_test.go +++ b/internal/cmd/keyspace/update_settings_test.go @@ -11,8 +11,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_UpdateSettingsCmd_OnlyVReplicationFlags(t *testing.T) { diff --git a/internal/cmd/keyspace/vschema.go b/internal/cmd/keyspace/vschema.go index 4a056e18e..0ed59a736 100644 --- a/internal/cmd/keyspace/vschema.go +++ b/internal/cmd/keyspace/vschema.go @@ -7,8 +7,8 @@ import ( "os" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/keyspace/vschema_test.go b/internal/cmd/keyspace/vschema_test.go index c9e00c374..3b36daead 100644 --- a/internal/cmd/keyspace/vschema_test.go +++ b/internal/cmd/keyspace/vschema_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestKeyspace_VSchemaShowCmd(t *testing.T) { diff --git a/internal/cmd/mcp/server_handlers.go b/internal/cmd/mcp/server_handlers.go index c5f6db0d6..fd6c110e7 100644 --- a/internal/cmd/mcp/server_handlers.go +++ b/internal/cmd/mcp/server_handlers.go @@ -10,7 +10,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/planetscale/cli/internal/cmdutil" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "golang.org/x/oauth2" ) diff --git a/internal/cmd/mcp/server_helpers.go b/internal/cmd/mcp/server_helpers.go index e908d54aa..b711bac18 100644 --- a/internal/cmd/mcp/server_helpers.go +++ b/internal/cmd/mcp/server_helpers.go @@ -11,9 +11,9 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/passwordutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/proxyutil" "github.com/planetscale/cli/internal/roleutil" - "github.com/planetscale/planetscale-go/planetscale" "github.com/planetscale/psdbproxy" "vitess.io/vitess/go/mysql" ) diff --git a/internal/cmd/org/list_test.go b/internal/cmd/org/list_test.go index c34e9d276..e87f78387 100644 --- a/internal/cmd/org/list_test.go +++ b/internal/cmd/org/list_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/org/org.go b/internal/cmd/org/org.go index 897f32647..df0fabf79 100644 --- a/internal/cmd/org/org.go +++ b/internal/cmd/org/org.go @@ -3,8 +3,8 @@ package org import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/org/switch.go b/internal/cmd/org/switch.go index e446c88ef..2b398fa1d 100644 --- a/internal/cmd/org/switch.go +++ b/internal/cmd/org/switch.go @@ -8,7 +8,7 @@ import ( "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" survey "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" diff --git a/internal/cmd/org/switch_test.go b/internal/cmd/org/switch_test.go index 44ce25c55..b1a16e007 100644 --- a/internal/cmd/org/switch_test.go +++ b/internal/cmd/org/switch_test.go @@ -11,9 +11,9 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/testutil" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/password/create.go b/internal/cmd/password/create.go index 505bcc16b..943c4e437 100644 --- a/internal/cmd/password/create.go +++ b/internal/cmd/password/create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/password/create_test.go b/internal/cmd/password/create_test.go index 23527495c..367117851 100644 --- a/internal/cmd/password/create_test.go +++ b/internal/cmd/password/create_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" "github.com/stretchr/testify/require" diff --git a/internal/cmd/password/delete.go b/internal/cmd/password/delete.go index e7fce5c73..d7c7cb15f 100644 --- a/internal/cmd/password/delete.go +++ b/internal/cmd/password/delete.go @@ -8,7 +8,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" diff --git a/internal/cmd/password/delete_test.go b/internal/cmd/password/delete_test.go index 91e8a54db..dc7b9c452 100644 --- a/internal/cmd/password/delete_test.go +++ b/internal/cmd/password/delete_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/password/list.go b/internal/cmd/password/list.go index b82d9d35e..b03fccaaa 100644 --- a/internal/cmd/password/list.go +++ b/internal/cmd/password/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/password/list_test.go b/internal/cmd/password/list_test.go index ccdfb99f5..d4b2ef71c 100644 --- a/internal/cmd/password/list_test.go +++ b/internal/cmd/password/list_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/password/password.go b/internal/cmd/password/password.go index 9cc15e47b..4c34b25ed 100644 --- a/internal/cmd/password/password.go +++ b/internal/cmd/password/password.go @@ -9,7 +9,7 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // PasswordCmd handles branch passwords. diff --git a/internal/cmd/password/renew.go b/internal/cmd/password/renew.go index 86bdcca04..4c99ced10 100644 --- a/internal/cmd/password/renew.go +++ b/internal/cmd/password/renew.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/ping/ping.go b/internal/cmd/ping/ping.go index 9a6423bdb..64a165174 100644 --- a/internal/cmd/ping/ping.go +++ b/internal/cmd/ping/ping.go @@ -15,7 +15,7 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" "github.com/spf13/pflag" "golang.org/x/exp/constraints" diff --git a/internal/cmd/ping/ping_test.go b/internal/cmd/ping/ping_test.go index 7146aa7cc..737336ca3 100644 --- a/internal/cmd/ping/ping_test.go +++ b/internal/cmd/ping/ping_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/stretchr/testify/require" ) diff --git a/internal/cmd/region/list.go b/internal/cmd/region/list.go index bf916b4d6..4d51c3956 100644 --- a/internal/cmd/region/list.go +++ b/internal/cmd/region/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/region/list_test.go b/internal/cmd/region/list_test.go index 869e1bf19..dad40caf6 100644 --- a/internal/cmd/region/list_test.go +++ b/internal/cmd/region/list_test.go @@ -10,7 +10,7 @@ import ( "github.com/planetscale/cli/internal/mock" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/region/region.go b/internal/cmd/region/region.go index b862a7c89..af98490be 100644 --- a/internal/cmd/region/region.go +++ b/internal/cmd/region/region.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/create.go b/internal/cmd/role/create.go index b47a37706..20f715a58 100644 --- a/internal/cmd/role/create.go +++ b/internal/cmd/role/create.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/create_test.go b/internal/cmd/role/create_test.go index 02ed0d143..ad203d7a0 100644 --- a/internal/cmd/role/create_test.go +++ b/internal/cmd/role/create_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/role/delete.go b/internal/cmd/role/delete.go index f5eeba572..d71c3e5fb 100644 --- a/internal/cmd/role/delete.go +++ b/internal/cmd/role/delete.go @@ -8,8 +8,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/get.go b/internal/cmd/role/get.go index 146cdb386..3aec8f46f 100644 --- a/internal/cmd/role/get.go +++ b/internal/cmd/role/get.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/list.go b/internal/cmd/role/list.go index b60ee1f38..f96884683 100644 --- a/internal/cmd/role/list.go +++ b/internal/cmd/role/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/pkg/browser" "github.com/spf13/cobra" diff --git a/internal/cmd/role/reassign.go b/internal/cmd/role/reassign.go index 34e0b85c9..a326871e4 100644 --- a/internal/cmd/role/reassign.go +++ b/internal/cmd/role/reassign.go @@ -8,8 +8,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/renew.go b/internal/cmd/role/renew.go index 3eeed8c43..0e3f7249b 100644 --- a/internal/cmd/role/renew.go +++ b/internal/cmd/role/renew.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/reset.go b/internal/cmd/role/reset.go index 5cf7c6393..3b68bb162 100644 --- a/internal/cmd/role/reset.go +++ b/internal/cmd/role/reset.go @@ -8,8 +8,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/reset_default.go b/internal/cmd/role/reset_default.go index 1291465e5..0e289dd86 100644 --- a/internal/cmd/role/reset_default.go +++ b/internal/cmd/role/reset_default.go @@ -10,8 +10,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/role/reset_default_test.go b/internal/cmd/role/reset_default_test.go index 352350d48..b5951ecdf 100644 --- a/internal/cmd/role/reset_default_test.go +++ b/internal/cmd/role/reset_default_test.go @@ -8,8 +8,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" qt "github.com/frankban/quicktest" ) diff --git a/internal/cmd/role/update.go b/internal/cmd/role/update.go index 28a9cf9b6..e6a99af4e 100644 --- a/internal/cmd/role/update.go +++ b/internal/cmd/role/update.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 279463175..8b21e1c5a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -59,7 +59,7 @@ import ( "github.com/planetscale/cli/internal/printer" "github.com/planetscale/cli/internal/update" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" "github.com/spf13/pflag" diff --git a/internal/cmd/shell/shell.go b/internal/cmd/shell/shell.go index 8f54979fc..299c2e91d 100644 --- a/internal/cmd/shell/shell.go +++ b/internal/cmd/shell/shell.go @@ -11,7 +11,7 @@ import ( "github.com/adrg/xdg" "github.com/mitchellh/go-homedir" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" exec "golang.org/x/sys/execabs" diff --git a/internal/cmd/signup/signup.go b/internal/cmd/signup/signup.go index 214653d7e..2de8681c6 100644 --- a/internal/cmd/signup/signup.go +++ b/internal/cmd/signup/signup.go @@ -12,8 +12,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/size/cluster.go b/internal/cmd/size/cluster.go index 06364294b..fe9b42807 100644 --- a/internal/cmd/size/cluster.go +++ b/internal/cmd/size/cluster.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/size/cluster_test.go b/internal/cmd/size/cluster_test.go index 17a1f1cf2..1790823e7 100644 --- a/internal/cmd/size/cluster_test.go +++ b/internal/cmd/size/cluster_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/testutil" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestSizeCluster_ListCmd_DefaultShowsAll(t *testing.T) { diff --git a/internal/cmd/token/addaccess.go b/internal/cmd/token/addaccess.go index 853d33a09..5148aa01f 100644 --- a/internal/cmd/token/addaccess.go +++ b/internal/cmd/token/addaccess.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/addaccess_test.go b/internal/cmd/token/addaccess_test.go index 4cc54b742..438add663 100644 --- a/internal/cmd/token/addaccess_test.go +++ b/internal/cmd/token/addaccess_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestServiceToken_AddAccessCmd(t *testing.T) { diff --git a/internal/cmd/token/create.go b/internal/cmd/token/create.go index 367c4506e..ba9482e0d 100644 --- a/internal/cmd/token/create.go +++ b/internal/cmd/token/create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/create_test.go b/internal/cmd/token/create_test.go index 762bef871..d9ec6d7f2 100644 --- a/internal/cmd/token/create_test.go +++ b/internal/cmd/token/create_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestServiceToken_CreateCmd(t *testing.T) { diff --git a/internal/cmd/token/delete.go b/internal/cmd/token/delete.go index a1700a803..64a56c556 100644 --- a/internal/cmd/token/delete.go +++ b/internal/cmd/token/delete.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/delete_test.go b/internal/cmd/token/delete_test.go index d96baa6f6..a71506cfb 100644 --- a/internal/cmd/token/delete_test.go +++ b/internal/cmd/token/delete_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestServiceToken_DeleteCmd(t *testing.T) { diff --git a/internal/cmd/token/deleteaccess.go b/internal/cmd/token/deleteaccess.go index 729a9ea59..9e51117b5 100644 --- a/internal/cmd/token/deleteaccess.go +++ b/internal/cmd/token/deleteaccess.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/deleteaccess_test.go b/internal/cmd/token/deleteaccess_test.go index 091b57a5e..185884689 100644 --- a/internal/cmd/token/deleteaccess_test.go +++ b/internal/cmd/token/deleteaccess_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestServiceToken_DeleteAccessCmd(t *testing.T) { diff --git a/internal/cmd/token/list.go b/internal/cmd/token/list.go index d9687cced..af64d3259 100644 --- a/internal/cmd/token/list.go +++ b/internal/cmd/token/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/list_test.go b/internal/cmd/token/list_test.go index f9c9efa0c..f1e91d518 100644 --- a/internal/cmd/token/list_test.go +++ b/internal/cmd/token/list_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestServiceToken_ListCmd(t *testing.T) { diff --git a/internal/cmd/token/showaccess.go b/internal/cmd/token/showaccess.go index 88505a657..b193bdbaa 100644 --- a/internal/cmd/token/showaccess.go +++ b/internal/cmd/token/showaccess.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/token/showaccess_test.go b/internal/cmd/token/showaccess_test.go index 32644df28..60e5a99fe 100644 --- a/internal/cmd/token/showaccess_test.go +++ b/internal/cmd/token/showaccess_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestServiceToken_ShowAccess(t *testing.T) { diff --git a/internal/cmd/token/token.go b/internal/cmd/token/token.go index 7d30540ec..1fc4dcbf4 100644 --- a/internal/cmd/token/token.go +++ b/internal/cmd/token/token.go @@ -5,8 +5,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/budget_create.go b/internal/cmd/trafficcontrol/budget_create.go index 15981022f..7ee42874f 100644 --- a/internal/cmd/trafficcontrol/budget_create.go +++ b/internal/cmd/trafficcontrol/budget_create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/budget_create_test.go b/internal/cmd/trafficcontrol/budget_create_test.go index 03f485d40..bfe4e4711 100644 --- a/internal/cmd/trafficcontrol/budget_create_test.go +++ b/internal/cmd/trafficcontrol/budget_create_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBudgetCreateCmd(t *testing.T) { diff --git a/internal/cmd/trafficcontrol/budget_delete.go b/internal/cmd/trafficcontrol/budget_delete.go index b47da0a1d..29720db34 100644 --- a/internal/cmd/trafficcontrol/budget_delete.go +++ b/internal/cmd/trafficcontrol/budget_delete.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/budget_delete_test.go b/internal/cmd/trafficcontrol/budget_delete_test.go index dc667a690..6453ef7c9 100644 --- a/internal/cmd/trafficcontrol/budget_delete_test.go +++ b/internal/cmd/trafficcontrol/budget_delete_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBudgetDeleteCmd(t *testing.T) { diff --git a/internal/cmd/trafficcontrol/budget_show.go b/internal/cmd/trafficcontrol/budget_show.go index 313dde5d0..b7d947448 100644 --- a/internal/cmd/trafficcontrol/budget_show.go +++ b/internal/cmd/trafficcontrol/budget_show.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/budget_show_test.go b/internal/cmd/trafficcontrol/budget_show_test.go index c4ff804bb..1e701828b 100644 --- a/internal/cmd/trafficcontrol/budget_show_test.go +++ b/internal/cmd/trafficcontrol/budget_show_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) var ( diff --git a/internal/cmd/trafficcontrol/budget_update.go b/internal/cmd/trafficcontrol/budget_update.go index 1e1622fbc..5972fc499 100644 --- a/internal/cmd/trafficcontrol/budget_update.go +++ b/internal/cmd/trafficcontrol/budget_update.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/budget_update_test.go b/internal/cmd/trafficcontrol/budget_update_test.go index 4e33bab4a..d2c073fee 100644 --- a/internal/cmd/trafficcontrol/budget_update_test.go +++ b/internal/cmd/trafficcontrol/budget_update_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestBudgetUpdateCmd(t *testing.T) { diff --git a/internal/cmd/trafficcontrol/rule_create.go b/internal/cmd/trafficcontrol/rule_create.go index afb0cdacf..ffb05128b 100644 --- a/internal/cmd/trafficcontrol/rule_create.go +++ b/internal/cmd/trafficcontrol/rule_create.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/rule_create_test.go b/internal/cmd/trafficcontrol/rule_create_test.go index 354abb95f..686418fe0 100644 --- a/internal/cmd/trafficcontrol/rule_create_test.go +++ b/internal/cmd/trafficcontrol/rule_create_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) var ( diff --git a/internal/cmd/trafficcontrol/rule_delete.go b/internal/cmd/trafficcontrol/rule_delete.go index e994e850a..bf3ff5d2d 100644 --- a/internal/cmd/trafficcontrol/rule_delete.go +++ b/internal/cmd/trafficcontrol/rule_delete.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/trafficcontrol/rule_delete_test.go b/internal/cmd/trafficcontrol/rule_delete_test.go index e5ea94fe8..b10fb0608 100644 --- a/internal/cmd/trafficcontrol/rule_delete_test.go +++ b/internal/cmd/trafficcontrol/rule_delete_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestRuleDeleteCmd(t *testing.T) { diff --git a/internal/cmd/trafficcontrol/traffic_control.go b/internal/cmd/trafficcontrol/traffic_control.go index f80e43655..6aa7c34bf 100644 --- a/internal/cmd/trafficcontrol/traffic_control.go +++ b/internal/cmd/trafficcontrol/traffic_control.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/create.go b/internal/cmd/webhook/create.go index 433ebcdd2..e6fa2c5f4 100644 --- a/internal/cmd/webhook/create.go +++ b/internal/cmd/webhook/create.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/create_test.go b/internal/cmd/webhook/create_test.go index a0ec4cfcd..389c903d4 100644 --- a/internal/cmd/webhook/create_test.go +++ b/internal/cmd/webhook/create_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_CreateCmd(t *testing.T) { diff --git a/internal/cmd/webhook/delete.go b/internal/cmd/webhook/delete.go index db5f3f581..99835b1d1 100644 --- a/internal/cmd/webhook/delete.go +++ b/internal/cmd/webhook/delete.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/delete_test.go b/internal/cmd/webhook/delete_test.go index b5be2a427..9cfcecf5d 100644 --- a/internal/cmd/webhook/delete_test.go +++ b/internal/cmd/webhook/delete_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_DeleteCmd(t *testing.T) { diff --git a/internal/cmd/webhook/list.go b/internal/cmd/webhook/list.go index 72d5ad9f0..557f3e033 100644 --- a/internal/cmd/webhook/list.go +++ b/internal/cmd/webhook/list.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/list_test.go b/internal/cmd/webhook/list_test.go index 03b1a6934..302235bd1 100644 --- a/internal/cmd/webhook/list_test.go +++ b/internal/cmd/webhook/list_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_ListCmd(t *testing.T) { diff --git a/internal/cmd/webhook/show.go b/internal/cmd/webhook/show.go index f1b555e70..3da4093b3 100644 --- a/internal/cmd/webhook/show.go +++ b/internal/cmd/webhook/show.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/show_test.go b/internal/cmd/webhook/show_test.go index 64ba8094d..8b4c64f08 100644 --- a/internal/cmd/webhook/show_test.go +++ b/internal/cmd/webhook/show_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_ShowCmd(t *testing.T) { diff --git a/internal/cmd/webhook/test.go b/internal/cmd/webhook/test.go index 2dc1cc57f..3e264f4c1 100644 --- a/internal/cmd/webhook/test.go +++ b/internal/cmd/webhook/test.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/test_test.go b/internal/cmd/webhook/test_test.go index 34d1a960b..54103a53d 100644 --- a/internal/cmd/webhook/test_test.go +++ b/internal/cmd/webhook/test_test.go @@ -9,8 +9,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_TestCmd(t *testing.T) { diff --git a/internal/cmd/webhook/update.go b/internal/cmd/webhook/update.go index a203f4be7..a1521f60e 100644 --- a/internal/cmd/webhook/update.go +++ b/internal/cmd/webhook/update.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/webhook/update_test.go b/internal/cmd/webhook/update_test.go index 24a14a12f..0293fb46b 100644 --- a/internal/cmd/webhook/update_test.go +++ b/internal/cmd/webhook/update_test.go @@ -10,8 +10,8 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" ) func TestWebhook_UpdateCmd(t *testing.T) { diff --git a/internal/cmd/webhook/webhook.go b/internal/cmd/webhook/webhook.go index 1a77c4072..8404dd28e 100644 --- a/internal/cmd/webhook/webhook.go +++ b/internal/cmd/webhook/webhook.go @@ -5,8 +5,8 @@ import ( "strings" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/cancel.go b/internal/cmd/workflow/cancel.go index b91ebf52c..b6bbcbefe 100644 --- a/internal/cmd/workflow/cancel.go +++ b/internal/cmd/workflow/cancel.go @@ -9,8 +9,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/cancel_test.go b/internal/cmd/workflow/cancel_test.go index 0a28320b5..7d61b6d76 100644 --- a/internal/cmd/workflow/cancel_test.go +++ b/internal/cmd/workflow/cancel_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_CancelCmd(t *testing.T) { diff --git a/internal/cmd/workflow/complete.go b/internal/cmd/workflow/complete.go index 391f87b46..6602cdda5 100644 --- a/internal/cmd/workflow/complete.go +++ b/internal/cmd/workflow/complete.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/complete_test.go b/internal/cmd/workflow/complete_test.go index acbfff058..2800f7829 100644 --- a/internal/cmd/workflow/complete_test.go +++ b/internal/cmd/workflow/complete_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_CompleteCmd(t *testing.T) { diff --git a/internal/cmd/workflow/create.go b/internal/cmd/workflow/create.go index 4e3731305..c247c5918 100644 --- a/internal/cmd/workflow/create.go +++ b/internal/cmd/workflow/create.go @@ -10,7 +10,7 @@ import ( "github.com/planetscale/cli/internal/printer" "github.com/spf13/cobra" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type createFlags struct { diff --git a/internal/cmd/workflow/create_test.go b/internal/cmd/workflow/create_test.go index 547260025..85635ed33 100644 --- a/internal/cmd/workflow/create_test.go +++ b/internal/cmd/workflow/create_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_CreateCmd(t *testing.T) { diff --git a/internal/cmd/workflow/cutover.go b/internal/cmd/workflow/cutover.go index 5609030f7..d422df5c5 100644 --- a/internal/cmd/workflow/cutover.go +++ b/internal/cmd/workflow/cutover.go @@ -9,8 +9,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/cutover_test.go b/internal/cmd/workflow/cutover_test.go index 6bb10b2ac..c32833c66 100644 --- a/internal/cmd/workflow/cutover_test.go +++ b/internal/cmd/workflow/cutover_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_CutoverCmd(t *testing.T) { diff --git a/internal/cmd/workflow/list.go b/internal/cmd/workflow/list.go index bf6ec52e9..b69761e08 100644 --- a/internal/cmd/workflow/list.go +++ b/internal/cmd/workflow/list.go @@ -6,8 +6,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/list_test.go b/internal/cmd/workflow/list_test.go index 4e3f6b1bf..0b0345aa6 100644 --- a/internal/cmd/workflow/list_test.go +++ b/internal/cmd/workflow/list_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_ListCmd(t *testing.T) { diff --git a/internal/cmd/workflow/retry.go b/internal/cmd/workflow/retry.go index 8e41b34bd..6737fec57 100644 --- a/internal/cmd/workflow/retry.go +++ b/internal/cmd/workflow/retry.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/retry_test.go b/internal/cmd/workflow/retry_test.go index 0d02f96a6..06ebe6365 100644 --- a/internal/cmd/workflow/retry_test.go +++ b/internal/cmd/workflow/retry_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_RetryCmd(t *testing.T) { diff --git a/internal/cmd/workflow/reverse_cutover.go b/internal/cmd/workflow/reverse_cutover.go index 61b22387c..fa46ddb7e 100644 --- a/internal/cmd/workflow/reverse_cutover.go +++ b/internal/cmd/workflow/reverse_cutover.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/reverse_cutover_test.go b/internal/cmd/workflow/reverse_cutover_test.go index c8033da61..b3b835f34 100644 --- a/internal/cmd/workflow/reverse_cutover_test.go +++ b/internal/cmd/workflow/reverse_cutover_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_ReverseCutoverCmd(t *testing.T) { diff --git a/internal/cmd/workflow/reverse_traffic.go b/internal/cmd/workflow/reverse_traffic.go index d7eb0aba7..a1fec8d92 100644 --- a/internal/cmd/workflow/reverse_traffic.go +++ b/internal/cmd/workflow/reverse_traffic.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/reverse_traffic_test.go b/internal/cmd/workflow/reverse_traffic_test.go index 2633ebf4d..cf35a1745 100644 --- a/internal/cmd/workflow/reverse_traffic_test.go +++ b/internal/cmd/workflow/reverse_traffic_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_ReverseTrafficCmd(t *testing.T) { diff --git a/internal/cmd/workflow/show.go b/internal/cmd/workflow/show.go index 196ef9347..07636b5c8 100644 --- a/internal/cmd/workflow/show.go +++ b/internal/cmd/workflow/show.go @@ -7,8 +7,8 @@ import ( "time" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/show_test.go b/internal/cmd/workflow/show_test.go index 7f7a7b83c..69818ae57 100644 --- a/internal/cmd/workflow/show_test.go +++ b/internal/cmd/workflow/show_test.go @@ -11,7 +11,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_ShowCmd(t *testing.T) { diff --git a/internal/cmd/workflow/switch_traffic.go b/internal/cmd/workflow/switch_traffic.go index 69301afb7..361e4ded6 100644 --- a/internal/cmd/workflow/switch_traffic.go +++ b/internal/cmd/workflow/switch_traffic.go @@ -9,8 +9,8 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/switch_traffic_test.go b/internal/cmd/workflow/switch_traffic_test.go index 1b39a7aae..5158187b1 100644 --- a/internal/cmd/workflow/switch_traffic_test.go +++ b/internal/cmd/workflow/switch_traffic_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_SwitchTrafficCmd_Primaries(t *testing.T) { diff --git a/internal/cmd/workflow/verify_data.go b/internal/cmd/workflow/verify_data.go index 2ed2e152d..32bffcf57 100644 --- a/internal/cmd/workflow/verify_data.go +++ b/internal/cmd/workflow/verify_data.go @@ -5,8 +5,8 @@ import ( "strconv" "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmd/workflow/verify_data_test.go b/internal/cmd/workflow/verify_data_test.go index 572243b14..a9e6e2e3a 100644 --- a/internal/cmd/workflow/verify_data_test.go +++ b/internal/cmd/workflow/verify_data_test.go @@ -12,7 +12,7 @@ import ( "github.com/planetscale/cli/internal/printer" qt "github.com/frankban/quicktest" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestWorkflow_VerifyDataCmd(t *testing.T) { diff --git a/internal/cmdutil/auth.go b/internal/cmdutil/auth.go index bb3a6665c..688ce5252 100644 --- a/internal/cmdutil/auth.go +++ b/internal/cmdutil/auth.go @@ -7,7 +7,7 @@ import ( "os" "github.com/planetscale/cli/internal/config" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go index 133b6df7c..02fbdee3d 100644 --- a/internal/cmdutil/cmdutil.go +++ b/internal/cmdutil/cmdutil.go @@ -16,7 +16,7 @@ import ( "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" "github.com/spf13/pflag" diff --git a/internal/cmdutil/completions.go b/internal/cmdutil/completions.go index ea3fb5215..809444cf7 100644 --- a/internal/cmdutil/completions.go +++ b/internal/cmdutil/completions.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/spf13/cobra" ) diff --git a/internal/cmdutil/errors.go b/internal/cmdutil/errors.go index 206c17711..2f3a800dc 100644 --- a/internal/cmdutil/errors.go +++ b/internal/cmdutil/errors.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/planetscale/planetscale-go/planetscale" + "github.com/planetscale/cli/internal/planetscale" ) const ( diff --git a/internal/config/config.go b/internal/config/config.go index 1a793da32..603038754 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/99designs/keyring" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/mitchellh/go-homedir" exec "golang.org/x/sys/execabs" diff --git a/internal/import/d1/import.go b/internal/import/d1/import.go index 11d9d1018..f6ab2de00 100644 --- a/internal/import/d1/import.go +++ b/internal/import/d1/import.go @@ -10,7 +10,7 @@ import ( "strings" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/postgres" "github.com/planetscale/cli/internal/roleutil" diff --git a/internal/import/d1/import_test.go b/internal/import/d1/import_test.go index 1b98602f5..957496d22 100644 --- a/internal/import/d1/import_test.go +++ b/internal/import/d1/import_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type failingImportClient struct{} diff --git a/internal/import/d1/notify.go b/internal/import/d1/notify.go index c13aab8dc..d92d19f98 100644 --- a/internal/import/d1/notify.go +++ b/internal/import/d1/notify.go @@ -8,7 +8,7 @@ import ( "strings" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) const importNotifyTimeout = 3 * time.Second diff --git a/internal/import/d1/notify_test.go b/internal/import/d1/notify_test.go index 60d9ed9df..9981a7f38 100644 --- a/internal/import/d1/notify_test.go +++ b/internal/import/d1/notify_test.go @@ -12,7 +12,7 @@ import ( "testing" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func testNotifyClient(t *testing.T, baseURL string) *ps.Client { diff --git a/internal/import/d1/schema_reset.go b/internal/import/d1/schema_reset.go index 00b8be819..535933ab5 100644 --- a/internal/import/d1/schema_reset.go +++ b/internal/import/d1/schema_reset.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/postgres" ) diff --git a/internal/import/d1/state_test.go b/internal/import/d1/state_test.go index 338d3f542..00ca4af4d 100644 --- a/internal/import/d1/state_test.go +++ b/internal/import/d1/state_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) func TestCompleteRequiresVerifiedPhase(t *testing.T) { diff --git a/internal/mock/auditlog.go b/internal/mock/auditlog.go index 00c7898c6..1e06ba7db 100644 --- a/internal/mock/auditlog.go +++ b/internal/mock/auditlog.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type AuditLogService struct { diff --git a/internal/mock/backup.go b/internal/mock/backup.go index beb066f34..7dd9d65fd 100644 --- a/internal/mock/backup.go +++ b/internal/mock/backup.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type BackupsService struct { diff --git a/internal/mock/branch.go b/internal/mock/branch.go index 4c1adf2da..1fef24d0f 100644 --- a/internal/mock/branch.go +++ b/internal/mock/branch.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type DatabaseBranchesService struct { diff --git a/internal/mock/database.go b/internal/mock/database.go index 70c9e2df1..6ef39e2ff 100644 --- a/internal/mock/database.go +++ b/internal/mock/database.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type DatabaseService struct { diff --git a/internal/mock/dr.go b/internal/mock/dr.go index d699e52c4..eca3609b6 100644 --- a/internal/mock/dr.go +++ b/internal/mock/dr.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type DeployRequestsService struct { diff --git a/internal/mock/imports.go b/internal/mock/imports.go index 91908c0df..b41632e97 100644 --- a/internal/mock/imports.go +++ b/internal/mock/imports.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type DataImportsService struct { diff --git a/internal/mock/keyspace.go b/internal/mock/keyspace.go index 7da10ee47..d99c24888 100644 --- a/internal/mock/keyspace.go +++ b/internal/mock/keyspace.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type KeyspacesService struct { diff --git a/internal/mock/org.go b/internal/mock/org.go index 1744ee125..3e86ddfa5 100644 --- a/internal/mock/org.go +++ b/internal/mock/org.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type OrganizationsService struct { diff --git a/internal/mock/password.go b/internal/mock/password.go index b3ab4da6c..803fa9762 100644 --- a/internal/mock/password.go +++ b/internal/mock/password.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type PasswordsService struct { diff --git a/internal/mock/planned_reparent_shard.go b/internal/mock/planned_reparent_shard.go index 4b116b6b5..56247f3f4 100644 --- a/internal/mock/planned_reparent_shard.go +++ b/internal/mock/planned_reparent_shard.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type PlannedReparentShardService struct { diff --git a/internal/mock/postgres_roles.go b/internal/mock/postgres_roles.go index c57952624..916b22198 100644 --- a/internal/mock/postgres_roles.go +++ b/internal/mock/postgres_roles.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type PostgresRolesService struct { diff --git a/internal/mock/processlist.go b/internal/mock/processlist.go index c5fde9c2c..74cd9df4c 100644 --- a/internal/mock/processlist.go +++ b/internal/mock/processlist.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type ProcesslistService struct { diff --git a/internal/mock/servicetoken.go b/internal/mock/servicetoken.go index e9f688b6a..3c0a8cb4b 100644 --- a/internal/mock/servicetoken.go +++ b/internal/mock/servicetoken.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type ServiceTokenService struct { diff --git a/internal/mock/traffic_budget.go b/internal/mock/traffic_budget.go index def32ff84..c61852d8a 100644 --- a/internal/mock/traffic_budget.go +++ b/internal/mock/traffic_budget.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type TrafficBudgetsService struct { diff --git a/internal/mock/traffic_rule.go b/internal/mock/traffic_rule.go index 4dbfb6652..398926498 100644 --- a/internal/mock/traffic_rule.go +++ b/internal/mock/traffic_rule.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type TrafficRulesService struct { diff --git a/internal/mock/vtctld_general.go b/internal/mock/vtctld_general.go index 3527b7acd..bcfce45c6 100644 --- a/internal/mock/vtctld_general.go +++ b/internal/mock/vtctld_general.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type VtctldService struct { diff --git a/internal/mock/vtctld_lookup_vindex.go b/internal/mock/vtctld_lookup_vindex.go index 657af0019..114ab0c29 100644 --- a/internal/mock/vtctld_lookup_vindex.go +++ b/internal/mock/vtctld_lookup_vindex.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type LookupVindexService struct { diff --git a/internal/mock/vtctld_materialize.go b/internal/mock/vtctld_materialize.go index 80d06b719..4b9b53edf 100644 --- a/internal/mock/vtctld_materialize.go +++ b/internal/mock/vtctld_materialize.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type MaterializeService struct { diff --git a/internal/mock/vtctld_move_tables.go b/internal/mock/vtctld_move_tables.go index 9497b229d..9141f1aed 100644 --- a/internal/mock/vtctld_move_tables.go +++ b/internal/mock/vtctld_move_tables.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type MoveTablesService struct { diff --git a/internal/mock/vtctld_vdiff.go b/internal/mock/vtctld_vdiff.go index 921f3c4f2..10883a733 100644 --- a/internal/mock/vtctld_vdiff.go +++ b/internal/mock/vtctld_vdiff.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type VDiffService struct { diff --git a/internal/mock/webhook.go b/internal/mock/webhook.go index 344146715..65ea6983c 100644 --- a/internal/mock/webhook.go +++ b/internal/mock/webhook.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type WebhooksService struct { diff --git a/internal/mock/workflow.go b/internal/mock/workflow.go index 163b39c1a..82f81de3a 100644 --- a/internal/mock/workflow.go +++ b/internal/mock/workflow.go @@ -3,7 +3,7 @@ package mock import ( "context" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) type WorkflowsService struct { diff --git a/internal/passwordutil/password.go b/internal/passwordutil/password.go index 572cfb947..7beeb2c99 100644 --- a/internal/passwordutil/password.go +++ b/internal/passwordutil/password.go @@ -8,7 +8,7 @@ import ( nanoid "github.com/matoous/go-nanoid/v2" "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) const ( diff --git a/internal/planetscale/audit_logs.go b/internal/planetscale/audit_logs.go new file mode 100644 index 000000000..e3cb6e92c --- /dev/null +++ b/internal/planetscale/audit_logs.go @@ -0,0 +1,143 @@ +package planetscale + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + "time" +) + +// AuditLogEvent represents an audit log's event type +type AuditLogEvent string + +const ( + AuditLogEventBranchCreated AuditLogEvent = "branch.created" + AuditLogEventBranchDeleted AuditLogEvent = "branch.deleted" + AuditLogEventDatabaseCreated AuditLogEvent = "database.created" + AuditLogEventDatabaseDeleted AuditLogEvent = "database.deleted" + AuditLogEventDeployRequestApproved AuditLogEvent = "deploy_request.approved" + AuditLogEventDeployRequestClosed AuditLogEvent = "deploy_request.closed" + AuditLogEventDeployRequestCreated AuditLogEvent = "deploy_request.created" + AuditLogEventDeployRequestDeleted AuditLogEvent = "deploy_request.deleted" + AuditLogEventDeployRequestQueued AuditLogEvent = "deploy_request.queued" + AuditLogEventDeployRequestUnqueued AuditLogEvent = "deploy_request.unqueued" + AuditLogEventIntegrationCreated AuditLogEvent = "integration.created" + AuditLogEventIntegrationDeleted AuditLogEvent = "integration.deleted" + AuditLogEventOrganizationInvitationCreated AuditLogEvent = "organization_invitation.created" + AuditLogEventOrganizationInvitationDeleted AuditLogEvent = "organization_invitation.deleted" + AuditLogEventOrganizationMembershipCreated AuditLogEvent = "organization_membership.created" + AuditLogEventOrganizationJoined AuditLogEvent = "organization.joined" + AuditLogEventOrganizationRemovedMember AuditLogEvent = "organization.removed_member" + AuditLogEventOrganizationDisabledSSO AuditLogEvent = "organization.disabled_sso" + AuditLogEventOrganizationEnabledSSO AuditLogEvent = "organization.enabled_sso" + AuditLogEventOrganizationUpdatedRole AuditLogEvent = "organization.updated_role" + AuditLogEventServiceTokenCreated AuditLogEvent = "service_token.created" + AuditLogEventServiceTokenDeleted AuditLogEvent = "service_token.deleted" + AuditLogEventServiceTokenGrantedAccess AuditLogEvent = "service_token.granted_access" +) + +var _ AuditLogsService = &auditlogsService{} + +// AuditLogsService is an interface for communicating with the PlanetScale +// AuditLogs API endpoints. +type AuditLogsService interface { + List(context.Context, *ListAuditLogsRequest, ...ListOption) (*CursorPaginatedResponse[*AuditLog], error) +} + +// ListAuditLogsRequest encapsulates the request for listing the audit logs of +// an organization. +type ListAuditLogsRequest struct { + Organization string + + // Events can be used to filter out only the given audit log events. + Events []AuditLogEvent +} + +// AuditLog represents a PlanetScale audit log. +type AuditLog struct { + ID string `json:"id"` + Type string `json:"type"` + + ActorID string `json:"actor_id"` + ActorType string `json:"actor_type"` + ActorDisplayName string `json:"actor_display_name"` + + AuditableID string `json:"auditable_id"` + AuditableType string `json:"auditable_type"` + AuditableDisplayName string `json:"auditable_display_name"` + + AuditAction string `json:"audit_action"` + Action string `json:"action"` + + Location string `json:"location"` + RemoteIP string `json:"remote_ip"` + + TargetID string `json:"target_id"` + TargetType string `json:"target_type"` + TargetDisplayName string `json:"target_display_name"` + + Metadata map[string]interface{} `json:"metadata"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type auditlogsService struct { + client *Client +} + +func NewAuditLogsService(client *Client) *auditlogsService { + return &auditlogsService{ + client: client, + } +} + +// WithEventFilters sets filters on a set of list filters from audit log events. +// For example, `audit_action:database.created`, +// `audit_action:database.deleted`, etc. +func WithEventFilters(events []AuditLogEvent) ListOption { + return func(opt *ListOptions) error { + values := opt.URLValues + if len(events) != 0 { + for _, action := range events { + values.Add("filters[]", fmt.Sprintf("audit_action:%s", action)) + } + } + return nil + } +} + +// List returns the audit logs for an organization. +func (o *auditlogsService) List(ctx context.Context, listReq *ListAuditLogsRequest, opts ...ListOption) (*CursorPaginatedResponse[*AuditLog], error) { + if listReq.Organization == "" { + return nil, errors.New("organization is not set") + } + + path := auditlogsAPIPath(listReq.Organization) + + defaultOpts := defaultListOptions(WithEventFilters(listReq.Events)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := o.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for listing audit logs: %w", err) + } + + resp := &CursorPaginatedResponse[*AuditLog]{} + if err := o.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func auditlogsAPIPath(org string) string { + return path.Join(organizationsAPIPath, org, "audit-log") +} diff --git a/internal/planetscale/audit_logs_test.go b/internal/planetscale/audit_logs_test.go new file mode 100644 index 000000000..efaa4aecb --- /dev/null +++ b/internal/planetscale/audit_logs_test.go @@ -0,0 +1,102 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestAuditLogs_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "type": "list", + "has_next": true, + "has_prev": false, + "cursor_start": "ecxuvovgfo95", + "cursor_end": "ecxuvovgfo95", + "data": [ + { + "id": "ecxuvovgfo95", + "type": "AuditLogEvent", + "actor_id": "d4hkujnkswjk", + "actor_type": "User", + "auditable_id": "kbog8qlq6lp4", + "auditable_type": "DeployRequest", + "target_id": "m40xz7x6gvvk", + "target_type": "Database", + "location": "Chicago, IL", + "target_display_name": "planetscale", + "metadata": { + "from": "add-name-to-service-tokens", + "into": "main" + }, + "audit_action": "deploy_request.queued", + "action": "queued", + "actor_display_name": "Elom Gomez", + "auditable_display_name": "deploy request #102", + "remote_ip": "45.19.24.124", + "created_at": "2021-07-19T17:13:45.000Z", + "updated_at": "2021-07-19T17:13:45.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + auditLogs, err := client.AuditLogs.List(ctx, &ListAuditLogsRequest{ + Organization: testOrg, + Events: []AuditLogEvent{ + AuditLogEventBranchDeleted, + AuditLogEventOrganizationJoined, + }, + }) + + auditLogID := "ecxuvovgfo95" + want := &CursorPaginatedResponse[*AuditLog]{ + Data: []*AuditLog{ + { + ID: "ecxuvovgfo95", + Type: "AuditLogEvent", + ActorID: "d4hkujnkswjk", + ActorType: "User", + AuditableID: "kbog8qlq6lp4", + AuditableType: "DeployRequest", + TargetID: "m40xz7x6gvvk", + TargetType: "Database", + Location: "Chicago, IL", + TargetDisplayName: "planetscale", + Metadata: map[string]interface{}{ + "from": "add-name-to-service-tokens", + "into": "main", + }, + AuditAction: "deploy_request.queued", + Action: "queued", + ActorDisplayName: "Elom Gomez", + AuditableDisplayName: "deploy request #102", + RemoteIP: "45.19.24.124", + CreatedAt: time.Date(2021, time.July, 19, 17, 13, 45, 0, time.UTC), + UpdatedAt: time.Date(2021, time.July, 19, 17, 13, 45, 0, time.UTC), + }, + }, + HasNext: true, + HasPrev: false, + CursorStart: &auditLogID, + CursorEnd: &auditLogID, + } + + c.Assert(err, qt.IsNil) + c.Assert(auditLogs, qt.DeepEquals, want) +} diff --git a/internal/planetscale/backups.go b/internal/planetscale/backups.go new file mode 100644 index 000000000..a37fc277c --- /dev/null +++ b/internal/planetscale/backups.go @@ -0,0 +1,145 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type Backup struct { + PublicID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Size int64 `json:"size"` + Actor *Actor `json:"actor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt time.Time `json:"started_at"` + ExpiresAt time.Time `json:"expires_at"` + CompletedAt time.Time `json:"completed_at"` +} + +type backupsResponse struct { + Backups []*Backup `json:"data"` +} + +type CreateBackupRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Name string `json:"name,omitempty"` + RetentionUnit string `json:"retention_unit,omitempty"` + RetentionValue int `json:"retention_value,omitempty"` + Emergency bool `json:"emergency,omitempty"` +} + +type ListBackupsRequest struct { + Organization string + Database string + Branch string +} + +type GetBackupRequest struct { + Organization string + Database string + Branch string + Backup string +} + +type DeleteBackupRequest struct { + Organization string + Database string + Branch string + Backup string +} + +// BackupsService is an interface for communicating with the PlanetScale +// backup API endpoint. +type BackupsService interface { + Create(context.Context, *CreateBackupRequest) (*Backup, error) + List(context.Context, *ListBackupsRequest) ([]*Backup, error) + Get(context.Context, *GetBackupRequest) (*Backup, error) + Delete(context.Context, *DeleteBackupRequest) error +} + +type backupsService struct { + client *Client +} + +var _ BackupsService = &backupsService{} + +func NewBackupsService(client *Client) *backupsService { + return &backupsService{ + client: client, + } +} + +// Creates a new backup for a branch. +func (d *backupsService) Create(ctx context.Context, createReq *CreateBackupRequest) (*Backup, error) { + path := backupsAPIPath(createReq.Organization, createReq.Database, createReq.Branch) + req, err := d.client.newRequest(http.MethodPost, path, createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + backup := &Backup{} + if err := d.client.do(ctx, req, &backup); err != nil { + return nil, err + } + + return backup, nil +} + +// Returns a single backup for a branch. +func (d *backupsService) Get(ctx context.Context, getReq *GetBackupRequest) (*Backup, error) { + path := backupAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Backup) + req, err := d.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + backup := &Backup{} + if err := d.client.do(ctx, req, &backup); err != nil { + return nil, err + } + + return backup, nil +} + +// Returns all of the backups for a branch. +func (d *backupsService) List(ctx context.Context, listReq *ListBackupsRequest) ([]*Backup, error) { + req, err := d.client.newRequest(http.MethodGet, backupsAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + backups := &backupsResponse{} + if err := d.client.do(ctx, req, &backups); err != nil { + return nil, err + } + + return backups.Backups, nil +} + +// Deletes a branch backup. +func (d *backupsService) Delete(ctx context.Context, deleteReq *DeleteBackupRequest) error { + path := backupAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.Backup) + req, err := d.client.newRequest(http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + err = d.client.do(ctx, req, nil) + return err +} + +func backupsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "backups") +} + +func backupAPIPath(org, db, branch, backup string) string { + return path.Join(backupsAPIPath(org, db, branch), backup) +} diff --git a/internal/planetscale/backups_test.go b/internal/planetscale/backups_test.go new file mode 100644 index 000000000..ca4f89174 --- /dev/null +++ b/internal/planetscale/backups_test.go @@ -0,0 +1,147 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testBackup = "planetscale-go-test-backup" + +func TestBackups_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + backup, err := client.Backups.Create(ctx, &CreateBackupRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + want := &Backup{ + PublicID: "planetscale-go-test-backup", + Name: testBackup, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(backup, qt.DeepEquals, want) +} + +func TestBackups_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + backups, err := client.Backups.List(ctx, &ListBackupsRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + want := []*Backup{{ + PublicID: "planetscale-go-test-backup", + Name: testBackup, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(backups, qt.DeepEquals, want) +} + +func TestBackups_ListEmpty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + backups, err := client.Backups.List(ctx, &ListBackupsRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + c.Assert(err, qt.IsNil) + c.Assert(backups, qt.HasLen, 0) +} + +func TestBackups_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-backup","type":"backup","name":"planetscale-go-test-backup","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + backup, err := client.Backups.Get(ctx, &GetBackupRequest{ + Organization: org, + Database: db, + Branch: branch, + Backup: testBackup, + }) + + want := &Backup{ + PublicID: "planetscale-go-test-backup", + Name: testBackup, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(backup, qt.DeepEquals, want) +} diff --git a/internal/planetscale/branch_infrastructure.go b/internal/planetscale/branch_infrastructure.go new file mode 100644 index 000000000..5ca352739 --- /dev/null +++ b/internal/planetscale/branch_infrastructure.go @@ -0,0 +1,157 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" + "time" +) + +// BranchInfrastructure represents the infrastructure for a branch. Exactly one +// of Vitess or Postgres is set, depending on the branch's database engine. +type BranchInfrastructure struct { + Type string + Vitess *VitessBranchInfrastructure + Postgres *PostgresBranchInfrastructure +} + +func (b *BranchInfrastructure) UnmarshalJSON(data []byte) error { + var discriminator struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &discriminator); err != nil { + return err + } + b.Type = discriminator.Type + + switch b.Type { + case "PostgresInfrastructure": + b.Postgres = &PostgresBranchInfrastructure{} + return json.Unmarshal(data, b.Postgres) + default: + b.Vitess = &VitessBranchInfrastructure{} + return json.Unmarshal(data, b.Vitess) + } +} + +// VitessBranchInfrastructure represents the infrastructure (pods) for a Vitess +// branch. +type VitessBranchInfrastructure struct { + Ready bool `json:"ready"` + Pods []*BranchInfraPod `json:"pods"` +} + +// PostgresBranchInfrastructure represents the infrastructure (nodes and +// bouncers) for a Postgres branch. +type PostgresBranchInfrastructure struct { + State string `json:"state"` + PrimaryName string `json:"primary_name"` + PrimaryPromotedAt *time.Time `json:"primary_promoted_at"` + VolumeModificationsBlockedUntil *time.Time `json:"volume_modifications_blocked_until"` + Nodes []*PostgresInfraNode `json:"nodes"` + Bouncers []*PostgresInfraBouncer `json:"bouncers"` +} + +// BranchInfraPod represents a single pod in the branch infrastructure. +type BranchInfraPod struct { + Name string `json:"name"` + Status string `json:"status"` + Component string `json:"component"` + Ready string `json:"ready"` + RestartCount int `json:"restart_count"` + CreatedAt *time.Time `json:"created_at"` + Cell string `json:"cell"` + Size string `json:"size"` + Keyspace *string `json:"keyspace"` + Shard *string `json:"shard"` + TabletType *string `json:"tablet_type"` +} + +// PostgresInfraNode represents a single Postgres instance in the branch +// infrastructure. +type PostgresInfraNode struct { + Name string `json:"name"` + NormalizedName string `json:"normalized_name"` + Role string `json:"role"` + AvailabilityZone string `json:"availability_zone"` + ClusterName string `json:"cluster_name"` + ClusterDisplayName string `json:"cluster_display_name"` + PeersCount int `json:"peers_count"` + VolumeUsageBytes *int64 `json:"volume_usage_bytes"` + VolumeCapacityBytes *int64 `json:"volume_capacity_bytes"` + VolumeShrinkThresholdPercent *float64 `json:"volume_shrink_threshold_percent"` + Region Region `json:"region"` + DiskReplacement *PostgresInfraDiskReplacement `json:"disk_replacement"` +} + +// PostgresInfraDiskReplacement represents a scheduled disk replacement for a +// Postgres node. +type PostgresInfraDiskReplacement struct { + Reason string `json:"reason"` + Bytes int64 `json:"bytes"` + ScheduledAt *time.Time `json:"scheduled_at"` +} + +// PostgresInfraBouncer represents a PgBouncer deployment in the branch +// infrastructure. +type PostgresInfraBouncer struct { + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + ReplicasPerCell int `json:"replicas_per_cell"` + Region Region `json:"region"` + SKU *PostgresInfraBouncerSKU `json:"sku"` +} + +// PostgresInfraBouncerSKU represents the size of a PgBouncer deployment. +type PostgresInfraBouncerSKU struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + CPU string `json:"cpu"` + RAM int64 `json:"ram"` +} + +// GetBranchInfrastructureRequest encapsulates the request for getting branch infrastructure. +type GetBranchInfrastructureRequest struct { + Organization string + Database string + Branch string +} + +// BranchInfrastructureService is an interface for interacting with the branch infrastructure API. +type BranchInfrastructureService interface { + Get(ctx context.Context, req *GetBranchInfrastructureRequest) (*BranchInfrastructure, error) +} + +type branchInfrastructureService struct { + client *Client +} + +var _ BranchInfrastructureService = &branchInfrastructureService{} + +func NewBranchInfrastructureService(client *Client) *branchInfrastructureService { + return &branchInfrastructureService{ + client: client, + } +} + +func (s *branchInfrastructureService) Get(ctx context.Context, getReq *GetBranchInfrastructureRequest) (*BranchInfrastructure, error) { + p := path.Join( + databaseBranchAPIPath(getReq.Organization, getReq.Database, getReq.Branch), + "infrastructure", + ) + + req, err := s.client.newRequest(http.MethodGet, p, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get branch infrastructure: %w", err) + } + + infra := &BranchInfrastructure{} + if err := s.client.do(ctx, req, &infra); err != nil { + return nil, err + } + + return infra, nil +} diff --git a/internal/planetscale/branch_infrastructure_test.go b/internal/planetscale/branch_infrastructure_test.go new file mode 100644 index 000000000..3d9aafe3f --- /dev/null +++ b/internal/planetscale/branch_infrastructure_test.go @@ -0,0 +1,246 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestBranchInfrastructure_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/infrastructure") + + out := `{ + "type": "PS-10", + "ready": true, + "pods": [ + { + "name": "vttablet-abc123", + "status": "Running", + "component": "vttablet", + "ready": "1/1", + "restart_count": 0, + "created_at": "2021-01-14T10:19:23.000Z", + "cell": "us-east-1", + "size": "PS-10", + "keyspace": "main", + "shard": "-", + "tablet_type": "primary" + }, + { + "name": "vtgate-def456", + "status": "Running", + "component": "vtgate", + "ready": "1/1", + "restart_count": 2, + "created_at": "2021-01-14T10:19:23.000Z", + "cell": "us-east-1", + "size": "PS-10", + "keyspace": null, + "shard": null, + "tablet_type": null + } + ] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + infra, err := client.BranchInfrastructure.Get(ctx, &GetBranchInfrastructureRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + }) + + createdAt := time.Date(2021, 1, 14, 10, 19, 23, 0, time.UTC) + keyspace := "main" + shard := "-" + tabletType := "primary" + + c.Assert(err, qt.IsNil) + c.Assert(infra.Type, qt.Equals, "PS-10") + c.Assert(infra.Postgres, qt.IsNil) + c.Assert(infra.Vitess, qt.IsNotNil) + c.Assert(infra.Vitess.Ready, qt.IsTrue) + c.Assert(len(infra.Vitess.Pods), qt.Equals, 2) + + pods := infra.Vitess.Pods + c.Assert(pods[0].Name, qt.Equals, "vttablet-abc123") + c.Assert(pods[0].Status, qt.Equals, "Running") + c.Assert(pods[0].Component, qt.Equals, "vttablet") + c.Assert(pods[0].Ready, qt.Equals, "1/1") + c.Assert(pods[0].RestartCount, qt.Equals, 0) + c.Assert(pods[0].CreatedAt, qt.DeepEquals, &createdAt) + c.Assert(pods[0].Cell, qt.Equals, "us-east-1") + c.Assert(pods[0].Size, qt.Equals, "PS-10") + c.Assert(pods[0].Keyspace, qt.DeepEquals, &keyspace) + c.Assert(pods[0].Shard, qt.DeepEquals, &shard) + c.Assert(pods[0].TabletType, qt.DeepEquals, &tabletType) + + c.Assert(pods[1].Name, qt.Equals, "vtgate-def456") + c.Assert(pods[1].Component, qt.Equals, "vtgate") + c.Assert(pods[1].RestartCount, qt.Equals, 2) + c.Assert(pods[1].Keyspace, qt.IsNil) + c.Assert(pods[1].Shard, qt.IsNil) + c.Assert(pods[1].TabletType, qt.IsNil) +} + +func TestBranchInfrastructure_GetPostgres(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/infrastructure") + + out := `{ + "type": "PostgresInfrastructure", + "state": "ready", + "primary_name": "hzi-abc123-aws-useast2a-1-1735266582-9c83d493", + "primary_promoted_at": "2025-12-09T13:15:00.000Z", + "volume_modifications_blocked_until": null, + "nodes": [ + { + "type": "PostgresNode", + "cluster_display_name": "PS-40", + "cluster_name": "PS_40", + "availability_zone": "Availability zone A", + "normalized_name": "aws-useast2a-1", + "name": "hzi-abc123-aws-useast2a-1-1735266582-9c83d493", + "peers_count": 0, + "role": "primary", + "volume_usage_bytes": 325369856, + "volume_capacity_bytes": 10737418240, + "volume_shrink_threshold_percent": 12.5, + "region": { + "slug": "us-east-2", + "provider": "AWS", + "display_name": "AWS us-east-2", + "enabled": true + }, + "disk_replacement": { + "type": "PostgresNodeDiskReplacement", + "reason": "ShrinkSuggested", + "bytes": 21474836480, + "scheduled_at": "2025-12-09T14:00:00.000Z" + } + }, + { + "type": "PostgresNode", + "cluster_display_name": "PS-40", + "cluster_name": "PS_40", + "availability_zone": "Availability zone C", + "normalized_name": "aws-useast2c-1", + "name": "hzi-abc123-aws-useast2c-1-3012702306-89b0e759", + "peers_count": 0, + "role": "replica", + "volume_usage_bytes": 341385216, + "volume_capacity_bytes": 10737418240, + "volume_shrink_threshold_percent": null, + "region": { + "slug": "us-east-2", + "provider": "AWS", + "display_name": "AWS us-east-2", + "enabled": true + }, + "disk_replacement": null + } + ], + "bouncers": [ + { + "type": "PostgresBouncerNode", + "id": "bouncer123", + "name": "primary-bouncer", + "target": "primary", + "replicas_per_cell": 1, + "region": { + "slug": "us-east-2", + "provider": "AWS", + "display_name": "AWS us-east-2", + "enabled": true + }, + "sku": { + "name": "HZB_C1_AMD64_PICO", + "display_name": "Pico", + "cpu": "0.25", + "ram": 268435456 + } + } + ] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + infra, err := client.BranchInfrastructure.Get(ctx, &GetBranchInfrastructureRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + }) + + c.Assert(err, qt.IsNil) + c.Assert(infra.Type, qt.Equals, "PostgresInfrastructure") + c.Assert(infra.Vitess, qt.IsNil) + c.Assert(infra.Postgres, qt.IsNotNil) + + pg := infra.Postgres + c.Assert(pg.State, qt.Equals, "ready") + c.Assert(pg.PrimaryName, qt.Equals, "hzi-abc123-aws-useast2a-1-1735266582-9c83d493") + c.Assert(pg.PrimaryPromotedAt, qt.DeepEquals, ptrTime(time.Date(2025, 12, 9, 13, 15, 0, 0, time.UTC))) + c.Assert(pg.VolumeModificationsBlockedUntil, qt.IsNil) + c.Assert(len(pg.Nodes), qt.Equals, 2) + c.Assert(len(pg.Bouncers), qt.Equals, 1) + + primary := pg.Nodes[0] + c.Assert(primary.Name, qt.Equals, "hzi-abc123-aws-useast2a-1-1735266582-9c83d493") + c.Assert(primary.NormalizedName, qt.Equals, "aws-useast2a-1") + c.Assert(primary.Role, qt.Equals, "primary") + c.Assert(primary.AvailabilityZone, qt.Equals, "Availability zone A") + c.Assert(primary.ClusterName, qt.Equals, "PS_40") + c.Assert(primary.ClusterDisplayName, qt.Equals, "PS-40") + c.Assert(*primary.VolumeUsageBytes, qt.Equals, int64(325369856)) + c.Assert(*primary.VolumeCapacityBytes, qt.Equals, int64(10737418240)) + c.Assert(*primary.VolumeShrinkThresholdPercent, qt.Equals, 12.5) + c.Assert(primary.Region.Slug, qt.Equals, "us-east-2") + c.Assert(primary.DiskReplacement.Reason, qt.Equals, "ShrinkSuggested") + c.Assert(primary.DiskReplacement.Bytes, qt.Equals, int64(21474836480)) + c.Assert(primary.DiskReplacement.ScheduledAt, qt.DeepEquals, ptrTime(time.Date(2025, 12, 9, 14, 0, 0, 0, time.UTC))) + + replica := pg.Nodes[1] + c.Assert(replica.Role, qt.Equals, "replica") + c.Assert(replica.VolumeShrinkThresholdPercent, qt.IsNil) + c.Assert(replica.DiskReplacement, qt.IsNil) + + bouncer := pg.Bouncers[0] + c.Assert(bouncer.ID, qt.Equals, "bouncer123") + c.Assert(bouncer.Name, qt.Equals, "primary-bouncer") + c.Assert(bouncer.Target, qt.Equals, "primary") + c.Assert(bouncer.ReplicasPerCell, qt.Equals, 1) + c.Assert(bouncer.Region.Slug, qt.Equals, "us-east-2") + c.Assert(bouncer.SKU.Name, qt.Equals, "HZB_C1_AMD64_PICO") + c.Assert(bouncer.SKU.DisplayName, qt.Equals, "Pico") + c.Assert(bouncer.SKU.CPU, qt.Equals, "0.25") + c.Assert(bouncer.SKU.RAM, qt.Equals, int64(268435456)) +} + +func ptrTime(t time.Time) *time.Time { + return &t +} diff --git a/internal/planetscale/branches.go b/internal/planetscale/branches.go new file mode 100644 index 000000000..3062a37d3 --- /dev/null +++ b/internal/planetscale/branches.go @@ -0,0 +1,495 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +// Actor represents a user or service token +type Actor struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"display_name"` +} + +// DatabaseBranch represents a database branch. +type DatabaseBranch struct { + ID string `json:"id"` + Name string `json:"name"` + ParentBranch string `json:"parent_branch"` + Actor Actor `json:"actor"` + Region Region `json:"region"` + Ready bool `json:"ready"` + Production bool `json:"production"` + HtmlURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SafeMigrations bool `json:"safe_migrations"` +} + +type databaseBranchesResponse struct { + Branches []*DatabaseBranch `json:"data"` +} + +// CreateDatabaseBranchRequest encapsulates the request for creating a new +// database branch +type CreateDatabaseBranchRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Region string `json:"region,omitempty"` + Name string `json:"name"` + ParentBranch string `json:"parent_branch"` + BackupID string `json:"backup_id,omitempty"` + SeedData string `json:"seed_data,omitempty"` + ClusterSize string `json:"cluster_size,omitempty"` +} + +// ListDatabaseBranchesRequest encapsulates the request for listing the branches +// of a database. +type ListDatabaseBranchesRequest struct { + Organization string + Database string +} + +// GetDatabaseBranchRequest encapsulates the request for getting a single +// database branch for a database. +type GetDatabaseBranchRequest struct { + Organization string + Database string + Branch string +} + +// DeleteDatabaseRequest encapsulates the request for deleting a database branch +// from a database. +type DeleteDatabaseBranchRequest struct { + Organization string + Database string + Branch string + DeleteDescendants bool +} + +// DiffBranchRequest encapsulates a request for getting the diff for a branch. +type DiffBranchRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// BranchSchemaRequest encapsulates a request for getting a branch's schema. +type BranchSchemaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type BranchRoutingRulesRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +type UpdateBranchRoutingRulesRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoutingRules string `json:"routing_rules"` +} + +// RefreshSchemaRequest reflects the request needed to refresh a schema +// snapshot on a database branch. +type RefreshSchemaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// DemoteRequest encapsulates the request for demoting a branch to +// development. +type DemoteRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// PromoteRequest encapsulates the request for promoting a request to +// production. +type PromoteRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// EnableSafeMigrationsRequest encapsulates the request for enabling safe +// migrations on a branch. +type EnableSafeMigrationsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// DisableSafeMigrationsRequest encapsulates the request for disabling safe +// migrations on a branch. +type DisableSafeMigrationsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// LintSchemaRequest encapsulates the request for linting a branch's schema. +type LintSchemaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// SchemaLintError represents an error with the branch's schema +type SchemaLintError struct { + LintError string `json:"lint_error"` + Keyspace string `json:"keyspace_name"` + Table string `json:"table_name"` + SubjectType string `json:"subject_type"` + ErrorDescription string `json:"error_description"` + DocsURL string `json:"docs_url"` +} + +type RoutingRules struct { + Raw string `json:"raw"` + HTML string `json:"html"` +} + +// DatabaseBranchesService is an interface for communicating with the PlanetScale +// Database Branch API endpoint. +type DatabaseBranchesService interface { + Create(context.Context, *CreateDatabaseBranchRequest) (*DatabaseBranch, error) + List(context.Context, *ListDatabaseBranchesRequest, ...ListOption) ([]*DatabaseBranch, error) + Get(context.Context, *GetDatabaseBranchRequest) (*DatabaseBranch, error) + Delete(context.Context, *DeleteDatabaseBranchRequest) error + Diff(context.Context, *DiffBranchRequest) ([]*Diff, error) + Schema(context.Context, *BranchSchemaRequest) ([]*Diff, error) + RoutingRules(context.Context, *BranchRoutingRulesRequest) (*RoutingRules, error) + UpdateRoutingRules(context.Context, *UpdateBranchRoutingRulesRequest) (*RoutingRules, error) + RefreshSchema(context.Context, *RefreshSchemaRequest) error + Demote(context.Context, *DemoteRequest) (*DatabaseBranch, error) + Promote(context.Context, *PromoteRequest) (*DatabaseBranch, error) + EnableSafeMigrations(context.Context, *EnableSafeMigrationsRequest) (*DatabaseBranch, error) + DisableSafeMigrations(context.Context, *DisableSafeMigrationsRequest) (*DatabaseBranch, error) + LintSchema(context.Context, *LintSchemaRequest) ([]*SchemaLintError, error) + ListClusterSKUs(context.Context, *ListBranchClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) +} + +// ListBranchClusterSKUsRequest encapsulates the request for getting a list of Cluster SKUs for a branch. +type ListBranchClusterSKUsRequest struct { + Organization string + Database string + Branch string +} + +type databaseBranchesService struct { + client *Client +} + +var _ DatabaseBranchesService = &databaseBranchesService{} + +func NewDatabaseBranchesService(client *Client) *databaseBranchesService { + return &databaseBranchesService{ + client: client, + } +} + +func (d *databaseBranchesService) Diff(ctx context.Context, diffReq *DiffBranchRequest) ([]*Diff, error) { + path := path.Join(databaseBranchAPIPath(diffReq.Organization, diffReq.Database, diffReq.Branch), "diff") + req, err := d.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + diffs := &diffResponse{} + if err := d.client.do(ctx, req, &diffs); err != nil { + return nil, err + } + + return diffs.Diffs, nil +} + +// schemaResponse returns the schemas +type schemaResponse struct { + Schemas []*Diff `json:"data"` +} + +func (d *databaseBranchesService) Schema(ctx context.Context, schemaReq *BranchSchemaRequest) ([]*Diff, error) { + path := path.Join(databaseBranchAPIPath(schemaReq.Organization, schemaReq.Database, schemaReq.Branch), "schema") + v := url.Values{} + if schemaReq.Keyspace != "" { + v.Add("keyspace", schemaReq.Keyspace) + } + + req, err := d.client.newRequest(http.MethodGet, path, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + schemas := &schemaResponse{} + if err := d.client.do(ctx, req, &schemas); err != nil { + return nil, err + } + + return schemas.Schemas, nil +} + +func (d *databaseBranchesService) RoutingRules(ctx context.Context, routingRulesReq *BranchRoutingRulesRequest) (*RoutingRules, error) { + path := path.Join(databaseBranchAPIPath(routingRulesReq.Organization, routingRulesReq.Database, routingRulesReq.Branch), "routing-rules") + + req, err := d.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + routingRules := &RoutingRules{} + if err := d.client.do(ctx, req, &routingRules); err != nil { + return nil, err + } + + return routingRules, nil +} + +func (d *databaseBranchesService) UpdateRoutingRules(ctx context.Context, updateRoutingRulesReq *UpdateBranchRoutingRulesRequest) (*RoutingRules, error) { + path := path.Join(databaseBranchAPIPath(updateRoutingRulesReq.Organization, updateRoutingRulesReq.Database, updateRoutingRulesReq.Branch), "routing-rules") + + req, err := d.client.newRequest(http.MethodPatch, path, updateRoutingRulesReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + routingRules := &RoutingRules{} + if err := d.client.do(ctx, req, &routingRules); err != nil { + return nil, err + } + + return routingRules, nil +} + +// Create creates a new branch for an organization's database. +func (d *databaseBranchesService) Create(ctx context.Context, createReq *CreateDatabaseBranchRequest) (*DatabaseBranch, error) { + path := databaseBranchesAPIPath(createReq.Organization, createReq.Database) + + req, err := d.client.newRequest(http.MethodPost, path, createReq) + if err != nil { + return nil, fmt.Errorf("error creating request for branch database: %w", err) + } + + dbBranch := &DatabaseBranch{} + if err := d.client.do(ctx, req, &dbBranch); err != nil { + return nil, err + } + + return dbBranch, nil +} + +// Get returns a database branch for an organization's database. +func (d *databaseBranchesService) Get(ctx context.Context, getReq *GetDatabaseBranchRequest) (*DatabaseBranch, error) { + path := path.Join(databaseBranchesAPIPath(getReq.Organization, getReq.Database), getReq.Branch) + req, err := d.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dbBranch := &DatabaseBranch{} + if err := d.client.do(ctx, req, &dbBranch); err != nil { + return nil, err + } + + return dbBranch, nil +} + +// List returns all of the branches for an organization's +// database. +func (d *databaseBranchesService) List(ctx context.Context, listReq *ListDatabaseBranchesRequest, opts ...ListOption) ([]*DatabaseBranch, error) { + path := databaseBranchesAPIPath(listReq.Organization, listReq.Database) + + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := d.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dbBranches := &databaseBranchesResponse{} + if err := d.client.do(ctx, req, &dbBranches); err != nil { + return nil, err + } + + return dbBranches.Branches, nil +} + +// Delete deletes a database branch from an organization's database. +func (d *databaseBranchesService) Delete(ctx context.Context, deleteReq *DeleteDatabaseBranchRequest) error { + path := path.Join(databaseBranchesAPIPath(deleteReq.Organization, deleteReq.Database), deleteReq.Branch) + + var opts []RequestOption + if deleteReq.DeleteDescendants { + v := url.Values{} + v.Set("delete_descendants", "true") + opts = append(opts, WithQueryParams(v)) + } + + req, err := d.client.newRequest(http.MethodDelete, path, nil, opts...) + if err != nil { + return fmt.Errorf("error creating request for delete branch: %w", err) + } + + err = d.client.do(ctx, req, nil) + return err +} + +// RefreshSchema refreshes the schema for a +func (d *databaseBranchesService) RefreshSchema(ctx context.Context, refreshReq *RefreshSchemaRequest) error { + path := path.Join(databaseBranchesAPIPath(refreshReq.Organization, refreshReq.Database), refreshReq.Branch, "refresh-schema") + req, err := d.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + if err := d.client.do(ctx, req, nil); err != nil { + return err + } + + return nil +} + +// Promote promotes a branch from development to production. +func (d *databaseBranchesService) Promote(ctx context.Context, promoteReq *PromoteRequest) (*DatabaseBranch, error) { + path := path.Join(databaseBranchAPIPath(promoteReq.Organization, promoteReq.Database, promoteReq.Branch), "promote") + req, err := d.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for branch promotion: %w", err) + } + + branch := &DatabaseBranch{} + err = d.client.do(ctx, req, &branch) + if err != nil { + return nil, err + } + + return branch, nil +} + +// EnableSafeMigrations enables safe migrations for a production branch. This +// will prevent DDL statements from being performed on the branch. +func (d *databaseBranchesService) EnableSafeMigrations(ctx context.Context, enableReq *EnableSafeMigrationsRequest) (*DatabaseBranch, error) { + path := path.Join(databaseBranchAPIPath(enableReq.Organization, enableReq.Database, enableReq.Branch), "safe-migrations") + req, err := d.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for enabling safe migrations: %w", err) + } + + branch := &DatabaseBranch{} + err = d.client.do(ctx, req, &branch) + if err != nil { + return nil, err + } + + return branch, nil +} + +// DisableSafeMigrations disables safe migrations for a production branch. This +// will allow DDL statements to be performed on the branch. +func (d *databaseBranchesService) DisableSafeMigrations(ctx context.Context, disableReq *DisableSafeMigrationsRequest) (*DatabaseBranch, error) { + path := path.Join(databaseBranchAPIPath(disableReq.Organization, disableReq.Database, disableReq.Branch), "safe-migrations") + req, err := d.client.newRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for disabling safe migrations: %w", err) + } + + branch := &DatabaseBranch{} + err = d.client.do(ctx, req, &branch) + if err != nil { + return nil, err + } + + return branch, nil +} + +// Demote demotes a branch from production to development. If the branch belongs +// to an Enterprise organization, it will return a demote request and require a +// second call by a different admin in order to complete demotion. +func (d *databaseBranchesService) Demote(ctx context.Context, demoteReq *DemoteRequest) (*DatabaseBranch, error) { + path := path.Join(databaseBranchAPIPath(demoteReq.Organization, demoteReq.Database, demoteReq.Branch), "demote") + req, err := d.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for branch demotion: %w", err) + } + + branch := &DatabaseBranch{} + err = d.client.do(ctx, req, &branch) + if err != nil { + return nil, err + } + + return branch, nil +} + +// lintSchemaResponse represents the response from the lint schema endpoint. +type lintSchemaResponse struct { + Errors []*SchemaLintError `json:"data"` +} + +// LintSchema lints the current schema of a branch and returns any errors that +// may be present. +func (d *databaseBranchesService) LintSchema(ctx context.Context, lintReq *LintSchemaRequest) ([]*SchemaLintError, error) { + path := path.Join(databaseBranchAPIPath(lintReq.Organization, lintReq.Database, lintReq.Branch), "schema", "lint") + req, err := d.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for linting branch schema: %w", err) + } + + lintResp := &lintSchemaResponse{} + err = d.client.do(ctx, req, &lintResp) + if err != nil { + return nil, err + } + + return lintResp.Errors, nil +} + +func (o *databaseBranchesService) ListClusterSKUs(ctx context.Context, listReq *ListBranchClusterSKUsRequest, opts ...ListOption) ([]*ClusterSKU, error) { + path := path.Join(databaseBranchAPIPath(listReq.Organization, listReq.Database, listReq.Branch), "cluster-size-skus") + + defaultOpts := defaultListOptions() + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := o.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + clusterSKUs := []*ClusterSKU{} + if err := o.client.do(ctx, req, &clusterSKUs); err != nil { + return nil, err + } + + return clusterSKUs, nil +} + +func databaseBranchesAPIPath(org, db string) string { + return path.Join(databasesAPIPath(org), db, "branches") +} + +func databaseBranchAPIPath(org, db, branch string) string { + return path.Join(databaseBranchesAPIPath(org, db), branch) +} diff --git a/internal/planetscale/branches_test.go b/internal/planetscale/branches_test.go new file mode 100644 index 000000000..cfe7fe541 --- /dev/null +++ b/internal/planetscale/branches_test.go @@ -0,0 +1,676 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testBranch = "planetscale-go-test-db-branch" + +func TestDatabaseBranches_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": {"slug": "us-west", "display_name": "US West"}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.Create(ctx, &CreateDatabaseBranchRequest{ + Organization: org, + Database: name, + Region: "us-west", + Name: testBranch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.List(ctx, &ListDatabaseBranchesRequest{ + Organization: org, + Database: name, + }) + + want := []*DatabaseBranch{{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_ListEmpty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.List(ctx, &ListDatabaseBranchesRequest{ + Organization: org, + Database: name, + }) + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.HasLen, 0) +} + +func TestDatabaseBranches_ListWithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Get("limit"), qt.Equals, "10") + c.Assert(r.URL.Query().Get("starting_after"), qt.Equals, "test-branch") + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.List(ctx, &ListDatabaseBranchesRequest{ + Organization: org, + Database: name, + }, WithLimit(10), WithStartingAfter("test-branch")) + + want := []*DatabaseBranch{{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_ListWithDefaultPerPage(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "100") + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.List(ctx, &ListDatabaseBranchesRequest{ + Organization: org, + Database: name, + }) + + want := []*DatabaseBranch{{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.Get(ctx, &GetDatabaseBranchRequest{ + Organization: org, + Database: name, + Branch: testBranch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestBranches_Diff(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"name": "foo"}, {"name": "bar"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + diffs, err := client.DatabaseBranches.Diff(ctx, &DiffBranchRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + want := []*Diff{ + {Name: "foo"}, + {Name: "bar"}, + } + + c.Assert(err, qt.IsNil) + c.Assert(diffs, qt.DeepEquals, want) +} + +func TestBranches_Schema(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"name": "foo"}, {"name": "bar"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + schemas, err := client.DatabaseBranches.Schema(ctx, &BranchSchemaRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + want := []*Diff{ + {Name: "foo"}, + {Name: "bar"}, + } + + c.Assert(err, qt.IsNil) + c.Assert(schemas, qt.DeepEquals, want) +} + +func TestBranches_RefreshSchema(t *testing.T) { + c := qt.New(t) + + wantURL := "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/refresh-schema" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + c.Assert(r.URL.String(), qt.DeepEquals, wantURL) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.DatabaseBranches.RefreshSchema(ctx, &RefreshSchemaRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + }) + c.Assert(err, qt.IsNil) +} + +func TestBranches_Demote(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z","production": false}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "my-test-db" + branch := "main" + + b, err := client.DatabaseBranches.Demote(ctx, &DemoteRequest{ + Organization: org, + Database: name, + Branch: branch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + Production: false, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(b, qt.DeepEquals, want) +} + +func TestDatabaseBranches_Promote(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z","production": true}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.Promote(ctx, &PromoteRequest{ + Organization: org, + Database: name, + Branch: testBranch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + Production: true, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_EnableSafeMigrations(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z","production": true,"safe_migrations":true}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.EnableSafeMigrations(ctx, &EnableSafeMigrationsRequest{ + Organization: org, + Database: name, + Branch: testBranch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + Production: true, + SafeMigrations: true, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_DisableSafeMigrations(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db-branch","type":"database_branch","name":"planetscale-go-test-db-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z","production": true,"safe_migrations":false}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + db, err := client.DatabaseBranches.DisableSafeMigrations(ctx, &DisableSafeMigrationsRequest{ + Organization: org, + Database: name, + Branch: testBranch, + }) + + want := &DatabaseBranch{ + ID: "planetscale-go-test-db-branch", + Name: testBranch, + Production: true, + SafeMigrations: false, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabaseBranches_LintSchema(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := ` +{ + "type": "list", + "current_page": 1, + "next_page": null, + "next_page_url": null, + "prev_page": null, + "prev_page_url": null, + "data": [ + { + "type": "SchemaLintError", + "lint_error": "NO_UNIQUE_KEY", + "subject_type": "table_error", + "keyspace_name": "test-database", + "table_name": "test", + "error_description": "table \"test\" has no unique key: all tables must have at least one unique, not-null key.", + "docs_url": "https://planetscale.com/docs/learn/change-single-unique-key", + "column_name": "", + "foreign_key_column_names": [], + "auto_increment_column_names": [], + "charset_name": "", + "engine_name": "", + "vindex_name": null, + "json_path": null, + "check_constraint_name": "", + "enum_value": "", + "partitioning_type": "", + "partition_name": "", + "url_hash": "81e9ed9a459b5824393c4fa735753c49d47861bbd43742e2baa0ab7013158d2b" + } + ] + } + ` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + + lintErrors, err := client.DatabaseBranches.LintSchema(ctx, &LintSchemaRequest{ + Organization: org, + Database: name, + Branch: testBranch, + }) + + c.Assert(err, qt.IsNil) + c.Assert(len(lintErrors), qt.Equals, 1) + + want := &SchemaLintError{ + LintError: "NO_UNIQUE_KEY", + SubjectType: "table_error", + Keyspace: "test-database", + Table: "test", + ErrorDescription: "table \"test\" has no unique key: all tables must have at least one unique, not-null key.", + DocsURL: "https://planetscale.com/docs/learn/change-single-unique-key", + } + lintErr := lintErrors[0] + + c.Assert(lintErr, qt.DeepEquals, want) +} + +func TestDatabaseBranches_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch") + c.Assert(r.URL.Query().Get("delete_descendants"), qt.Equals, "") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.DatabaseBranches.Delete(ctx, &DeleteDatabaseBranchRequest{ + Organization: "my-org", + Database: "planetscale-go-test-db", + Branch: testBranch, + }) + + c.Assert(err, qt.IsNil) +} + +func TestDatabaseBranches_DeleteWithDescendants(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch") + c.Assert(r.URL.Query().Get("delete_descendants"), qt.Equals, "true") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.DatabaseBranches.Delete(ctx, &DeleteDatabaseBranchRequest{ + Organization: "my-org", + Database: "planetscale-go-test-db", + Branch: testBranch, + DeleteDescendants: true, + }) + + c.Assert(err, qt.IsNil) +} + +func TestDatabaseBranches_ListClusterSKUs(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/databases/my-cool-db/branches/main/cluster-size-skus") + out := `[ + { + "name": "PS_10", + "type": "ClusterSizeSku", + "display_name": "PS-10", + "cpu": "1/8", + "provider_instance_type": null, + "storage": null, + "ram": 1, + "enabled": true, + "provider": null, + "rate": null, + "replica_rate": null, + "default_vtgate": "VTG_5", + "default_vtgate_rate": null, + "sort_order": 1 + } + ]` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.DatabaseBranches.ListClusterSKUs(ctx, &ListBranchClusterSKUsRequest{ + Organization: "my-cool-org", + Database: "my-cool-db", + Branch: "main", + }) + + c.Assert(err, qt.IsNil) + want := []*ClusterSKU{ + { + Name: "PS_10", + DisplayName: "PS-10", + CPU: "1/8", + Memory: 1, + Enabled: true, + DefaultVTGate: "VTG_5", + SortOrder: 1, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} + +func TestDatabaseBranches_ListClusterSKUsWithRates(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/databases/my-cool-db/branches/main/cluster-size-skus?rates=true") + out := `[ + { + "name": "PS_10", + "type": "ClusterSizeSku", + "display_name": "PS-10", + "cpu": "1/8", + "provider_instance_type": null, + "storage": 100, + "ram": 1, + "sort_order": 1, + "enabled": true, + "provider": null, + "rate": 39, + "replica_rate": 13, + "default_vtgate": "VTG_5", + "default_vtgate_rate": null + } + ]` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.DatabaseBranches.ListClusterSKUs(ctx, &ListBranchClusterSKUsRequest{ + Organization: "my-cool-org", + Database: "my-cool-db", + Branch: "main", + }, WithRates()) + + c.Assert(err, qt.IsNil) + want := []*ClusterSKU{ + { + Name: "PS_10", + DisplayName: "PS-10", + CPU: "1/8", + Memory: 1, + Enabled: true, + Storage: Pointer[int64](100), + Rate: Pointer[int64](39), + ReplicaRate: Pointer[int64](13), + DefaultVTGate: "VTG_5", + SortOrder: 1, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go new file mode 100644 index 000000000..b3bf6be26 --- /dev/null +++ b/internal/planetscale/client.go @@ -0,0 +1,519 @@ +package planetscale + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/hashicorp/go-cleanhttp" + "golang.org/x/oauth2" +) + +const ( + DefaultBaseURL = "https://api.planetscale.com/" + jsonMediaType = "application/json" +) + +// ErrorCode defines the code of an error. +type ErrorCode string + +const ( + ErrInternal ErrorCode = "internal" // Internal error. + ErrInvalid ErrorCode = "invalid" // Invalid operation, e.g wrong params + ErrPermission ErrorCode = "permission" // Permission denied. + ErrNotFound ErrorCode = "not_found" // Resource not found. + ErrRetry ErrorCode = "retry" // Operation should be retried. + ErrResponseMalformed ErrorCode = "response_malformed" // Response body is malformed. +) + +// Client encapsulates a client that talks to the PlanetScale API +type Client struct { + // client represents the HTTP client used for making HTTP requests. + client *http.Client + + // UserAgent identifies the CLI version making the request + UserAgent string + + // headers are used to override request headers for every single HTTP request + headers map[string]string + + // base URL for the API + baseURL *url.URL + + AuditLogs AuditLogsService + Backups BackupsService + BranchInfrastructure BranchInfrastructureService + D1ImportNotifications D1ImportNotificationsService + DatabaseBranches DatabaseBranchesService + Databases DatabasesService + DataImports DataImportsService + DeployRequests DeployRequestsService + Keyspaces KeyspacesService + LookupVindex LookupVindexService + Materialize MaterializeService + MoveTables MoveTablesService + Organizations OrganizationsService + Passwords PasswordsService + PlannedReparentShard PlannedReparentShardService + PostgresBranches PostgresBranchesService + PostgresRoles PostgresRolesService + Processlist ProcesslistService + QueryPatterns QueryPatternsService + Regions RegionsService + SchemaRecommendations SchemaRecommendationService + ServiceTokens ServiceTokenService + TrafficBudgets TrafficBudgetsService + TrafficRules TrafficRulesService + VDiff VDiffService + Vtctld VtctldService + Webhooks WebhooksService + Workflows WorkflowsService +} + +// ListOptions are options for listing responses. +type ListOptions struct { + URLValues *url.Values +} + +type ListOption func(*ListOptions) error + +// DefaultListOptions returns the default list options values. +func defaultListOptions(opts ...ListOption) *ListOptions { + listOpts := &ListOptions{ + URLValues: &url.Values{}, + } + + for _, opt := range opts { + err := opt(listOpts) + if err != nil { + panic(err) + } + } + + return listOpts +} + +// WithStartingAfter returns a ListOption that sets the "starting_after" URL parameter. +func WithStartingAfter(startingAfter string) ListOption { + return func(opt *ListOptions) error { + if startingAfter != "" { + opt.URLValues.Set("starting_after", startingAfter) + } + return nil + } +} + +// WithLimit returns a ListOption that sets the "limit" URL parameter. +func WithLimit(limit int) ListOption { + return func(opt *ListOptions) error { + if limit > 0 { + limitStr := strconv.Itoa(limit) + opt.URLValues.Set("limit", limitStr) + } + return nil + } +} + +// WithRates returns a ListOption that sets the "rates" URL parameter. +func WithRates() ListOption { + return func(opt *ListOptions) error { + opt.URLValues.Set("rates", "true") + return nil + } +} + +// WithPostgreSQL returns a ListOption that sets the "postgresql" URL parameter. +func WithPostgreSQL() ListOption { + return func(opt *ListOptions) error { + opt.URLValues.Set("postgresql", "true") + return nil + } +} + +// WithRegion returns a ListOption sets the "region" URL parameter. +func WithRegion(region string) ListOption { + return func(opt *ListOptions) error { + if len(region) > 0 { + opt.URLValues.Set("region", region) + } + return nil + } +} + +// WithPage returns a ListOption that sets the "page" URL parameter. +func WithPage(page int) ListOption { + return func(opt *ListOptions) error { + if page > 0 { + pageStr := strconv.Itoa(page) + opt.URLValues.Set("page", pageStr) + } + return nil + } +} + +// WithPerPage returns a ListOption that sets the "per_page" URL paramter. +func WithPerPage(perPage int) ListOption { + return func(opt *ListOptions) error { + if perPage > 0 { + perPageStr := strconv.Itoa(perPage) + opt.URLValues.Set("per_page", perPageStr) + } + return nil + } +} + +// ClientOption provides a variadic option for configuring the client +type ClientOption func(c *Client) error + +// The CLI always overrides this with its real version via WithUserAgent; +// the fallback only shows up when the client is constructed directly (tests). +const defaultUserAgent = "pscale-cli/unknown" + +// WithUserAgent overrides the User-Agent header. +func WithUserAgent(userAgent string) ClientOption { + return func(c *Client) error { + c.UserAgent = userAgent + return nil + } +} + +// WithRequestHeaders sets the request headers for every HTTP request. +func WithRequestHeaders(headers map[string]string) ClientOption { + return func(c *Client) error { + for k, v := range headers { + c.headers[k] = v + } + + return nil + } +} + +// WithBaseURL overrides the base URL for the API. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + parsedURL, err := url.Parse(baseURL) + if err != nil { + return err + } + + c.baseURL = parsedURL + return nil + } +} + +// WithAccessToken configures a client with the given PlanetScale access token. +func WithAccessToken(token string) ClientOption { + return func(c *Client) error { + if token == "" { + return errors.New("missing access token") + } + + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + + // make sure we use our own HTTP client + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c.client) + oauthClient := oauth2.NewClient(ctx, tokenSource) + + c.client = oauthClient + return nil + } +} + +// WithServiceToken configures a client with the given PlanetScale Service Token +func WithServiceToken(name, token string) ClientOption { + return func(c *Client) error { + if token == "" || name == "" { + return errors.New("missing token name and string") + } + + transport := serviceTokenTransport{ + rt: c.client.Transport, + token: token, + tokenName: name, + } + + c.client.Transport = &transport + return nil + } +} + +// WithHTTPClient configures the PlanetScale client with the given HTTP client. +func WithHTTPClient(client *http.Client) ClientOption { + return func(c *Client) error { + if client == nil { + client = cleanhttp.DefaultClient() + } + + c.client = client + return nil + } +} + +// NewClient instantiates an instance of the PlanetScale API client. +func NewClient(opts ...ClientOption) (*Client, error) { + baseURL, err := url.Parse(DefaultBaseURL) + if err != nil { + return nil, err + } + + c := &Client{ + client: cleanhttp.DefaultClient(), + baseURL: baseURL, + UserAgent: defaultUserAgent, + headers: make(map[string]string, 0), + } + + for _, opt := range opts { + err := opt(c) + if err != nil { + return nil, err + } + } + + c.AuditLogs = &auditlogsService{client: c} + c.Backups = &backupsService{client: c} + c.BranchInfrastructure = &branchInfrastructureService{client: c} + c.D1ImportNotifications = &d1ImportNotificationsService{client: c} + c.DatabaseBranches = &databaseBranchesService{client: c} + c.Databases = &databasesService{client: c} + c.DataImports = &dataImportsService{client: c} + c.DeployRequests = &deployRequestsService{client: c} + c.Keyspaces = &keyspacesService{client: c} + c.LookupVindex = &lookupVindexService{client: c} + c.Materialize = &materializeService{client: c} + c.MoveTables = &moveTablesService{client: c} + c.Organizations = &organizationsService{client: c} + c.Passwords = &passwordsService{client: c} + c.PlannedReparentShard = &plannedReparentShardService{client: c} + c.Processlist = &processlistService{client: c} + c.PostgresBranches = &postgresBranchesService{client: c} + c.PostgresRoles = &postgresRolesService{client: c} + c.QueryPatterns = &queryPatternsService{client: c} + c.Regions = ®ionsService{client: c} + c.SchemaRecommendations = &schemaRecommendationService{client: c} + c.ServiceTokens = &serviceTokenService{client: c} + c.TrafficBudgets = &trafficBudgetsService{client: c} + c.TrafficRules = &trafficRulesService{client: c} + c.VDiff = &vdiffService{client: c} + c.Vtctld = &vtctldService{client: c} + c.Webhooks = &webhooksService{client: c} + c.Workflows = &workflowsService{client: c} + + return c, nil +} + +// do makes an HTTP request and populates the given struct v from the response. +func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) error { + req = req.WithContext(ctx) + res, err := c.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + return c.handleResponse(ctx, res, v) +} + +// handleResponse makes an HTTP request and populates the given struct v from +// the response. This is meant for internal testing and shouldn't be used +// directly. Instead please use `Client.do`. +func (c *Client) handleResponse(ctx context.Context, res *http.Response, v interface{}) error { + out, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + if res.StatusCode >= 400 { + // errorResponse represents an error response from the API + type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + } + + errorRes := &errorResponse{} + err = json.Unmarshal(out, errorRes) + if err != nil { + var jsonErr *json.SyntaxError + if errors.As(err, &jsonErr) { + return &Error{ + msg: "malformed error response body received", + Code: ErrResponseMalformed, + Meta: map[string]string{ + "body": string(out), + "err": jsonErr.Error(), + "http_status": http.StatusText(res.StatusCode), + }, + } + } + return err + } + + // json.Unmarshal doesn't return an error if the response + // body has a different protocol then "ErrorResponse". We + // check here to make sure that errorRes is populated. If + // not, we return the full response back to the user, so + // they can debug the issue. + // TODO(fatih): fix the behavior on the API side + if *errorRes == (errorResponse{}) { + return &Error{ + msg: "internal error, response body doesn't match error type signature", + Code: ErrInternal, + Meta: map[string]string{ + "body": string(out), + "http_status": http.StatusText(res.StatusCode), + }, + } + } + + var errCode ErrorCode + switch errorRes.Code { + case "not_found": + errCode = ErrNotFound + case "unauthorized": + errCode = ErrPermission + case "bad_request", "invalid_params": + errCode = ErrInvalid + case "unprocessable": + errCode = ErrRetry + } + + return &Error{ + msg: errorRes.Message, + Code: errCode, + } + } + + // this means we don't care about unmarshaling the response body into v + if v == nil || res.StatusCode == http.StatusNoContent { + return nil + } + + err = json.Unmarshal(out, &v) + if err != nil { + var jsonErr *json.SyntaxError + if errors.As(err, &jsonErr) { + return &Error{ + msg: "malformed response body received", + Code: ErrResponseMalformed, + Meta: map[string]string{ + "body": string(out), + "http_status": http.StatusText(res.StatusCode), + }, + } + } + return err + } + + return nil +} + +// RequestOption allows for customizing HTTP requests +type RequestOption func(*requestOptions) + +type requestOptions struct { + queryParams url.Values +} + +// WithQueryParams sets query parameters for the request +func WithQueryParams(params url.Values) RequestOption { + return func(opts *requestOptions) { + opts.queryParams = params + } +} + +func (c *Client) newRequest(method string, path string, body interface{}, opts ...RequestOption) (*http.Request, error) { + u, err := c.baseURL.Parse(path) + if err != nil { + return nil, err + } + + // Apply options + reqOpts := &requestOptions{} + for _, opt := range opts { + opt(reqOpts) + } + + // Set query parameters if provided + if reqOpts.queryParams != nil { + u.RawQuery = reqOpts.queryParams.Encode() + } + + var req *http.Request + switch method { + case http.MethodGet: + req, err = http.NewRequest(method, u.String(), nil) + if err != nil { + return nil, err + } + default: + buf := new(bytes.Buffer) + if body != nil { + err = json.NewEncoder(buf).Encode(body) + if err != nil { + return nil, err + } + } + + req, err = http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", jsonMediaType) + } + + req.Header.Set("Accept", jsonMediaType) + req.Header.Set("User-Agent", c.UserAgent) + + for k, v := range c.headers { + req.Header.Set(k, v) + } + + return req, nil +} + +type serviceTokenTransport struct { + rt http.RoundTripper + token string + tokenName string +} + +func (t *serviceTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Authorization", t.tokenName+":"+t.token) + return t.rt.RoundTrip(req) +} + +// Error represents common errors originating from the Client. +type Error struct { + // msg contains the human readable string + msg string + + // Code specifies the error code. i.e; NotFound, RateLimited, etc... + Code ErrorCode + + // Meta contains additional information depending on the error code. As an + // example, if the Code is "ErrResponseMalformed", the map will be: ["body"] + // = "body of the response" + Meta map[string]string +} + +// Error returns the string representation of the error. +func (e *Error) Error() string { return e.msg } + +// CursorPaginatedResponse provides a generic means of wrapping a paginated +// response. +type CursorPaginatedResponse[T any] struct { + Data []T `json:"data"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` + // CursorStart is the ending cursor of the previous page. + CursorStart *string `json:"cursor_start"` + + // CursorEnd is the starting cursor of the next page. + CursorEnd *string `json:"cursor_end"` +} diff --git a/internal/planetscale/client_test.go b/internal/planetscale/client_test.go new file mode 100644 index 000000000..0d1bfaa81 --- /dev/null +++ b/internal/planetscale/client_test.go @@ -0,0 +1,181 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestDo(t *testing.T) { + tests := []struct { + desc string + response string + statusCode int + method string + expectedError error + clientOptions []ClientOption + wantHeaders map[string]string + body interface{} + v interface{} + want interface{} + }{ + { + desc: "returns an HTTP response and no error for 2xx responses", + statusCode: http.StatusOK, + response: `{}`, + method: http.MethodGet, + wantHeaders: map[string]string{ + "User-Agent": "pscale-cli/unknown", + }, + }, + { + desc: "sets a custom header with the request option", + statusCode: http.StatusOK, + response: `{}`, + method: http.MethodGet, + clientOptions: []ClientOption{WithUserAgent("test-user-agent"), WithRequestHeaders(map[string]string{"Test-Header": "test-value"})}, + wantHeaders: map[string]string{ + "Test-Header": "test-value", + "User-Agent": "test-user-agent", + }, + }, + { + desc: "returns ErrorResponse for 4xx errors", + statusCode: http.StatusNotFound, + method: http.MethodGet, + response: `{ + "code": "not_found", + "message": "Not Found" + }`, + expectedError: &Error{ + msg: "Not Found", + Code: ErrNotFound, + }, + }, + { + desc: "maps bad_request errors to invalid", + statusCode: http.StatusBadRequest, + method: http.MethodPost, + response: `{ + "code": "bad_request", + "message": "Bad Request" + }`, + expectedError: &Error{ + msg: "Bad Request", + Code: ErrInvalid, + }, + }, + { + desc: "returns ErrorResponse for 5xx errors", + statusCode: http.StatusInternalServerError, + method: http.MethodGet, + response: `{}`, + expectedError: &Error{ + msg: "internal error, response body doesn't match error type signature", + Code: ErrInternal, + }, + }, + { + desc: "returns an HTTP response 200 when posting a request", + statusCode: http.StatusOK, + response: ` +{ + "id": "509", + "type": "database", + "name": "foo-bar", + "notes": "" +}`, + body: &Database{ + Name: "foo-bar", + }, + v: &Database{}, + want: &Database{ + Name: "foo-bar", + }, + }, + { + desc: "returns an HTTP response 204 when deleting a request", + statusCode: http.StatusNoContent, + method: http.MethodDelete, + response: "", + body: nil, + v: &Database{}, + want: nil, + }, + { + desc: "returns an non-204 HTTP response when deleting a request", + statusCode: http.StatusAccepted, + method: http.MethodDelete, + response: `{ + "id": "test" + }`, + body: nil, + v: &DatabaseDeletionRequest{}, + want: &DatabaseDeletionRequest{ + ID: "test", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ctx := context.Background() + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + + if tt.wantHeaders != nil { + for key, value := range tt.wantHeaders { + c.Assert(r.Header.Get(key), qt.Equals, value) + } + } + + res := []byte(tt.response) + if tt.response == "" { + res = nil + } + _, err := w.Write(res) + if err != nil { + t.Fatal(err) + } + })) + t.Cleanup(ts.Close) + + opts := append(tt.clientOptions, WithBaseURL(ts.URL)) + client, err := NewClient(opts...) + if err != nil { + t.Fatal(err) + } + + req, err := client.newRequest(tt.method, "/api-endpoint", tt.body) + if err != nil { + t.Fatal(err) + } + + res, err := client.client.Do(req) + c.Assert(err, qt.IsNil) + defer res.Body.Close() + + err = client.handleResponse(ctx, res, &tt.v) + if err != nil { + if tt.expectedError != nil { + c.Assert(tt.expectedError.Error(), qt.Equals, err.Error()) + } + } + + c.Assert(res, qt.Not(qt.IsNil)) + c.Assert(res.StatusCode, qt.Equals, tt.statusCode) + + if tt.v != nil && tt.want != nil { + c.Assert(tt.want, qt.DeepEquals, tt.v) + } + }) + } +} + +func Pointer[K any](val K) *K { + return &val +} diff --git a/internal/planetscale/d1_import_notifications.go b/internal/planetscale/d1_import_notifications.go new file mode 100644 index 000000000..84b85e5a0 --- /dev/null +++ b/internal/planetscale/d1_import_notifications.go @@ -0,0 +1,83 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" +) + +type D1ImportNotificationsService interface { + Create(ctx context.Context, req *CreateD1ImportNotificationRequest) error +} + +type CreateD1ImportNotificationRequest struct { + Organization string + Database string + BranchName string + MigrationID string + Event string + Method string + ExportBytes int64 + TableCount int + Matched *bool + DurationMs int64 + Error string + ErrorCode string + Stage string + Message string +} + +type createD1ImportNotificationRequest struct { + BranchName string `json:"branch_name,omitempty"` + MigrationID string `json:"migration_id"` + Event string `json:"event"` + Method string `json:"method,omitempty"` + ExportBytes int64 `json:"export_bytes,omitempty"` + TableCount int `json:"table_count,omitempty"` + Matched *bool `json:"matched,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Stage string `json:"stage,omitempty"` + Message string `json:"message,omitempty"` +} + +type d1ImportNotificationsService struct { + client *Client +} + +func (s *d1ImportNotificationsService) Create(ctx context.Context, req *CreateD1ImportNotificationRequest) error { + if req == nil { + return fmt.Errorf("nil CreateD1ImportNotificationRequest") + } + if req.Organization == "" || req.Database == "" { + return fmt.Errorf("organization and database are required") + } + if req.MigrationID == "" || req.Event == "" { + return fmt.Errorf("migration_id and event are required") + } + + body := createD1ImportNotificationRequest{ + BranchName: req.BranchName, + MigrationID: req.MigrationID, + Event: req.Event, + Method: req.Method, + ExportBytes: req.ExportBytes, + TableCount: req.TableCount, + Matched: req.Matched, + DurationMs: req.DurationMs, + Error: req.Error, + ErrorCode: req.ErrorCode, + Stage: req.Stage, + Message: req.Message, + } + + p := path.Join("internal/organizations", req.Organization, "databases", req.Database, "d1-import-notifications") + httpReq, err := s.client.newRequest(http.MethodPost, p, body) + if err != nil { + return err + } + + return s.client.do(ctx, httpReq, nil) +} diff --git a/internal/planetscale/d1_import_notifications_test.go b/internal/planetscale/d1_import_notifications_test.go new file mode 100644 index 000000000..5330db3ad --- /dev/null +++ b/internal/planetscale/d1_import_notifications_test.go @@ -0,0 +1,128 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestD1ImportNotifications_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/internal/organizations/my-org/databases/my-db/d1-import-notifications") + + var body createD1ImportNotificationRequest + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body.MigrationID, qt.Equals, "abc123") + c.Assert(body.Event, qt.Equals, "imported") + c.Assert(body.BranchName, qt.Equals, "main") + c.Assert(body.Method, qt.Equals, "pgloader") + c.Assert(body.ExportBytes, qt.Equals, int64(1024)) + c.Assert(body.TableCount, qt.Equals, 3) + + w.WriteHeader(http.StatusAccepted) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.D1ImportNotifications.Create(context.Background(), &CreateD1ImportNotificationRequest{ + Organization: "my-org", + Database: "my-db", + BranchName: "main", + MigrationID: "abc123", + Event: "imported", + Method: "pgloader", + ExportBytes: 1024, + TableCount: 3, + }) + c.Assert(err, qt.IsNil) +} + +func TestD1ImportNotifications_CreateProgressWithStageAndMessage(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body createD1ImportNotificationRequest + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body.Event, qt.Equals, "progress") + c.Assert(body.Method, qt.Equals, "pgloader") + c.Assert(body.Stage, qt.Equals, "sqlite_staging") + c.Assert(body.Message, qt.Equals, "Staging SQLite database from export...") + c.Assert(body.Error, qt.Equals, "") + + w.WriteHeader(http.StatusAccepted) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.D1ImportNotifications.Create(context.Background(), &CreateD1ImportNotificationRequest{ + Organization: "my-org", + Database: "my-db", + MigrationID: "abc123", + Event: "progress", + Method: "pgloader", + Stage: "sqlite_staging", + Message: "Staging SQLite database from export...", + }) + c.Assert(err, qt.IsNil) +} + +func TestD1ImportNotifications_CreateOmitsEmptyBranchName(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body createD1ImportNotificationRequest + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body.BranchName, qt.Equals, "") + + w.WriteHeader(http.StatusAccepted) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.D1ImportNotifications.Create(context.Background(), &CreateD1ImportNotificationRequest{ + Organization: "my-org", + Database: "my-db", + MigrationID: "abc123", + Event: "complete", + }) + c.Assert(err, qt.IsNil) +} + +func TestD1ImportNotifications_CreateError(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{"code":"not_found","message":"Not Found"}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.D1ImportNotifications.Create(context.Background(), &CreateD1ImportNotificationRequest{ + Organization: "my-org", + Database: "my-db", + MigrationID: "abc123", + Event: "imported", + }) + + wantError := &Error{ + msg: "Not Found", + Code: ErrNotFound, + } + c.Assert(err.Error(), qt.Equals, wantError.Error()) +} diff --git a/internal/planetscale/databases.go b/internal/planetscale/databases.go new file mode 100644 index 000000000..f8313aae7 --- /dev/null +++ b/internal/planetscale/databases.go @@ -0,0 +1,187 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type DatabaseEngine string + +const ( + DatabaseEngineMySQL DatabaseEngine = "mysql" + DatabaseEnginePostgres DatabaseEngine = "postgresql" +) + +// StorageConfig represents storage size configuration for a database or branch. +type StorageConfig struct { + MinimumStorageBytes *int64 `json:"minimum_storage_bytes,omitempty"` + MaximumStorageBytes *int64 `json:"maximum_storage_bytes,omitempty"` +} + +// 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"` +} + +// DatabaseRequest encapsulates the request for getting a single database. +type GetDatabaseRequest struct { + Organization string + Database string +} + +// ListDatabasesRequest encapsulates the request for listing all databases in an +// organization. +type ListDatabasesRequest struct { + Organization string +} + +// DeleteDatabaseRequest encapsulates the request for deleting a database from +// an organization. +type DeleteDatabaseRequest struct { + Organization string + Database string +} + +// DatabaseService is an interface for communicating with the PlanetScale +// Databases API endpoint. +type DatabasesService interface { + Create(context.Context, *CreateDatabaseRequest) (*Database, error) + Get(context.Context, *GetDatabaseRequest) (*Database, error) + List(context.Context, *ListDatabasesRequest, ...ListOption) ([]*Database, error) + Delete(context.Context, *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) +} + +// DatabaseDeletionRequest encapsulates the request for deleting a database from +// an organization. +type DatabaseDeletionRequest struct { + ID string `json:"id"` + Actor Actor `json:"actor"` +} + +// DatabaseState represents the state of a database +type DatabaseState string + +const ( + DatabasePending DatabaseState = "pending" + DatabaseImporting DatabaseState = "importing" + DatabaseAwakening DatabaseState = "awakening" + DatabaseSleepInProgress DatabaseState = "sleep_in_progress" + DatabaseSleeping DatabaseState = "sleeping" + DatabaseReady DatabaseState = "ready" +) + +// Database represents a PlanetScale database +type Database struct { + Name string `json:"name"` + Notes string `json:"notes"` + Region Region `json:"region"` + State DatabaseState `json:"state"` + Kind DatabaseEngine `json:"kind"` + HtmlURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Database represents a list of PlanetScale databases +type databasesResponse struct { + Databases []*Database `json:"data"` +} + +type databasesService struct { + client *Client +} + +var _ DatabasesService = &databasesService{} + +func NewDatabasesService(client *Client) *databasesService { + return &databasesService{ + client: client, + } +} + +func (ds *databasesService) List(ctx context.Context, listReq *ListDatabasesRequest, opts ...ListOption) ([]*Database, error) { + path := databasesAPIPath(listReq.Organization) + + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := ds.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dbResponse := databasesResponse{} + err = ds.client.do(ctx, req, &dbResponse) + if err != nil { + return nil, err + } + + return dbResponse.Databases, nil +} + +func (ds *databasesService) Create(ctx context.Context, createReq *CreateDatabaseRequest) (*Database, error) { + req, err := ds.client.newRequest(http.MethodPost, databasesAPIPath(createReq.Organization), createReq) + if err != nil { + return nil, fmt.Errorf("error creating request for create database: %w", err) + } + + db := &Database{} + err = ds.client.do(ctx, req, &db) + if err != nil { + return nil, err + } + + return db, nil +} + +func (ds *databasesService) Get(ctx context.Context, getReq *GetDatabaseRequest) (*Database, error) { + path := path.Join(databasesAPIPath(getReq.Organization), getReq.Database) + req, err := ds.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get database: %w", err) + } + + db := &Database{} + err = ds.client.do(ctx, req, &db) + if err != nil { + return nil, err + } + + return db, nil +} + +func (ds *databasesService) Delete(ctx context.Context, deleteReq *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) { + path := path.Join(databasesAPIPath(deleteReq.Organization), deleteReq.Database) + req, err := ds.client.newRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for delete database: %w", err) + } + + var dbr *DatabaseDeletionRequest + err = ds.client.do(ctx, req, &dbr) + if err != nil { + return nil, err + } + + return dbr, nil +} + +func databasesAPIPath(org string) string { + return path.Join("v1/organizations", org, "databases") +} diff --git a/internal/planetscale/databases_test.go b/internal/planetscale/databases_test.go new file mode 100644 index 000000000..00d552069 --- /dev/null +++ b/internal/planetscale/databases_test.go @@ -0,0 +1,466 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const ( + testOrg = "my-org" + testDatabase = "planetscale-go-test-db" +) + +func TestDatabases_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","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() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: org, + Region: "us-west", + Name: name, + Notes: notes, + }) + + want := &Database{ + Name: name, + Notes: notes, + State: DatabaseReady, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_CreatePostgres(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["kind"], qt.Equals, "postgresql") + + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","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","kind":"postgresql"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: org, + Region: "us-west", + Name: name, + Notes: notes, + Kind: DatabaseEnginePostgres, + }) + + want := &Database{ + Name: name, + Notes: notes, + State: DatabaseReady, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Kind: "postgresql", + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_CreateWithReplicas(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["replicas"], qt.Equals, float64(3)) + + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","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() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + replicas := 3 + + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: org, + Region: "us-west", + Name: name, + Notes: notes, + Replicas: &replicas, + }) + + want := &Database{ + Name: name, + Notes: notes, + State: DatabaseReady, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_CreateWithReplicasZero(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) + + // With omitempty and *int type, replicas field SHOULD be present when explicitly set to 0 + replicas, hasReplicas := body["replicas"] + c.Assert(hasReplicas, qt.IsTrue, qt.Commentf("replicas field should be present when explicitly set to 0")) + c.Assert(replicas, qt.Equals, float64(0)) + + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","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() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + replicas := 0 + + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: org, + Region: "us-west", + Name: name, + Notes: notes, + Replicas: &replicas, + }) + + want := &Database{ + Name: name, + Notes: notes, + State: DatabaseReady, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_CreateWithStorage(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodPost) + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + storage, ok := body["storage"].(map[string]any) + c.Assert(ok, qt.IsTrue, qt.Commentf("storage field should be a nested object")) + c.Assert(storage["minimum_storage_bytes"], qt.Equals, float64(10737418240)) + c.Assert(storage["maximum_storage_bytes"], qt.Equals, float64(107374182400)) + + 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","kind":"postgresql"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + minStorage := int64(10737418240) + maxStorage := int64(107374182400) + + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: testOrg, + Region: "us-west", + Name: testDatabase, + Kind: DatabaseEnginePostgres, + Storage: &StorageConfig{ + MinimumStorageBytes: &minStorage, + MaximumStorageBytes: &maxStorage, + }, + }) + + want := &Database{ + Name: testDatabase, + State: DatabaseReady, + Kind: DatabaseEnginePostgres, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + + db, err := client.Databases.Get(ctx, &GetDatabaseRequest{ + Organization: org, + Database: name, + }) + + want := &Database{ + Name: name, + Notes: notes, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-db","type":"database", "name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + + db, err := client.Databases.List(ctx, &ListDatabasesRequest{ + Organization: org, + }) + + want := []*Database{{ + Name: name, + Notes: notes, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_ListWithOptions(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"planetscale-go-test-db","type":"database", "name":"planetscale-go-test-db","notes":"This is a test DB created from the planetscale-go API library","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "100") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "planetscale-go-test-db" + notes := "This is a test DB created from the planetscale-go API library" + + db, err := client.Databases.List(ctx, &ListDatabasesRequest{ + Organization: org, + }, WithPage(2)) + + want := []*Database{{ + Name: name, + Notes: notes, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.DeepEquals, want) +} + +func TestDatabases_DeleteNoContent(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + _, err := w.Write(nil) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + + dbr, err := client.Databases.Delete(ctx, &DeleteDatabaseRequest{ + Organization: org, + Database: "planetscale-go-test-db", + }) + + c.Assert(err, qt.IsNil) + c.Assert(dbr, qt.IsNil) +} + +func TestDatabases_DeleteAccepted(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + out := `{"id": "planetscale-go-test-db"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + + dbr, err := client.Databases.Delete(ctx, &DeleteDatabaseRequest{ + Organization: org, + Database: "planetscale-go-test-db", + }) + + want := &DatabaseDeletionRequest{ + ID: "planetscale-go-test-db", + } + + c.Assert(err, qt.IsNil) + c.Assert(dbr, qt.DeepEquals, want) +} + +func TestDatabases_List_malformed_response(t *testing.T) { + c := qt.New(t) + + malformedBody := `400 Bad Request +
nginx
` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + _, err := w.Write([]byte(malformedBody)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + + _, err = client.Databases.List(ctx, &ListDatabasesRequest{ + Organization: org, + }) + + c.Assert(err, qt.Not(qt.IsNil)) + c.Assert(err, qt.ErrorMatches, `malformed error response body received`) +} + +func TestDatabases_Empty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + + db, err := client.Databases.List(ctx, &ListDatabasesRequest{ + Organization: org, + }) + + c.Assert(err, qt.IsNil) + c.Assert(db, qt.HasLen, 0) +} diff --git a/internal/planetscale/dependency_test.go b/internal/planetscale/dependency_test.go new file mode 100644 index 000000000..5087faa60 --- /dev/null +++ b/internal/planetscale/dependency_test.go @@ -0,0 +1,60 @@ +package planetscale + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// The API client is vendored in this package; the CLI must not depend on +// the external planetscale-go module again. See doc/api-client.md. +// +// The strings are split so this file does not match its own checks. +const ( + bannedModule = "github.com/planetscale/" + "planetscale-go" + bannedImport = `"` + bannedModule +) + +func TestNoPlanetscaleGoDependency(t *testing.T) { + root := filepath.Join("..", "..") + + gomod, err := os.ReadFile(filepath.Join(root, "go.mod")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(gomod), bannedModule) { + t.Errorf("go.mod requires %s; the API client is vendored at internal/planetscale and that module must not come back. See doc/api-client.md", bannedModule) + } + + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path == root { + return nil + } + name := d.Name() + if strings.HasPrefix(name, ".") || name == "testdata" || name == "bin" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), bannedImport) { + t.Errorf("%s imports %s; use github.com/planetscale/cli/internal/planetscale instead. See doc/api-client.md", path, bannedModule) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/planetscale/deploy_requests.go b/internal/planetscale/deploy_requests.go new file mode 100644 index 000000000..024cd9ad7 --- /dev/null +++ b/internal/planetscale/deploy_requests.go @@ -0,0 +1,542 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +type deployRequestsService struct { + client *Client +} + +var _ DeployRequestsService = (*deployRequestsService)(nil) + +// DeployRequestsService is an interface for communicating with the PlanetScale +// deploy requests API. +type DeployRequestsService interface { + ApplyDeploy(context.Context, *ApplyDeployRequestRequest) (*DeployRequest, error) + AutoApplyDeploy(context.Context, *AutoApplyDeployRequestRequest) (*DeployRequest, error) + CancelDeploy(context.Context, *CancelDeployRequestRequest) (*DeployRequest, error) + CloseDeploy(context.Context, *CloseDeployRequestRequest) (*DeployRequest, error) + Create(context.Context, *CreateDeployRequestRequest) (*DeployRequest, error) + CreateReview(context.Context, *ReviewDeployRequestRequest) (*DeployRequestReview, error) + Deploy(context.Context, *PerformDeployRequest) (*DeployRequest, error) + Diff(ctx context.Context, diffReq *DiffRequest) ([]*Diff, error) + Get(context.Context, *GetDeployRequestRequest) (*DeployRequest, error) + List(context.Context, *ListDeployRequestsRequest) ([]*DeployRequest, error) + GetDeployOperations(context.Context, *GetDeployOperationsRequest) ([]*DeployOperation, error) + SkipRevertDeploy(context.Context, *SkipRevertDeployRequestRequest) (*DeployRequest, error) + RevertDeploy(context.Context, *RevertDeployRequestRequest) (*DeployRequest, error) +} + +// DeployRequestReview posts a review to a deploy request. +type DeployRequestReview struct { + ID string `json:"id"` + Body string `json:"body"` + State string `json:"state"` + Actor Actor `json:"actor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PerformDeployRequest is a request for approving and deploying a deploy request. +// NOTE: We deviate from naming convention here because we have a data model +// named DeployRequest already. +type PerformDeployRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` + InstantDDL bool `json:"instant_ddl"` + Strategy string `json:"strategy,omitempty"` +} + +// GetDeployRequest encapsulates the request for getting a single deploy +// request. +type GetDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// ListDeployRequestsRequest gets the deploy requests for a specific database +// branch. +type ListDeployRequestsRequest struct { + Organization string + Database string + State string + Branch string + IntoBranch string +} + +// GetDeployOperationsRequest encapsulates the request for getting a deploy +// operation for a deploy request. +type GetDeployOperationsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// DeployOperation encapsulates a deploy operation within a deployment from the +// PlanetScale API. +type DeployOperation struct { + ID string `json:"id"` + State string `json:"state"` + Table string `json:"table_name"` + Keyspace string `json:"keyspace_name"` + Operation string `json:"operation_name"` + ETASeconds int64 `json:"eta_seconds"` + ProgressPercentage uint64 `json:"progress_percentage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// QueuedDeployment encapsulates a deployment that is in the queue. +type QueuedDeployment struct { + ID string `json:"id"` + State string `json:"state"` + DeployRequestNumber uint64 `json:"deploy_request_number"` + IntoBranch string `json:"into_branch"` + + Actor *Actor `json:"actor"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + QueuedAt *time.Time `json:"queued_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +// DeploymentLintError represents an error that occurs during the deployment +// flow. +type DeploymentLintError struct { + LintError string `json:"lint_error"` + Keyspace string `json:"keyspace_name"` + Table string `json:"table_name"` + SubjectType string `json:"subject_type"` + ErrorDescription string `json:"error_description"` + DocsUrl string `json:"docs_url"` +} + +// Deployment encapsulates a deployment for a deploy request. +type Deployment struct { + ID string `json:"id"` + State string `json:"state"` + Deployable bool `json:"deployable"` + LintErrors []*DeploymentLintError `json:"lint_errors"` + DeployRequestNumber uint64 `json:"deploy_request_number"` + IntoBranch string `json:"into_branch"` + PrecedingDeployments []*QueuedDeployment `json:"preceding_deployments"` + + InstantDDLEligible bool `json:"instant_ddl_eligible"` + InstantDDL bool `json:"instant_ddl"` + + Actor *Actor `json:"actor"` + CutoverActor *Actor `json:"cutover_actor"` + CancelledActor *Actor `json:"cancelled_actor"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + QueuedAt *time.Time `json:"queued_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +// DeployRequest encapsulates the request to deploy a database branch's schema +// to a production branch +type DeployRequest struct { + ID string `json:"id"` + + Branch string `json:"branch"` + IntoBranch string `json:"into_branch"` + + Actor Actor `json:"actor"` + ClosedBy *Actor `json:"closed_by"` + BranchDeletedBy *Actor `json:"branch_deleted_by"` + Number uint64 `json:"number"` + + State string `json:"state"` + + DeploymentState string `json:"deployment_state"` + + Approved bool `json:"approved"` + + Notes string `json:"notes"` + + Deployment *Deployment `json:"deployment"` + + HtmlURL string `json:"html_url"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClosedAt *time.Time `json:"closed_at"` + DeployedAt *time.Time `json:"deployed_at"` +} + +type ApplyDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +type AutoApplyDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` + Enable bool `json:"-"` +} + +type CancelDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +type CreateDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"branch"` + IntoBranch string `json:"into_branch,omitempty"` + Notes string `json:"notes"` + AutoCutover bool `json:"auto_cutover,omitempty"` + AutoDeleteBranch bool `json:"auto_delete_branch,omitempty"` +} + +type SkipRevertDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +type RevertDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +type ReviewDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` + + // CommentText represents the comment body to be posted + CommentText string `json:"-"` + + // ReviewAction defines the action for an individual review. + ReviewAction ReviewAction `json:"-"` +} + +// ReviewAction defines the action for an individual review. +type ReviewAction int + +const ( + // Comment is used to comment a Review with a custom text. + ReviewComment ReviewAction = iota + + // Approve is used to approve a Review. + ReviewApprove +) + +func (r ReviewAction) String() string { + switch r { + case ReviewApprove: + return "approved" + case ReviewComment: + fallthrough + default: + return "commented" + } +} + +type CloseDeployRequestRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +func NewDeployRequestsService(client *Client) *deployRequestsService { + return &deployRequestsService{ + client: client, + } +} + +// Get fetches a single deploy request. +func (d *deployRequestsService) Get(ctx context.Context, getReq *GetDeployRequestRequest) (*DeployRequest, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestAPIPath(getReq.Organization, getReq.Database, getReq.Number), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +type CloseRequest struct { + State string `json:"state"` +} + +// CloseDeploy closes a deploy request +func (d *deployRequestsService) CloseDeploy(ctx context.Context, closeReq *CloseDeployRequestRequest) (*DeployRequest, error) { + updateReq := &CloseRequest{ + State: "closed", + } + + req, err := d.client.newRequest(http.MethodPatch, deployRequestAPIPath(closeReq.Organization, closeReq.Database, closeReq.Number), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +// Deploy approves and executes a specific deploy request. +func (d *deployRequestsService) Deploy(ctx context.Context, deployReq *PerformDeployRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(deployReq.Organization, deployReq.Database, deployReq.Number, "deploy") + req, err := d.client.newRequest(http.MethodPost, path, deployReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +type deployRequestsResponse struct { + DeployRequests []*DeployRequest `json:"data"` +} + +func (d *deployRequestsService) Create(ctx context.Context, createReq *CreateDeployRequestRequest) (*DeployRequest, error) { + path := deployRequestsAPIPath(createReq.Organization, createReq.Database) + req, err := d.client.newRequest(http.MethodPost, path, createReq) + if err != nil { + return nil, err + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + return dr, nil +} + +// CancelDeploy cancels a queued deploy request. +func (d *deployRequestsService) CancelDeploy(ctx context.Context, deployReq *CancelDeployRequestRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(deployReq.Organization, deployReq.Database, deployReq.Number, "cancel") + req, err := d.client.newRequest(http.MethodPost, path, deployReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +func (d *deployRequestsService) ApplyDeploy(ctx context.Context, applyReq *ApplyDeployRequestRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(applyReq.Organization, applyReq.Database, applyReq.Number, "apply-deploy") + req, err := d.client.newRequest(http.MethodPost, path, applyReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + drr := &DeployRequest{} + if err := d.client.do(ctx, req, &drr); err != nil { + return nil, err + } + + return drr, nil +} + +func (d *deployRequestsService) AutoApplyDeploy(ctx context.Context, autoApplyReq *AutoApplyDeployRequestRequest) (*DeployRequest, error) { + reqBody := struct { + Enable bool `json:"enable"` + }{ + Enable: autoApplyReq.Enable, + } + + path := deployRequestActionAPIPath(autoApplyReq.Organization, autoApplyReq.Database, autoApplyReq.Number, "auto-apply") + req, err := d.client.newRequest(http.MethodPut, path, reqBody) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + drr := &DeployRequest{} + if err := d.client.do(ctx, req, &drr); err != nil { + return nil, err + } + + return drr, nil +} + +// SkipRevert skips a pending revert of a completed deploy request +func (d *deployRequestsService) SkipRevertDeploy(ctx context.Context, deployReq *SkipRevertDeployRequestRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(deployReq.Organization, deployReq.Database, deployReq.Number, "skip-revert") + req, err := d.client.newRequest(http.MethodPost, path, deployReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +// RevertDeploy reverts a completed deploy request +func (d *deployRequestsService) RevertDeploy(ctx context.Context, deployReq *RevertDeployRequestRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(deployReq.Organization, deployReq.Database, deployReq.Number, "revert") + req, err := d.client.newRequest(http.MethodPost, path, deployReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + +// Diff returns the diff for a database deploy request +type Diff struct { + Name string `json:"name"` + Raw string `json:"raw"` + HTML string `json:"html"` +} + +type diffResponse struct { + Diffs []*Diff `json:"data"` +} + +type DiffRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// Diff returns a diff +func (d *deployRequestsService) Diff(ctx context.Context, diffReq *DiffRequest) ([]*Diff, error) { + req, err := d.client.newRequest( + http.MethodGet, + deployRequestActionAPIPath(diffReq.Organization, diffReq.Database, diffReq.Number, "diff"), + nil, + ) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + diffs := &diffResponse{} + if err := d.client.do(ctx, req, &diffs); err != nil { + return nil, err + } + + return diffs.Diffs, nil +} + +func (d *deployRequestsService) List(ctx context.Context, listReq *ListDeployRequestsRequest) ([]*DeployRequest, error) { + baseURL := deployRequestsAPIPath(listReq.Organization, listReq.Database) + + queryParams := url.Values{} + if listReq.State != "" { + queryParams.Set("state", listReq.State) + } + if listReq.Branch != "" { + queryParams.Set("branch", listReq.Branch) + } + if listReq.IntoBranch != "" { + queryParams.Set("into_branch", listReq.IntoBranch) + } + + req, err := d.client.newRequest(http.MethodGet, baseURL, nil, WithQueryParams(queryParams)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + drReq := &deployRequestsResponse{} + if err := d.client.do(ctx, req, &drReq); err != nil { + return nil, err + } + + return drReq.DeployRequests, nil +} + +func (d *deployRequestsService) CreateReview(ctx context.Context, reviewReq *ReviewDeployRequestRequest) (*DeployRequestReview, error) { + reqBody := struct { + State string `json:"state"` + Body string `json:"body"` + }{ + State: reviewReq.ReviewAction.String(), + Body: reviewReq.CommentText, + } + + req, err := d.client.newRequest(http.MethodPost, + deployRequestActionAPIPath( + reviewReq.Organization, + reviewReq.Database, + reviewReq.Number, + "reviews", + ), reqBody) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + drr := &DeployRequestReview{} + if err := d.client.do(ctx, req, &drr); err != nil { + return nil, err + } + + return drr, nil +} + +type deployOperationResponse struct { + Ops []*DeployOperation `json:"data"` +} + +func (d *deployRequestsService) GetDeployOperations(ctx context.Context, getReq *GetDeployOperationsRequest) ([]*DeployOperation, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(getReq.Organization, getReq.Database, getReq.Number, "operations"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &deployOperationResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Ops, nil +} + +func deployRequestsAPIPath(org, db string) string { + return path.Join(databasesAPIPath(org), db, "deploy-requests") +} + +// deployRequestAPIPath gets the base path for accessing a single deploy request +func deployRequestAPIPath(org string, db string, number uint64) string { + return path.Join(databasesAPIPath(org), db, "deploy-requests", fmt.Sprintf("%d", number)) +} + +func deployRequestActionAPIPath(org string, db string, number uint64, actionPath string) string { + return path.Join(deployRequestAPIPath(org, db, number), actionPath) +} diff --git a/internal/planetscale/deploy_requests_test.go b/internal/planetscale/deploy_requests_test.go new file mode 100644 index 000000000..9630d17ea --- /dev/null +++ b/internal/planetscale/deploy_requests_test.go @@ -0,0 +1,581 @@ +package planetscale + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestDeployRequests_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": null, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.Get(ctx, &GetDeployRequestRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Number: 1337, + Branch: "development", + IntoBranch: "some-branch", + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: nil, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_Deploy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued"}, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + IntoBranch: "some-branch", + Number: 1337, + Deployment: &Deployment{ + State: "queued", + }, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_InstantDeploy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + InstantDDL bool `json:"instant_ddl"` + } + err := json.NewDecoder(r.Body).Decode(&request) + c.Assert(err, qt.IsNil) + c.Assert(request.InstantDDL, qt.Equals, true) + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued", "instant_ddl": true }, "number": 1337}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + InstantDDL: true, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + IntoBranch: "some-branch", + Number: 1337, + Deployment: &Deployment{ + State: "queued", + InstantDDL: true, + }, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_DeployWithStrategy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + InstantDDL bool `json:"instant_ddl"` + Strategy string `json:"strategy"` + } + err := json.NewDecoder(r.Body).Decode(&request) + c.Assert(err, qt.IsNil) + c.Assert(request.Strategy, qt.Equals, "parallel") + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued" }, "number": 1337}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + _, err = client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + Strategy: "parallel", + }) + c.Assert(err, qt.IsNil) +} + +func TestDeployRequests_DeployOmitsEmptyStrategy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(strings.Contains(string(raw), "strategy"), qt.IsFalse) + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "queued" }, "number": 1337}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + _, err = client.DeployRequests.Deploy(ctx, &PerformDeployRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + c.Assert(err, qt.IsNil) +} + +func TestDeployRequests_CancelDeploy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "pending" }, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.CancelDeploy(ctx, &CancelDeployRequestRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + Deployment: &Deployment{ + State: "pending", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_Close(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "pending" }, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.CloseDeploy(ctx, &CloseDeployRequestRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + Deployment: &Deployment{ + State: "pending", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "number": 1337, "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + requests, err := client.DeployRequests.Create(ctx, &CreateDeployRequestRequest{ + Organization: testOrg, + Database: testDatabase, + Notes: "", + AutoDeleteBranch: true, + AutoCutover: false, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Number: 1337, + Branch: "development", + IntoBranch: "some-branch", + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(requests, qt.DeepEquals, want) +} + +func TestDeployRequests_Review(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-review-id","type": "DeployRequestReview","body": "test body","html_body": "","state": "approved","created_at": "2021-01-14T10:19:23.000Z","updated_at": "2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + requests, err := client.DeployRequests.CreateReview(ctx, &ReviewDeployRequestRequest{ + Organization: testOrg, + Database: testDatabase, + CommentText: "test body", + ReviewAction: ReviewApprove, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequestReview{ + ID: "test-review-id", + Body: "test body", + State: "approved", + CreatedAt: testTime, + UpdatedAt: testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(requests, qt.DeepEquals, want) +} + +func TestDeployRequests_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data": [{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + requests, err := client.DeployRequests.List(ctx, &ListDeployRequestsRequest{ + Organization: testOrg, + Database: testDatabase, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := []*DeployRequest{ + { + ID: "test-deploy-request-id", + Branch: "development", + IntoBranch: "some-branch", + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(requests, qt.DeepEquals, want) +} + +func TestDeployRequests_ListQueryParams(t *testing.T) { + c := qt.New(t) + + var receivedQueryParams url.Values + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedQueryParams = r.URL.Query() + + w.WriteHeader(200) + out := `{"data": [{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + requests, err := client.DeployRequests.List(ctx, &ListDeployRequestsRequest{ + Organization: testOrg, + Database: testDatabase, + State: "closed", + Branch: "dev", + IntoBranch: "main", + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := []*DeployRequest{ + { + ID: "test-deploy-request-id", + Branch: "development", + IntoBranch: "some-branch", + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(requests, qt.DeepEquals, want) + + // Assert the expected query parameters + c.Assert(receivedQueryParams.Get("state"), qt.Equals, "closed") + c.Assert(receivedQueryParams.Get("branch"), qt.Equals, "dev") + c.Assert(receivedQueryParams.Get("into_branch"), qt.Equals, "main") +} + +func TestDeployRequests_SkipRevertDeploy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "complete" }, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.SkipRevertDeploy(ctx, &SkipRevertDeployRequestRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + Deployment: &Deployment{ + State: "complete", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_RevertDeploy(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": "2021-01-14T10:19:23.000Z", "deployment": { "state": "complete_revert" }, "number": 1337}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + dr, err := client.DeployRequests.RevertDeploy(ctx, &RevertDeployRequestRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := &DeployRequest{ + ID: "test-deploy-request-id", + Branch: "development", + Deployment: &Deployment{ + State: "complete_revert", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + ClosedAt: &testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_DeployOperations(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "type":"list", + "current_page":1, + "data":[ + { + "id":"test-operation-id", + "type":"DeployOperation", + "state":"pending", + "keyspace_name":"treats", + "table_name":"ice_creams", + "operation_name":"CREATE", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + } + ] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + do, err := client.DeployRequests.GetDeployOperations(ctx, &GetDeployOperationsRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC) + + want := []*DeployOperation{{ + ID: "test-operation-id", + State: "pending", + Table: "ice_creams", + Keyspace: "treats", + Operation: "CREATE", + ETASeconds: 0, + ProgressPercentage: 0, + CreatedAt: testTime, + UpdatedAt: testTime, + }} + c.Assert(err, qt.IsNil) + c.Assert(do, qt.DeepEquals, want) +} diff --git a/internal/planetscale/doc.go b/internal/planetscale/doc.go new file mode 100644 index 000000000..12f018cda --- /dev/null +++ b/internal/planetscale/doc.go @@ -0,0 +1,7 @@ +// Package planetscale is the CLI's client for the PlanetScale API. +// +// It was vendored from github.com/planetscale/planetscale-go (v0.178.0) in +// July 2026 and is maintained here; the CLI no longer depends on that +// module. New endpoints are added directly to this package in the same PR +// as the CLI feature that uses them. See doc/api-client.md for background. +package planetscale diff --git a/internal/planetscale/imports.go b/internal/planetscale/imports.go new file mode 100644 index 000000000..71cbd869e --- /dev/null +++ b/internal/planetscale/imports.go @@ -0,0 +1,361 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type BillingPlan int + +const ( + HobbyPlan BillingPlan = iota + ScalerProPlan +) + +func (bp BillingPlan) String() string { + switch bp { + case ScalerProPlan: + return "scaler_pro" + default: + return "developer" + } +} + +var planToBillingPlanMap = map[string]BillingPlan{ + "scaler_pro": ScalerProPlan, + "developer": HobbyPlan, +} + +type DataImportSource struct { + HostName string `json:"hostname"` + Database string `json:"schema_name"` + Port int `json:"port"` + SSLMode string `json:"ssl_mode"` + SSLVerificationMode ExternalDataSourceSSLVerificationMode + UserName string `json:"username"` + Password string `json:"password"` + SSLCA string `json:"ssl_ca"` + SSLCertificate string `json:"ssl_cert"` + SSLKey string `json:"ssl_key"` + SSLServerName string `json:"ssl_server_name"` +} + +type ExternalDataSourceSSLVerificationMode int + +const ( + SSLModeDisabled ExternalDataSourceSSLVerificationMode = iota + SSLModePreferred + SSLModeRequired + SSLModeVerifyCA + SSLModeVerifyIdentity +) + +func (sm ExternalDataSourceSSLVerificationMode) String() string { + switch sm { + case SSLModeDisabled: + return "disabled" + case SSLModePreferred: + return "preferred" + case SSLModeRequired: + return "required" + case SSLModeVerifyCA: + return "verify_ca" + default: + return "verify_identity" + } +} + +type DataImportState int + +const ( + DataImportPreparingDataCopy DataImportState = iota + DataImportPreparingDataCopyFailed + DataImportCopyingData + DataImportCopyingDataFailed + DataImportSwitchTrafficPending + DataImportSwitchTrafficRunning + DataImportSwitchTrafficCompleted + DataImportSwitchTrafficError + DataImportReverseTrafficRunning + DataImportReverseTrafficCompleted + DataImportReverseTrafficError + DataImportDetachExternalDatabaseRunning + DataImportDetachExternalDatabaseError + DataImportReady +) + +var stateToImportStateMap = map[string]DataImportState{ + "prepare_data_copy_pending": DataImportPreparingDataCopy, + "prepare_data_copy_error": DataImportPreparingDataCopyFailed, + "data_copy_pending": DataImportCopyingData, + "data_copy_error": DataImportCopyingDataFailed, + "switch_traffic_workflow_pending": DataImportSwitchTrafficPending, + "switch_traffic_workflow_running": DataImportSwitchTrafficRunning, + "switch_traffic_workflow_error": DataImportSwitchTrafficError, + "reverse_traffic_workflow_running": DataImportReverseTrafficRunning, + "reverse_traffic_workflow_error": DataImportReverseTrafficError, + "cleanup_workflow_pending": DataImportSwitchTrafficCompleted, + "cleanup_workflow_running": DataImportDetachExternalDatabaseRunning, + "cleanup_workflow_error": DataImportDetachExternalDatabaseError, + "ready": DataImportReady, +} + +var importStateToDescMap = map[DataImportState]string{ + DataImportPreparingDataCopy: "Preparing to copy data from external database", + DataImportPreparingDataCopyFailed: "Failed to copy data from external database", + DataImportCopyingData: "Copying data from external database", + DataImportCopyingDataFailed: "Failed to copy data from external database", + DataImportSwitchTrafficPending: "PlanetScale database is running in replica mode", + DataImportSwitchTrafficRunning: "Switching PlanetScale database to primary mode", + DataImportSwitchTrafficError: "Failed to switching PlanetScale database to primary mode", + DataImportReverseTrafficRunning: "Switching PlanetScale database to replica mode", + DataImportReverseTrafficError: "Failed to switching PlanetScale database to replica mode", + DataImportDetachExternalDatabaseRunning: "Detaching external database from PlanetScale database", + DataImportDetachExternalDatabaseError: "Failed to detach external database from PlanetScale database", + DataImportReady: "Import has completed and your PlanetScale Database is now ready", +} + +func (d DataImportState) String() string { + if val, ok := importStateToDescMap[d]; ok { + return val + } + + panic("unknown data import state") +} + +type DataImport struct { + ID string `json:"id"` + ImportState DataImportState + State string `json:"state"` + Errors string `json:"import_check_errors"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + DeletedAt *time.Time `json:"deleted_at"` + ExternalDataSource DataImportSource `json:"data_source"` +} + +func (di *DataImport) ParseState() { + if val, ok := stateToImportStateMap[di.State]; ok { + di.ImportState = val + return + } + + panic("unknown data import state " + di.State) +} + +type TestDataImportSourceRequest struct { + Organization string `json:"organization"` + Database string `json:"database_name"` + Connection DataImportSource `json:"connection"` +} + +// DataSourceIncompatibilityError represents an error that occurs when the +// source schema in an external database server is incompatible with PlanetScale. +type DataSourceIncompatibilityError struct { + LintError string `json:"lint_error"` + Keyspace string `json:"keyspace_name"` + Table string `json:"table_name"` + SubjectType string `json:"subject_type"` + ErrorDescription string `json:"error_description"` + DocsUrl string `json:"docs_url"` +} + +type UserShouldUpgradePlanError struct{} + +func (e UserShouldUpgradePlanError) Error() string { + return "Importing databases over 5GB requires a paid plan. Log in to app.planetscale.com to upgrade." +} + +type TestDataImportSourceResponse struct { + CanConnect bool `json:"can_connect"` + ShouldUpgradePlan bool `json:"should_upgrade"` + SuggestedPlan string `json:"suggested_plan"` + SuggestedBillingPlan BillingPlan + ConnectError string `json:"error"` + Errors []*DataSourceIncompatibilityError `json:"lint_errors"` + MaxPoolSize int `json:"max_pool_size"` +} + +type StartDataImportRequest struct { + Organization string `json:"organization"` + Database string `json:"database_name"` + Connection DataImportSource `json:"connection"` + Region string `json:"region"` + Plan string `json:"plan"` + MaxPoolSize int `json:"max_pool_size"` +} + +type MakePlanetScalePrimaryRequest struct { + Organization string + Database string +} + +type MakePlanetScaleReplicaRequest struct { + Organization string + Database string +} + +type DetachExternalDatabaseRequest struct { + Organization string + Database string +} + +type GetImportStatusRequest struct { + Organization string + Database string +} + +type CancelDataImportRequest struct { + Organization string + Database string +} + +// DataImportsService is an interface for communicating with the PlanetScale +// Data Imports API endpoint. +type DataImportsService interface { + // TestDataImportSource checks if the external database that we're importing will be supported + // by PlanetScale. It checks for ability to replicate binlogs, schema compatibility and other factors. + TestDataImportSource(ctx context.Context, request *TestDataImportSourceRequest) (*TestDataImportSourceResponse, error) + // StartDataImport spins up a downstream PlanetScale database in replica mode, with the + // external database as a Primary and starts copying data from external to PlanetScale. + StartDataImport(ctx context.Context, request *StartDataImportRequest) (*DataImport, error) + // CancelDataImport halts all replication and data copy from external to PlanetScale + // and deletes the PlanetScale database. + CancelDataImport(ctx context.Context, request *CancelDataImportRequest) error + // GetDataImportStatus gets the current status of a DataImport for a given database + // Fails if the database is not importing any data. + GetDataImportStatus(ctx context.Context, request *GetImportStatusRequest) (*DataImport, error) + // MakePlanetScalePrimary makes the downstream PlanetScale database a Primary and the external database a Replica. + MakePlanetScalePrimary(ctx context.Context, request *MakePlanetScalePrimaryRequest) (*DataImport, error) + // MakePlanetScaleReplica makes the downstream PlanetScale database a Replica and the external database a Primary. + MakePlanetScaleReplica(ctx context.Context, request *MakePlanetScaleReplicaRequest) (*DataImport, error) + // DetachExternalDatabase detaches the external database from PlanetScale after a data import has finished + // and PlanetScale is running as Primary. + DetachExternalDatabase(ctx context.Context, request *DetachExternalDatabaseRequest) (*DataImport, error) +} + +type dataImportsService struct { + client *Client +} + +// TestDataImportSource will check an external database for compatibility with PlanetScale +func (d *dataImportsService) TestDataImportSource(ctx context.Context, request *TestDataImportSourceRequest) (*TestDataImportSourceResponse, error) { + request.Connection.SSLMode = request.Connection.SSLVerificationMode.String() + pathStr := path.Join("/v1/organizations", request.Organization, "data-imports/test-connection") + req, err := d.client.newRequest(http.MethodPost, pathStr, request) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &TestDataImportSourceResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + if resp.ShouldUpgradePlan { + return resp, UserShouldUpgradePlanError{} + } + + resp.SuggestedBillingPlan = planToBillingPlanMap[resp.SuggestedPlan] + return resp, nil +} + +func (d *dataImportsService) StartDataImport(ctx context.Context, request *StartDataImportRequest) (*DataImport, error) { + request.Connection.SSLMode = request.Connection.SSLVerificationMode.String() + pathStr := path.Join("/v1/organizations", request.Organization, "data-imports/new") + req, err := d.client.newRequest(http.MethodPost, pathStr, request) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &DataImport{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (d *dataImportsService) GetDataImportStatus(ctx context.Context, getReq *GetImportStatusRequest) (*DataImport, error) { + pathStr := dataImportAPIPath(getReq.Organization, getReq.Database) + req, err := d.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get database: %w", err) + } + + di := &DataImport{} + err = d.client.do(ctx, req, &di) + if err != nil { + return nil, err + } + + di.ParseState() + return di, nil +} + +func (d *dataImportsService) CancelDataImport(ctx context.Context, cancelReq *CancelDataImportRequest) error { + pathStr := path.Join(dataImportAPIPath(cancelReq.Organization, cancelReq.Database), "cancel") + req, err := d.client.newRequest(http.MethodPost, pathStr, nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + if err := d.client.do(ctx, req, nil); err != nil { + return err + } + + return nil +} + +func (d *dataImportsService) MakePlanetScalePrimary(ctx context.Context, request *MakePlanetScalePrimaryRequest) (*DataImport, error) { + pathStr := path.Join(dataImportAPIPath(request.Organization, request.Database), "begin-switch-traffic") + req, err := d.client.newRequest(http.MethodPost, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &DataImport{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + resp.ParseState() + return resp, nil +} + +func (d *dataImportsService) MakePlanetScaleReplica(ctx context.Context, request *MakePlanetScaleReplicaRequest) (*DataImport, error) { + pathStr := path.Join(dataImportAPIPath(request.Organization, request.Database), "begin-reverse-traffic") + req, err := d.client.newRequest(http.MethodPost, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &DataImport{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + resp.ParseState() + return resp, nil +} + +func (d *dataImportsService) DetachExternalDatabase(ctx context.Context, request *DetachExternalDatabaseRequest) (*DataImport, error) { + pathStr := path.Join(dataImportAPIPath(request.Organization, request.Database), "detach-external-database") + req, err := d.client.newRequest(http.MethodPost, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &DataImport{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + resp.ParseState() + return resp, nil +} + +func dataImportAPIPath(organization, database string) string { + return path.Join("/v1/organizations", organization, "databases", database, "data-imports") +} diff --git a/internal/planetscale/imports_test.go b/internal/planetscale/imports_test.go new file mode 100644 index 000000000..93623eff2 --- /dev/null +++ b/internal/planetscale/imports_test.go @@ -0,0 +1,409 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +var knownStates = map[string]DataImportState{ + "prepare_data_copy_pending": DataImportPreparingDataCopy, + "prepare_data_copy_error": DataImportPreparingDataCopyFailed, + "data_copy_pending": DataImportCopyingData, + "data_copy_error": DataImportCopyingDataFailed, + "switch_traffic_workflow_pending": DataImportSwitchTrafficPending, + "switch_traffic_workflow_running": DataImportSwitchTrafficRunning, + "switch_traffic_workflow_error": DataImportSwitchTrafficError, + "reverse_traffic_workflow_running": DataImportReverseTrafficRunning, + "cleanup_workflow_pending": DataImportSwitchTrafficCompleted, + "cleanup_workflow_running": DataImportDetachExternalDatabaseRunning, + "cleanup_workflow_error": DataImportDetachExternalDatabaseError, + "ready": DataImportReady, +} + +func TestImports_ParseState(t *testing.T) { + c := qt.New(t) + for state, importState := range knownStates { + t.Run(fmt.Sprintf("Can parse state : %s", state), func(t *testing.T) { + di := DataImport{ + State: state, + } + + di.ParseState() + + c.Assert(di.ImportState, qt.Equals, importState) + }) + } +} + +func TestImports_CanRunLintExternalDatabase_Success(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") + w.WriteHeader(200) + out := `{ "can_connect": true, "error": "", "lint_errors": [], "table_statuses": []}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + td := TestDataImportSourceRequest{ + Organization: org, + Database: db, + Connection: DataImportSource{}, + } + + results, err := client.DataImports.TestDataImportSource(ctx, &td) + c.Assert(err, qt.IsNil) + + c.Assert(true, qt.Equals, results.CanConnect) +} + +func TestImports_CanRunLintExternalDatabase_ConnectFailure(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") + w.WriteHeader(200) + out := `{ "can_connect": false, "error": "external database is down", "lint_errors": [], "table_statuses": []}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + td := TestDataImportSourceRequest{ + Organization: org, + Database: db, + Connection: DataImportSource{}, + } + + results, err := client.DataImports.TestDataImportSource(ctx, &td) + c.Assert(err, qt.IsNil) + + c.Assert(false, qt.Equals, results.CanConnect) + c.Assert("external database is down", qt.Equals, results.ConnectError) +} + +func TestImports_CanRunLintExternalDatabase_LintFailure(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") + + w.WriteHeader(200) + out := `{ +"can_connect": true, +"error": "", +"lint_errors": [{ + "lint_error": "NO_PRIMARY_KEY", + "table_name": "employees", + "error_description": "Table 'employees' has no primary key" +}], +"table_statuses": [] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + td := TestDataImportSourceRequest{ + Organization: org, + Database: db, + Connection: DataImportSource{}, + } + + results, err := client.DataImports.TestDataImportSource(ctx, &td) + c.Assert(err, qt.IsNil) + + c.Assert(true, qt.Equals, results.CanConnect) + c.Assert("", qt.Equals, results.ConnectError) + c.Assert(1, qt.Equals, len(results.Errors)) + c.Assert([]*DataSourceIncompatibilityError{ + { + LintError: "NO_PRIMARY_KEY", + Table: "employees", + ErrorDescription: "Table 'employees' has no primary key", + }, + }, qt.DeepEquals, results.Errors) +} + +func TestImports_CanRunLintExternalDatabase_NeedsUpgrade(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/test-connection") + w.WriteHeader(200) + out := `{ +"can_connect": true, +"error": "", +"should_upgrade": true, +"table_statuses": [] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + td := TestDataImportSourceRequest{ + Organization: org, + Database: db, + Connection: DataImportSource{}, + } + + _, err = client.DataImports.TestDataImportSource(ctx, &td) + c.Assert(err, qt.IsNotNil) + c.Assert(err, qt.ErrorIs, UserShouldUpgradePlanError{}) +} + +func TestImports_CanStartDataImport_Success(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/data-imports/new") + var startRequest StartDataImportRequest + err := json.NewDecoder(r.Body).Decode(&startRequest) + c.Assert(err, qt.IsNil) + c.Assert("us-west-2", qt.Equals, startRequest.Region) + + w.WriteHeader(200) + out := `{ +"id": "PUBLIC_ID", +"state": "prepare_data_copy_pending", +"import_check_errors": "", +"data_source": { + "hostname": "aws.rds.something.com", + "port": 25060, + "database": "employees" +} +}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + + startReq := &StartDataImportRequest{ + Organization: org, + Database: db, + Region: "us-west-2", + } + di, err := client.DataImports.StartDataImport(ctx, startReq) + c.Assert(err, qt.IsNil) + c.Assert(di.ID, qt.Equals, "PUBLIC_ID") + c.Assert(di.Errors, qt.Equals, "") + c.Assert(di.ImportState, qt.Equals, DataImportPreparingDataCopy) +} + +func TestImports_CanGetDataImportStatus_Success(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports") + w.WriteHeader(200) + out := `{ + "id": "IMPORT_PUBLIC_ID", + "state": "switch_traffic_workflow_pending" +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + di, err := client.DataImports.GetDataImportStatus(ctx, &GetImportStatusRequest{ + Organization: org, + Database: db, + }) + c.Assert(err, qt.IsNil) + c.Assert(di.ID, qt.Equals, "IMPORT_PUBLIC_ID") + c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficPending) +} + +func TestImports_CanGetDataImportStatus_NoImport(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports") + w.WriteHeader(400) + out := `{ +"code": "bad_request", +"message": "Data import has not been setup for this database." +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + di, err := client.DataImports.GetDataImportStatus(ctx, &GetImportStatusRequest{ + Organization: org, + Database: db, + }) + c.Assert(err, qt.IsNotNil) + c.Assert(err, qt.ErrorMatches, "Data import has not been setup for this database.") + c.Assert(di, qt.IsNil) +} + +func TestImports_CanCancelDataImport(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/cancel") + w.WriteHeader(200) + out := `{ +"id": "PUBLIC_ID", +"state": "prepare_data_copy_pending", +"import_check_errors": "", +"data_source": { + "hostname": "aws.rds.something.com", + "port": "25060", + "database": "employees" +} +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + err = client.DataImports.CancelDataImport(ctx, &CancelDataImportRequest{ + Organization: org, + Database: db, + }) + c.Assert(err, qt.IsNil) +} + +func TestImports_CanMakePlanetScalePrimary(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/begin-switch-traffic") + w.WriteHeader(200) + out := `{ +"id": "PUBLIC_ID", +"state": "cleanup_workflow_pending", +"import_check_errors": "", +"data_source": { + "hostname": "aws.rds.something.com", + "port": 25060, + "database": "employees" +} +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + + makePrimRequest := &MakePlanetScalePrimaryRequest{ + Organization: org, + Database: db, + } + di, err := client.DataImports.MakePlanetScalePrimary(ctx, makePrimRequest) + c.Assert(err, qt.IsNil) + c.Assert(di.ID, qt.Equals, "PUBLIC_ID") + c.Assert(di.Errors, qt.Equals, "") + c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficCompleted) +} + +func TestImports_CanMakePlanetScaleReplica(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/begin-reverse-traffic") + w.WriteHeader(200) + out := `{ +"id": "PUBLIC_ID", +"state": "switch_traffic_workflow_pending", +"import_check_errors": "", +"data_source": { + "hostname": "aws.rds.something.com", + "port": 25060, + "database": "employees" +} +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + + makeReplicaRequest := &MakePlanetScaleReplicaRequest{ + Organization: org, + Database: db, + } + di, err := client.DataImports.MakePlanetScaleReplica(ctx, makeReplicaRequest) + c.Assert(err, qt.IsNil) + c.Assert(di.ID, qt.Equals, "PUBLIC_ID") + c.Assert(di.Errors, qt.Equals, "") + c.Assert(di.ImportState, qt.Equals, DataImportSwitchTrafficPending) +} + +func TestImports_CanDetachExternalDatabase(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/data-imports/detach-external-database") + w.WriteHeader(200) + out := `{ +"id": "PUBLIC_ID", +"state": "ready", +"import_check_errors": "", +"data_source": { + "hostname": "aws.rds.something.com", + "port": 25060, + "database": "employees" +} +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + ctx := context.Background() + org := "my-org" + db := "my-db" + + detachReq := &DetachExternalDatabaseRequest{ + Organization: org, + Database: db, + } + di, err := client.DataImports.DetachExternalDatabase(ctx, detachReq) + c.Assert(err, qt.IsNil) + c.Assert(di.ID, qt.Equals, "PUBLIC_ID") + c.Assert(di.Errors, qt.Equals, "") + c.Assert(di.ImportState, qt.Equals, DataImportReady) +} diff --git a/internal/planetscale/integration_test.go b/internal/planetscale/integration_test.go new file mode 100644 index 000000000..750970c6c --- /dev/null +++ b/internal/planetscale/integration_test.go @@ -0,0 +1,112 @@ +//go:build integration +// +build integration + +package planetscale + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +// This integration test creates, lists and then deletes a PlanetScale +// Database. Use with caution!. Usage: +// +// PLANETSCALE_TOKEN=$(cat ~/.config/planetscale/access-token) PLANETSCALE_ORG="damp-dew-9934" go test -tags integration +// + +func TestIntegration_Databases_List(t *testing.T) { + token := os.Getenv("PLANETSCALE_TOKEN") + if token == "" { + t.Fatalf("PLANETSCALE_TOKEN is not set") + } + + org := os.Getenv("PLANETSCALE_ORG") + if org == "" { + t.Fatalf("PLANETSCALE_ORG is not set") + } + + ctx := context.Background() + + client, err := NewClient( + WithAccessToken(token), + ) + if err != nil { + t.Fatal(err) + } + + dbName := "planetscale-go-test-db" + + _, err = client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: org, + Name: dbName, + }) + if err != nil { + t.Fatalf("create database failed: %s", err) + } + + // poor mans polling, remove once we have an API to poll the status of the DB + time.Sleep(time.Second * 2) + + dbs, err := client.Databases.List(ctx, &ListDatabasesRequest{ + Organization: org, + }) + if err != nil { + t.Fatalf("list database failed: %s", err) + } + + fmt.Printf("Found %d databases\n", len(dbs)) + for _, db := range dbs { + fmt.Printf("db struct = %+v\n", db) + fmt.Println("----------") + fmt.Printf("Name: %q\n", db.Name) + fmt.Printf("Notes: %q\n", db.Notes) + } + + err = client.Databases.Delete(ctx, &DeleteDatabaseRequest{ + Organization: org, + Database: dbName, + }) + if err != nil { + t.Fatalf("delete database failed: %s", err) + } +} + +func TestIntegration_AuditLogs_List(t *testing.T) { + token := os.Getenv("PLANETSCALE_TOKEN") + if token == "" { + t.Fatalf("PLANETSCALE_TOKEN is not set") + } + + org := os.Getenv("PLANETSCALE_ORG") + if org == "" { + t.Fatalf("PLANETSCALE_ORG is not set") + } + + ctx := context.Background() + + client, err := NewClient( + WithAccessToken(token), + ) + if err != nil { + t.Fatal(err) + } + + auditLogs, err := client.AuditLogs.List(ctx, &ListAuditLogsRequest{ + Organization: org, + Events: []AuditLogEvent{ + AuditLogEventBranchDeleted, + AuditLogEventOrganizationJoined, + }, + }) + if err != nil { + t.Fatalf("get audit logs failed: %s", err) + } + + for _, l := range auditLogs { + fmt.Printf("l. = %+v\n", l.AuditAction) + } + fmt.Printf("len(auditLogs) = %+v\n", len(auditLogs)) +} diff --git a/internal/planetscale/keyspaces.go b/internal/planetscale/keyspaces.go new file mode 100644 index 000000000..4447102db --- /dev/null +++ b/internal/planetscale/keyspaces.go @@ -0,0 +1,351 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type Keyspace struct { + ID string `json:"id"` + Name string `json:"name"` + Shards int `json:"shards"` + Sharded bool `json:"sharded"` + Replicas uint64 `json:"replicas"` + ExtraReplicas uint64 `json:"extra_replicas"` + ResizePending bool `json:"resize_pending"` + Resizing bool `json:"resizing"` + Ready bool `json:"ready"` + ClusterSize string `json:"cluster_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + VReplicationFlags *VReplicationFlags `json:"vreplication_flags"` + ReplicationDurabilityConstraints *ReplicationDurabilityConstraints `json:"replication_durability_constraints"` +} + +// VSchema represnts the VSchema for a branch keyspace +type VSchema struct { + Raw string `json:"raw"` + HTML string `json:"html"` +} + +type ListKeyspacesRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +type CreateKeyspaceRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + ClusterSize string `json:"cluster_size"` + ExtraReplicas int `json:"extra_replicas"` + Shards int `json:"shards"` +} + +type GetKeyspaceRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type GetKeyspaceVSchemaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type UpdateKeyspaceVSchemaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + VSchema string `json:"vschema"` +} + +type keyspacesResponse struct { + Keyspaces []*Keyspace `json:"data"` +} + +type ResizeKeyspaceRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + ExtraReplicas *uint `json:"extra_replicas,omitempty"` + ClusterSize *string `json:"cluster_size,omitempty"` +} + +type KeyspaceResizeRequest struct { + ID string `json:"id"` + State string `json:"state"` + Actor *Actor `json:"actor"` + + ClusterSize string `json:"cluster_name"` + PreviousClusterSize string `json:"previous_cluster_name"` + + Replicas uint `json:"replicas"` + ExtraReplicas uint `json:"extra_replicas"` + PreviousReplicas uint `json:"previous_replicas"` + + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +type KeyspaceRollout struct { + Name string `json:"name"` + State string `json:"state"` + + Shards []ShardRollout `json:"shards"` +} + +type ShardRollout struct { + Name string `json:"name"` + State string `json:"state"` + + LastRolloutStartedAt time.Time `json:"last_rollout_started_at"` + LastRolloutFinishedAt time.Time `json:"last_rollout_finished_at"` +} + +type CancelKeyspaceResizeRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type KeyspaceResizeStatusRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type KeyspaceRolloutStatusRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` +} + +type UpdateKeyspaceSettingsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + ReplicationDurabilityConstraints *ReplicationDurabilityConstraints `json:"replication_durability_constraints,omitempty"` + VReplicationFlags *VReplicationFlags `json:"vreplication_flags,omitempty"` +} + +type ReplicationDurabilityConstraints struct { + Strategy string `json:"strategy"` +} + +type VReplicationFlags struct { + OptimizeInserts bool `json:"optimize_inserts"` + AllowNoBlobBinlogRowImage bool `json:"allow_no_blob_binlog_row_image"` + VPlayerBatching bool `json:"vplayer_batching"` +} + +// KeyspacesService is an interface for interacting with the keyspace endpoints of the PlanetScale API +type KeyspacesService interface { + Create(context.Context, *CreateKeyspaceRequest) (*Keyspace, error) + List(context.Context, *ListKeyspacesRequest) ([]*Keyspace, error) + Get(context.Context, *GetKeyspaceRequest) (*Keyspace, error) + VSchema(context.Context, *GetKeyspaceVSchemaRequest) (*VSchema, error) + UpdateVSchema(context.Context, *UpdateKeyspaceVSchemaRequest) (*VSchema, error) + Resize(context.Context, *ResizeKeyspaceRequest) (*KeyspaceResizeRequest, error) + CancelResize(context.Context, *CancelKeyspaceResizeRequest) error + ResizeStatus(context.Context, *KeyspaceResizeStatusRequest) (*KeyspaceResizeRequest, error) + RolloutStatus(context.Context, *KeyspaceRolloutStatusRequest) (*KeyspaceRollout, error) + UpdateSettings(context.Context, *UpdateKeyspaceSettingsRequest) (*Keyspace, error) +} + +type keyspacesService struct { + client *Client +} + +var _ KeyspacesService = &keyspacesService{} + +func NewKeyspacesService(client *Client) *keyspacesService { + return &keyspacesService{client} +} + +// List returns a list of keyspaces for a branch +func (s *keyspacesService) List(ctx context.Context, listReq *ListKeyspacesRequest) ([]*Keyspace, error) { + req, err := s.client.newRequest(http.MethodGet, keyspacesAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspaces := &keyspacesResponse{} + if err := s.client.do(ctx, req, keyspaces); err != nil { + return nil, err + } + + return keyspaces.Keyspaces, nil +} + +// Get returns a keyspace for a branch +func (s *keyspacesService) Get(ctx context.Context, getReq *GetKeyspaceRequest) (*Keyspace, error) { + req, err := s.client.newRequest(http.MethodGet, keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspace := &Keyspace{} + if err := s.client.do(ctx, req, keyspace); err != nil { + return nil, err + } + + return keyspace, nil +} + +// Create creates a keyspace for a branch +func (s *keyspacesService) Create(ctx context.Context, createReq *CreateKeyspaceRequest) (*Keyspace, error) { + req, err := s.client.newRequest(http.MethodPost, keyspacesAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspace := &Keyspace{} + if err := s.client.do(ctx, req, keyspace); err != nil { + return nil, err + } + + return keyspace, nil +} + +// VSchema returns the VSchema for a keyspace in a branch +func (s *keyspacesService) VSchema(ctx context.Context, getReq *GetKeyspaceVSchemaRequest) (*VSchema, error) { + pathStr := path.Join(keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace), "vschema") + req, err := s.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + vschema := &VSchema{} + if err := s.client.do(ctx, req, vschema); err != nil { + return nil, err + } + + return vschema, nil +} + +func (s *keyspacesService) UpdateVSchema(ctx context.Context, updateReq *UpdateKeyspaceVSchemaRequest) (*VSchema, error) { + pathStr := path.Join(keyspaceAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Keyspace), "vschema") + req, err := s.client.newRequest(http.MethodPatch, pathStr, updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + vschema := &VSchema{} + if err := s.client.do(ctx, req, vschema); err != nil { + return nil, err + } + + return vschema, nil +} + +// Resize starts or queues a resize of a branch's keyspace. +func (s *keyspacesService) Resize(ctx context.Context, resizeReq *ResizeKeyspaceRequest) (*KeyspaceResizeRequest, error) { + req, err := s.client.newRequest(http.MethodPut, keyspaceResizesAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch, resizeReq.Keyspace), resizeReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspaceResize := &KeyspaceResizeRequest{} + if err := s.client.do(ctx, req, keyspaceResize); err != nil { + return nil, err + } + + return keyspaceResize, nil +} + +// CancelResize cancels a queued resize of a branch's keyspace. +func (s *keyspacesService) CancelResize(ctx context.Context, cancelReq *CancelKeyspaceResizeRequest) error { + req, err := s.client.newRequest(http.MethodDelete, keyspaceResizesAPIPath(cancelReq.Organization, cancelReq.Database, cancelReq.Branch, cancelReq.Keyspace), nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + return s.client.do(ctx, req, nil) +} + +func keyspacesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "keyspaces") +} + +func keyspaceAPIPath(org, db, branch, keyspace string) string { + return path.Join(keyspacesAPIPath(org, db, branch), keyspace) +} + +func keyspaceResizesAPIPath(org, db, branch, keyspace string) string { + return path.Join(keyspaceAPIPath(org, db, branch, keyspace), "resizes") +} + +type keyspaceResizesResponse struct { + Resizes []*KeyspaceResizeRequest `json:"data"` +} + +func (s *keyspacesService) ResizeStatus(ctx context.Context, resizeReq *KeyspaceResizeStatusRequest) (*KeyspaceResizeRequest, error) { + req, err := s.client.newRequest(http.MethodGet, keyspaceResizesAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch, resizeReq.Keyspace), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resizesResponse := &keyspaceResizesResponse{} + if err := s.client.do(ctx, req, resizesResponse); err != nil { + return nil, err + } + + // If there are no resizes, treat the same as a not found error + if len(resizesResponse.Resizes) == 0 { + return nil, &Error{ + msg: "Not Found", + Code: ErrNotFound, + } + } + + return resizesResponse.Resizes[0], nil +} + +func keyspaceRolloutStatusAPIPath(org, db, branch, keyspace string) string { + return path.Join(keyspaceAPIPath(org, db, branch, keyspace), "rollout-status") +} + +func (s *keyspacesService) RolloutStatus(ctx context.Context, rolloutReq *KeyspaceRolloutStatusRequest) (*KeyspaceRollout, error) { + req, err := s.client.newRequest(http.MethodGet, keyspaceRolloutStatusAPIPath(rolloutReq.Organization, rolloutReq.Database, rolloutReq.Branch, rolloutReq.Keyspace), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + rolloutStatusResponse := &KeyspaceRollout{} + if err := s.client.do(ctx, req, rolloutStatusResponse); err != nil { + return nil, err + } + + return rolloutStatusResponse, nil +} + +func (s *keyspacesService) UpdateSettings(ctx context.Context, updateReq *UpdateKeyspaceSettingsRequest) (*Keyspace, error) { + req, err := s.client.newRequest(http.MethodPatch, keyspaceAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Keyspace), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspace := &Keyspace{} + if err := s.client.do(ctx, req, keyspace); err != nil { + return nil, err + } + + return keyspace, nil +} diff --git a/internal/planetscale/keyspaces_test.go b/internal/planetscale/keyspaces_test.go new file mode 100644 index 000000000..55addf2f1 --- /dev/null +++ b/internal/planetscale/keyspaces_test.go @@ -0,0 +1,368 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestKeyspaces_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"thisisanid","type":"Keyspace","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + keyspaces, err := client.Keyspaces.List(ctx, &ListKeyspacesRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(len(keyspaces), qt.Equals, 1) + c.Assert(keyspaces[0].ID, qt.Equals, wantID) + c.Assert(keyspaces[0].Sharded, qt.Equals, true) + c.Assert(keyspaces[0].Shards, qt.Equals, 2) +} + +func TestKeyspaces_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + keyspace, err := client.Keyspaces.Get(ctx, &GetKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(keyspace.ID, qt.Equals, wantID) + c.Assert(keyspace.Sharded, qt.Equals, true) + c.Assert(keyspace.Shards, qt.Equals, 2) +} + +func TestKeyspaces_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPost) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + keyspace, err := client.Keyspaces.Create(ctx, &CreateKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Name: "qux", + ClusterSize: "small", + ExtraReplicas: 3, + Shards: 2, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(keyspace.ID, qt.Equals, wantID) + c.Assert(keyspace.Sharded, qt.Equals, true) + c.Assert(keyspace.Shards, qt.Equals, 2) +} + +func TestKeyspaces_VSchema(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"raw":"{\"sharded\":true,\"tables\":{}}","html":"
\"sharded\":true,\"tables\":{}
"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + vSchema, err := client.Keyspaces.VSchema(ctx, &GetKeyspaceVSchemaRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + wantRaw := "{\"sharded\":true,\"tables\":{}}" + wantHTML := "
\"sharded\":true,\"tables\":{}
" + + c.Assert(err, qt.IsNil) + c.Assert(vSchema.Raw, qt.Equals, wantRaw) + c.Assert(vSchema.HTML, qt.Equals, wantHTML) +} + +func TestKeyspaces_UpdateVSchema(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"raw":"{\"sharded\":true,\"tables\":{}}","html":"
\"sharded\":true,\"tables\":{}
"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + vSchema, err := client.Keyspaces.UpdateVSchema(ctx, &UpdateKeyspaceVSchemaRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + VSchema: "{\"sharded\":true,\"tables\":{}}", + }) + + wantRaw := "{\"sharded\":true,\"tables\":{}}" + wantHTML := "
\"sharded\":true,\"tables\":{}
" + + c.Assert(err, qt.IsNil) + c.Assert(vSchema.Raw, qt.Equals, wantRaw) + c.Assert(vSchema.HTML, qt.Equals, wantHTML) +} + +func TestKeyspaces_Resize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"KeyspaceResizeRequest","state":"pending","started_at":"2024-06-25T18:03:09.459Z","completed_at":"2024-06-25T18:04:06.228Z","created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:04:06.238Z","actor":{"id":"actorid","type":"User","display_name":"Test User"},"cluster_name":"PS_10","extra_replicas":1,"previous_cluster_name":"PS_10","replicas":3,"previous_replicas":5}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPut) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + size := "PS_10" + replicas := uint(3) + + krr, err := client.Keyspaces.Resize(ctx, &ResizeKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + ClusterSize: &size, + ExtraReplicas: &replicas, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(krr.ID, qt.Equals, wantID) + c.Assert(krr.ExtraReplicas, qt.Equals, uint(1)) + c.Assert(krr.Replicas, qt.Equals, uint(3)) + c.Assert(krr.PreviousReplicas, qt.Equals, uint(5)) + c.Assert(krr.ClusterSize, qt.Equals, "PS_10") + c.Assert(krr.PreviousClusterSize, qt.Equals, "PS_10") +} + +func TestKeyspaces_CancelResize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + c.Assert(r.Method, qt.Equals, http.MethodDelete) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.Keyspaces.CancelResize(ctx, &CancelKeyspaceResizeRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + c.Assert(err, qt.IsNil) +} + +func TestKeyspaces_ResizeStatus(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"thisisanid","type":"KeyspaceResizeRequest","state":"completed","started_at":"2024-06-25T18:03:09.459Z","completed_at":"2024-06-25T18:04:06.228Z","created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:04:06.238Z","actor":{"id":"thisisanid","type":"User","display_name":"Test User"},"cluster_name":"PS_10","extra_replicas":0,"previous_cluster_name":"PS_10","replicas":2,"previous_replicas":5}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + krr, err := client.Keyspaces.ResizeStatus(ctx, &KeyspaceResizeStatusRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(krr.ID, qt.Equals, wantID) + c.Assert(krr.ExtraReplicas, qt.Equals, uint(0)) + c.Assert(krr.Replicas, qt.Equals, uint(2)) + c.Assert(krr.PreviousReplicas, qt.Equals, uint(5)) + c.Assert(krr.ClusterSize, qt.Equals, "PS_10") + c.Assert(krr.PreviousClusterSize, qt.Equals, "PS_10") +} + +func TestKeyspaces_ResizeStatusEmpty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + krr, err := client.Keyspaces.ResizeStatus(ctx, &KeyspaceResizeStatusRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + wantError := &Error{ + msg: "Not Found", + Code: ErrNotFound, + } + + c.Assert(krr, qt.IsNil) + c.Assert(err.Error(), qt.Equals, wantError.Error()) +} + +func TestKeyspaces_RolloutStatus(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"BranchInfrastructureKeyspace","state":"complete","name":"qux","shards":[{"type":"BranchInfrastructureKeyspaceShard","state":"complete","last_rollout_started_at":"2025-01-17T18:27:25.027Z","last_rollout_finished_at":"2025-01-17T18:28:25.027Z","name":"-80"},{"type":"BranchInfrastructureKeyspaceShard","state":"complete","last_rollout_started_at":"2025-01-17T18:28:25.033Z","last_rollout_finished_at":"2025-01-17T18:29:25.033Z","name":"80-"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + krr, err := client.Keyspaces.RolloutStatus(ctx, &KeyspaceRolloutStatusRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + }) + + c.Assert(err, qt.IsNil) + c.Assert(krr.Name, qt.Equals, "qux") + c.Assert(krr.State, qt.Equals, "complete") + c.Assert(len(krr.Shards), qt.Equals, int(2)) + c.Assert(krr.Shards[0].Name, qt.Equals, "-80") + c.Assert(krr.Shards[0].State, qt.Equals, "complete") + c.Assert(krr.Shards[1].Name, qt.Equals, "80-") + c.Assert(krr.Shards[1].State, qt.Equals, "complete") +} + +func TestKeyspaces_UpdateSettings(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"Keyspace","id":"thisisanid","name":"planetscale","shards":2,"sharded":true,"created_at":"2022-01-14T15:39:28.394Z","updated_at":"2021-12-20T21:11:07.697Z","vreplication_flags":{"optimize_inserts":true,"allow_no_blob_binlog_row_image":true,"vplayer_batching":true},"replication_durability_constraints":{"strategy":"maximum"}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + keyspace, err := client.Keyspaces.UpdateSettings(ctx, &UpdateKeyspaceSettingsRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "qux", + VReplicationFlags: &VReplicationFlags{ + OptimizeInserts: true, + AllowNoBlobBinlogRowImage: true, + VPlayerBatching: true, + }, + ReplicationDurabilityConstraints: &ReplicationDurabilityConstraints{ + Strategy: "maximum", + }, + }) + + c.Assert(err, qt.IsNil) + c.Assert(keyspace.ID, qt.Equals, "thisisanid") + c.Assert(keyspace.Sharded, qt.Equals, true) + c.Assert(keyspace.Shards, qt.Equals, 2) + c.Assert(keyspace.VReplicationFlags.OptimizeInserts, qt.Equals, true) + c.Assert(keyspace.VReplicationFlags.AllowNoBlobBinlogRowImage, qt.Equals, true) + c.Assert(keyspace.VReplicationFlags.VPlayerBatching, qt.Equals, true) + c.Assert(keyspace.ReplicationDurabilityConstraints.Strategy, qt.Equals, "maximum") +} diff --git a/internal/planetscale/organizations.go b/internal/planetscale/organizations.go new file mode 100644 index 000000000..30ca044c6 --- /dev/null +++ b/internal/planetscale/organizations.go @@ -0,0 +1,155 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +const organizationsAPIPath = "v1/organizations" + +// GetOrganizationRequest encapsulates the request for getting a single +// organization. +type GetOrganizationRequest struct { + Organization string +} + +// OrganizationsService is an interface for communicating with the PlanetScale +// Organizations API endpoints. +type OrganizationsService interface { + Get(context.Context, *GetOrganizationRequest) (*Organization, error) + List(context.Context) ([]*Organization, error) + ListRegions(context.Context, *ListOrganizationRegionsRequest) ([]*Region, error) + ListClusterSKUs(context.Context, *ListOrganizationClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) +} + +// ListRegionsRequest encapsulates the request for getting a list of regions for +// an organization. +type ListOrganizationRegionsRequest struct { + Organization string +} + +// ListOrganizationClusterSKUsRequest encapsulates the request for getting a list of Cluster SKUs for an organization. +type ListOrganizationClusterSKUsRequest struct { + Organization string +} + +// ClusterSKU represents a SKU for a PlanetScale cluster +type ClusterSKU struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + CPU string `json:"cpu"` + Memory int64 `json:"ram"` + + SortOrder int64 `json:"sort_order"` + + Storage *int64 `json:"storage"` + + Rate *int64 `json:"rate"` + ReplicaRate *int64 `json:"replica_rate"` + ProviderInstanceType *string `json:"provider_instance_type"` + Provider *string `json:"provider"` + Enabled bool `json:"enabled"` + DefaultVTGate string `json:"default_vtgate"` + DefaultVTGateRate *int64 `json:"default_vtgate_rate"` + + Metal bool `json:"metal"` +} + +// Organization represents a PlanetScale organization. +type Organization struct { + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RemainingFreeDatabases int `json:"free_databases_remaining"` +} + +type organizationsResponse struct { + Organizations []*Organization `json:"data"` +} + +type organizationsService struct { + client *Client +} + +var _ OrganizationsService = &organizationsService{} + +func NewOrganizationsService(client *Client) *organizationsService { + return &organizationsService{ + client: client, + } +} + +// Get fetches a single organization by name. +func (o *organizationsService) Get(ctx context.Context, getReq *GetOrganizationRequest) (*Organization, error) { + req, err := o.client.newRequest(http.MethodGet, path.Join(organizationsAPIPath, getReq.Organization), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get organization: %w", err) + } + + org := &Organization{} + if err := o.client.do(ctx, req, &org); err != nil { + return nil, err + } + + return org, nil +} + +// List returns all the organizations for a user. +func (o *organizationsService) List(ctx context.Context) ([]*Organization, error) { + req, err := o.client.newRequest(http.MethodGet, organizationsAPIPath, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for list organization: %w", err) + } + + orgResponse := &organizationsResponse{} + if err := o.client.do(ctx, req, &orgResponse); err != nil { + return nil, err + } + + return orgResponse.Organizations, nil +} + +type listRegionsResponse struct { + Regions []*Region `json:"data"` +} + +func (o *organizationsService) ListRegions(ctx context.Context, listReq *ListOrganizationRegionsRequest) ([]*Region, error) { + req, err := o.client.newRequest(http.MethodGet, path.Join(organizationsAPIPath, listReq.Organization, "regions"), nil) + if err != nil { + return nil, err + } + + listResponse := &listRegionsResponse{} + if err := o.client.do(ctx, req, &listResponse); err != nil { + return nil, err + } + + return listResponse.Regions, nil +} + +func (o *organizationsService) ListClusterSKUs(ctx context.Context, listReq *ListOrganizationClusterSKUsRequest, opts ...ListOption) ([]*ClusterSKU, error) { + path := path.Join(organizationsAPIPath, listReq.Organization, "cluster-size-skus") + + defaultOpts := defaultListOptions() + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := o.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + clusterSKUs := []*ClusterSKU{} + if err := o.client.do(ctx, req, &clusterSKUs); err != nil { + return nil, err + } + + return clusterSKUs, nil +} diff --git a/internal/planetscale/organizations_test.go b/internal/planetscale/organizations_test.go new file mode 100644 index 000000000..3656964a2 --- /dev/null +++ b/internal/planetscale/organizations_test.go @@ -0,0 +1,300 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestOrganizations_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "data": [ + { + "id": "my-cool-org", + "type": "organization", + "name": "my-cool-org", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Organizations.List(ctx) + + c.Assert(err, qt.IsNil) + want := []*Organization{ + { + Name: "my-cool-org", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} + +func TestOrganizations_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "id": "my-cool-org", + "type": "organization", + "name": "my-cool-org", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z" +}` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + org, err := client.Organizations.Get(ctx, &GetOrganizationRequest{ + Organization: "my-cool-org", + }) + + c.Assert(err, qt.IsNil) + want := &Organization{ + Name: "my-cool-org", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(org, qt.DeepEquals, want) +} + +func TestOrganizations_ListRegions(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ +"data": [ + { + "id": "my-cool-org", + "type": "Region", + "slug": "us-east", + "display_name": "US East", + "enabled": true + } + ] +}` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Organizations.ListRegions(ctx, &ListOrganizationRegionsRequest{ + Organization: "my-cool-org", + }) + + c.Assert(err, qt.IsNil) + want := []*Region{ + { + Name: "US East", + Slug: "us-east", + Enabled: true, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} + +func TestOrganizations_ListClusterSKUs(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus") + out := `[ + { + "name": "PS_10", + "type": "ClusterSizeSku", + "display_name": "PS-10", + "cpu": "1/8", + "provider_instance_type": null, + "storage": null, + "ram": 1, + "metal": true, + "enabled": true, + "provider": null, + "rate": null, + "replica_rate": null, + "default_vtgate": "VTG_5", + "default_vtgate_rate": null, + "sort_order": 1 + } + ]` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Organizations.ListClusterSKUs(ctx, &ListOrganizationClusterSKUsRequest{ + Organization: "my-cool-org", + }) + + c.Assert(err, qt.IsNil) + want := []*ClusterSKU{ + { + Name: "PS_10", + DisplayName: "PS-10", + CPU: "1/8", + Memory: 1, + Enabled: true, + DefaultVTGate: "VTG_5", + SortOrder: 1, + Metal: true, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} + +func TestOrganizations_ListClusterSKUsWithRates(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus?rates=true") + out := `[ + { + "name": "PS_10", + "type": "ClusterSizeSku", + "display_name": "PS-10", + "cpu": "1/8", + "provider_instance_type": null, + "storage": 100, + "ram": 1, + "sort_order": 1, + "enabled": true, + "provider": null, + "rate": 39, + "replica_rate": 13, + "default_vtgate": "VTG_5", + "default_vtgate_rate": null + } + ]` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Organizations.ListClusterSKUs(ctx, &ListOrganizationClusterSKUsRequest{ + Organization: "my-cool-org", + }, WithRates()) + + c.Assert(err, qt.IsNil) + want := []*ClusterSKU{ + { + Name: "PS_10", + DisplayName: "PS-10", + CPU: "1/8", + Memory: 1, + Enabled: true, + Storage: Pointer[int64](100), + Rate: Pointer[int64](39), + ReplicaRate: Pointer[int64](13), + DefaultVTGate: "VTG_5", + SortOrder: 1, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} + +func TestOrganizations_ListClusterSKUsWithPostgreSQL(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus?postgresql=true") + out := `[ + { + "name": "PS_10_AWS_ARM", + "type": "ClusterSizeSku", + "display_name": "PS-10-AWS-ARM", + "cpu": "1/8", + "provider_instance_type": null, + "storage": null, + "ram": 1, + "sort_order": 1, + "enabled": true, + "provider": "aws", + "rate": 34, + "replica_rate": null, + "default_vtgate": null, + "default_vtgate_rate": null + } + ]` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Organizations.ListClusterSKUs(ctx, &ListOrganizationClusterSKUsRequest{ + Organization: "my-cool-org", + }, WithPostgreSQL()) + + c.Assert(err, qt.IsNil) + want := []*ClusterSKU{ + { + Name: "PS_10_AWS_ARM", + DisplayName: "PS-10-AWS-ARM", + CPU: "1/8", + Memory: 1, + Enabled: true, + Provider: Pointer("aws"), + Rate: Pointer[int64](34), + SortOrder: 1, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} diff --git a/internal/planetscale/passwords.go b/internal/planetscale/passwords.go new file mode 100644 index 000000000..fb14b3f44 --- /dev/null +++ b/internal/planetscale/passwords.go @@ -0,0 +1,205 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type DatabaseBranchPassword struct { + PublicID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"access_host_url"` + Username string `json:"username"` + Role string `json:"role"` + Actor *Actor `json:"actor"` + Branch DatabaseBranch `json:"database_branch"` + CreatedAt time.Time `json:"created_at"` + DeletedAt time.Time `json:"deleted_at"` + ExpiresAt time.Time `json:"expires_at"` + PlainText string `json:"plain_text"` + TTL int `json:"ttl_seconds"` + Renewable bool `json:"renewable"` + Replica bool `json:"replica"` +} + +// DatabaseBranchPasswordRequest encapsulates the request for creating/getting/deleting a +// database branch password. +type DatabaseBranchPasswordRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Role string `json:"role,omitempty"` + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + Replica bool `json:"replica,omitempty"` +} + +// ListDatabaseBranchPasswordRequest encapsulates the request for listing all passwords +// for a given database branch. +type ListDatabaseBranchPasswordRequest struct { + Organization string + Database string + Branch string +} + +// GetDatabaseBranchPasswordRequest encapsulates the request for listing all passwords +// for a given database branch. +type GetDatabaseBranchPasswordRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + PasswordId string +} + +// DeleteDatabaseBranchPasswordRequest encapsulates the request for deleting a password +// for a given database branch. +type DeleteDatabaseBranchPasswordRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + PasswordId string +} + +// RenewDatabaseBranchPasswordRequest encapsulates the request for renewing a password +// for a given database branch. +type RenewDatabaseBranchPasswordRequest struct { + Organization string + Database string + Branch string + PasswordId string +} + +// DatabaseBranchPasswordsService is an interface for communicating with the PlanetScale +// Database Branch Passwords API endpoint. +type PasswordsService interface { + Create(context.Context, *DatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) + // List returns passwords with optional pagination support via ListOption parameters + List(context.Context, *ListDatabaseBranchPasswordRequest, ...ListOption) ([]*DatabaseBranchPassword, error) + Get(context.Context, *GetDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) + Delete(context.Context, *DeleteDatabaseBranchPasswordRequest) error + Renew(context.Context, *RenewDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) +} + +type passwordsService struct { + client *Client +} + +type passwordsResponse struct { + Passwords []*DatabaseBranchPassword `json:"data"` +} + +var _ PasswordsService = &passwordsService{} + +func NewPasswordsService(client *Client) *passwordsService { + return &passwordsService{ + client: client, + } +} + +// Creates a new password for a branch. +func (d *passwordsService) Create(ctx context.Context, createReq *DatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { + pathStr := passwordsBranchAPIPath(createReq.Organization, createReq.Database, createReq.Branch) + req, err := d.client.newRequest(http.MethodPost, pathStr, createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + password := &DatabaseBranchPassword{} + if err := d.client.do(ctx, req, &password); err != nil { + return nil, err + } + + return password, nil +} + +// Delete an existing password for a branch. +func (d *passwordsService) Delete(ctx context.Context, deleteReq *DeleteDatabaseBranchPasswordRequest) error { + pathStr := passwordBranchAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.PasswordId) + req, err := d.client.newRequest(http.MethodDelete, pathStr, nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + err = d.client.do(ctx, req, nil) + return err +} + +// Get an existing password for a branch. +func (d *passwordsService) Get(ctx context.Context, getReq *GetDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { + pathStr := passwordBranchAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.PasswordId) + req, err := d.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + password := &DatabaseBranchPassword{} + if err := d.client.do(ctx, req, &password); err != nil { + return nil, err + } + + return password, nil +} + +// List all existing passwords. If req.Branch is set, all passwords for that +// branch will be listed. +func (d *passwordsService) List(ctx context.Context, listReq *ListDatabaseBranchPasswordRequest, opts ...ListOption) ([]*DatabaseBranchPassword, error) { + pathStr := passwordsAPIPath(listReq.Organization, listReq.Database) + if listReq.Branch != "" { + pathStr = passwordBranchAPIPath(listReq.Organization, listReq.Database, listReq.Branch, "") + } + + defaultOpts := defaultListOptions(WithPerPage(50)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := d.client.newRequest(http.MethodGet, pathStr, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request to list passwords: %w", err) + } + + passwordsResp := &passwordsResponse{} + if err := d.client.do(ctx, req, &passwordsResp); err != nil { + return nil, err + } + + return passwordsResp.Passwords, nil +} + +func (d *passwordsService) Renew(ctx context.Context, renewReq *RenewDatabaseBranchPasswordRequest) (*DatabaseBranchPassword, error) { + pathStr := passwordRenewAPIPath(renewReq.Organization, renewReq.Database, renewReq.Branch, renewReq.PasswordId) + req, err := d.client.newRequest(http.MethodPost, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + password := &DatabaseBranchPassword{} + if err := d.client.do(ctx, req, &password); err != nil { + return nil, err + } + return password, nil +} + +func passwordBranchAPIPath(org, db, branch, password string) string { + return path.Join(passwordsBranchAPIPath(org, db, branch), password) +} + +func passwordsBranchAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "passwords") +} + +func passwordsAPIPath(org, db string) string { + return path.Join(databasesAPIPath(org), db, "passwords") +} + +func passwordRenewAPIPath(org, db, branch, password string) string { + return path.Join(passwordBranchAPIPath(org, db, branch, password), "renew") +} diff --git a/internal/planetscale/passwords_test.go b/internal/planetscale/passwords_test.go new file mode 100644 index 000000000..91acc9326 --- /dev/null +++ b/internal/planetscale/passwords_test.go @@ -0,0 +1,356 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testPasswordID = "4rwwvrxk2o99" // #nosec G101 - Not a password but a password identifier. + +func TestPasswords_Create(t *testing.T) { + c := qt.New(t) + plainText := "plain-text-password" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "role": "admin", + "plain_text": "%s", + "name": "planetscale-go-test-password", + "created_at": "2021-01-14T10:19:23.000Z", + "replica": false +}`, testPasswordID, plainText) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + password, err := client.Passwords.Create(ctx, &DatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + Role: "admin", + }) + + want := &DatabaseBranchPassword{ + Name: "planetscale-go-test-password", + PublicID: testPasswordID, + + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Role: "admin", + PlainText: plainText, + Replica: false, + } + + c.Assert(err, qt.IsNil) + c.Assert(password, qt.DeepEquals, want) +} + +func TestPasswords_CreateReplica(t *testing.T) { + c := qt.New(t) + plainText := "plain-text-replica-password" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "role": "reader", + "plain_text": "%s", + "name": "planetscale-go-test-replica-password", + "created_at": "2021-01-14T10:19:23.000Z", + "replica": true +}`, testPasswordID, plainText) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + password, err := client.Passwords.Create(ctx, &DatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + Role: "reader", + Replica: true, + }) + + want := &DatabaseBranchPassword{ + Name: "planetscale-go-test-replica-password", + PublicID: testPasswordID, + + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Role: "reader", + PlainText: plainText, + Replica: true, + } + + c.Assert(err, qt.IsNil) + c.Assert(password, qt.DeepEquals, want) +} + +func TestPasswords_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "data": + [ + { + "id": "4rwwvrxk2o99", + "name": "planetscale-go-test-password", + "created_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + + passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + }) + + want := []*DatabaseBranchPassword{ + { + Name: "planetscale-go-test-password", + PublicID: testPasswordID, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(passwords, qt.DeepEquals, want) +} + +func TestPasswords_ListBranch(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ + "data": + [ + { + "id": "4rwwvrxk2o99", + "name": "planetscale-go-test-password", + "database_branch": { + "name": "my-branch" + }, + "created_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + want := []*DatabaseBranchPassword{ + { + Name: "planetscale-go-test-password", + Branch: DatabaseBranch{ + Name: branch, + }, + PublicID: testPasswordID, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(passwords, qt.DeepEquals, want) +} + +func TestPasswords_ListEmpty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + + passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + }) + + c.Assert(err, qt.IsNil) + c.Assert(passwords, qt.HasLen, 0) +} + +func TestPasswords_ListWithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify pagination parameters are included in the request + c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "50") + + w.WriteHeader(200) + out := `{ + "data": + [ + { + "id": "4rwwvrxk2o99", + "name": "planetscale-go-test-password", + "created_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + + passwords, err := client.Passwords.List(ctx, &ListDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + }, WithPage(2), WithPerPage(50)) + + want := []*DatabaseBranchPassword{ + { + Name: "planetscale-go-test-password", + PublicID: testPasswordID, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(passwords, qt.DeepEquals, want) +} + +func TestPasswords_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "role": "writer", + "name": "planetscale-go-test-password", + "created_at": "2021-01-14T10:19:23.000Z" +}`, testPasswordID) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + password, err := client.Passwords.Get(ctx, &GetDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + PasswordId: testPasswordID, + }) + + want := &DatabaseBranchPassword{ + Name: "planetscale-go-test-password", + PublicID: testPasswordID, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Role: "writer", + } + + c.Assert(err, qt.IsNil) + c.Assert(password, qt.DeepEquals, want) +} + +func TestPasswords_Renew(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "role": "writer", + "name": "planetscale-go-test-password", + "created_at": "2021-01-14T10:19:23.000Z" +}`, testPasswordID) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "planetscale-go-test-db" + branch := "my-branch" + + password, err := client.Passwords.Renew(ctx, &RenewDatabaseBranchPasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + PasswordId: testPasswordID, + }) + + want := &DatabaseBranchPassword{ + Name: "planetscale-go-test-password", + PublicID: testPasswordID, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Role: "writer", + } + + c.Assert(err, qt.IsNil) + c.Assert(password, qt.DeepEquals, want) +} diff --git a/internal/planetscale/postgres_branches.go b/internal/planetscale/postgres_branches.go new file mode 100644 index 000000000..653ba5905 --- /dev/null +++ b/internal/planetscale/postgres_branches.go @@ -0,0 +1,302 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +// PostgresBranch represents a Postgres branch in the PlanetScale API. +type PostgresBranch struct { + ID string `json:"id"` + Name string `json:"name"` + ClusterName string `json:"cluster_name"` + ClusterDisplayName string `json:"cluster_display_name"` + ClusterArchitecture string `json:"cluster_architecture"` + ClusterIOPS int `json:"cluster_iops"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Actor Actor `json:"actor"` + Production bool `json:"production"` + Ready bool `json:"ready"` + ParentBranch string `json:"parent_branch"` + Region Region `json:"region"` + Kind string `json:"kind"` + Replicas int `json:"replicas"` +} + +type postgresBranchesResponse struct { + Branches []*PostgresBranch `json:"data"` +} + +// CreatePostgresBranchRequest encapsulates the request to create a Postgres branch. +type CreatePostgresBranchRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Region string `json:"region,omitempty"` + Name string `json:"name"` + ParentBranch string `json:"parent_branch"` + BackupID string `json:"backup_id,omitempty"` + ClusterName string `json:"cluster_name,omitempty"` + MajorVersion string `json:"major_version,omitempty"` + Storage *StorageConfig `json:"storage,omitempty"` +} + +// ListPostgresBranchesRequest encapsulates the request to list Postgres branches for a database. +type ListPostgresBranchesRequest struct { + Organization string + Database string +} + +// GetPostgresBranchRequest encapsulates the request to get a specific Postgres branch. +type GetPostgresBranchRequest struct { + Organization string + Database string + Branch string +} + +// DeletePostgresBranchRequest encapsulates the request to delete a Postgres branch. +type DeletePostgresBranchRequest struct { + Organization string + Database string + Branch string + DeleteDescendants bool +} + +// ResizePostgresBranchRequest encapsulates the request to resize a Postgres +// branch's cluster (and optionally change its replica count). +type ResizePostgresBranchRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + // ClusterSize is the fully-qualified cluster size SKU name to resize to, + // e.g. "PS_10_GCP_X86". Use the values returned by ListClusterSKUs. + ClusterSize string `json:"cluster_size,omitempty"` + // Replicas is the desired number of replicas. Nil leaves it unchanged. + Replicas *int `json:"replicas,omitempty"` +} + +// PostgresBranchClusterResizeRequest represents an asynchronous Postgres branch +// cluster change (resize) request. +type PostgresBranchClusterResizeRequest struct { + ID string `json:"id"` + State string `json:"state"` + + ClusterName string `json:"cluster_name"` + ClusterDisplayName string `json:"cluster_display_name"` + Replicas int `json:"replicas"` + + PreviousClusterName string `json:"previous_cluster_name"` + PreviousClusterDisplayName string `json:"previous_cluster_display_name"` + PreviousReplicas int `json:"previous_replicas"` + + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PostgresBranchSchemaRequest encapsulates the request to get the schema of a Postgres branch. +type PostgresBranchSchemaRequest struct { + Organization string + Database string + Branch string + Namespace string `json:"-"` +} + +// PostgresBranchSchema encapsulates the schema of a Postgres branch. +type PostgresBranchSchema struct { + Name string `json:"name"` + Raw string `json:"raw"` + HTML string `json:"html"` +} + +// postgresBranchSchemaResponse returns the schemas +type postgresBranchSchemaResponse struct { + Schemas []*PostgresBranchSchema `json:"data"` +} + +type PostgresBranchesService interface { + Create(context.Context, *CreatePostgresBranchRequest) (*PostgresBranch, error) + List(context.Context, *ListPostgresBranchesRequest, ...ListOption) ([]*PostgresBranch, error) + Get(context.Context, *GetPostgresBranchRequest) (*PostgresBranch, error) + Delete(context.Context, *DeletePostgresBranchRequest) error + Schema(context.Context, *PostgresBranchSchemaRequest) ([]*PostgresBranchSchema, error) + ListClusterSKUs(context.Context, *ListBranchClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) + Resize(context.Context, *ResizePostgresBranchRequest) (*PostgresBranchClusterResizeRequest, error) +} + +type postgresBranchesService struct { + client *Client +} + +var _ PostgresBranchesService = &postgresBranchesService{} + +func NewPostgresBranchesService(client *Client) *postgresBranchesService { + return &postgresBranchesService{ + client: client, + } +} + +// Create creates a new Postgres branch in the specified organization and database. +func (p *postgresBranchesService) Create(ctx context.Context, createReq *CreatePostgresBranchRequest) (*PostgresBranch, error) { + path := postgresBranchesAPIPath(createReq.Organization, createReq.Database) + req, err := p.client.newRequest(http.MethodPost, path, createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + b := &PostgresBranch{} + if err := p.client.do(ctx, req, b); err != nil { + return nil, err + } + + return b, nil +} + +// List returns a list of Postgres branches for the specified organization and database. +func (p *postgresBranchesService) List(ctx context.Context, listReq *ListPostgresBranchesRequest, opts ...ListOption) ([]*PostgresBranch, error) { + path := postgresBranchesAPIPath(listReq.Organization, listReq.Database) + + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := p.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + pgBranches := &postgresBranchesResponse{} + if err := p.client.do(ctx, req, &pgBranches); err != nil { + return nil, err + } + + return pgBranches.Branches, nil +} + +// Get returns a single Postgres branch for the specified organization, database, and branch. +func (p *postgresBranchesService) Get(ctx context.Context, getReq *GetPostgresBranchRequest) (*PostgresBranch, error) { + path := path.Join(postgresBranchesAPIPath(getReq.Organization, getReq.Database), getReq.Branch) + req, err := p.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + pgBranch := &PostgresBranch{} + if err := p.client.do(ctx, req, &pgBranch); err != nil { + return nil, err + } + + return pgBranch, nil +} + +// Delete deletes a Postgres branch from the specified organization and database. +func (p *postgresBranchesService) Delete(ctx context.Context, deleteReq *DeletePostgresBranchRequest) error { + path := path.Join(postgresBranchesAPIPath(deleteReq.Organization, deleteReq.Database), deleteReq.Branch) + + var opts []RequestOption + if deleteReq.DeleteDescendants { + v := url.Values{} + v.Set("delete_descendants", "true") + opts = append(opts, WithQueryParams(v)) + } + + req, err := p.client.newRequest(http.MethodDelete, path, nil, opts...) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + err = p.client.do(ctx, req, nil) + return err +} + +// ListClusterSKUs returns a list of cluster SKUs for the specified Postgres branch. +func (p *postgresBranchesService) ListClusterSKUs(ctx context.Context, listReq *ListBranchClusterSKUsRequest, opts ...ListOption) ([]*ClusterSKU, error) { + path := path.Join(postgresBranchAPIPath(listReq.Organization, listReq.Database, listReq.Branch), "cluster-size-skus") + + defaultOpts := defaultListOptions() + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := p.client.newRequest(http.MethodGet, path, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + clusterSKUs := []*ClusterSKU{} + if err := p.client.do(ctx, req, &clusterSKUs); err != nil { + return nil, err + } + + return clusterSKUs, nil +} + +// Resize starts an asynchronous resize of a Postgres branch's cluster (and +// optionally changes its replica count). It returns the resulting change +// request, whose State can be polled via the branch changes API. A nil change +// request is returned when the requested configuration matches the current one +// (the API responds 204 No Content). +func (p *postgresBranchesService) Resize(ctx context.Context, resizeReq *ResizePostgresBranchRequest) (*PostgresBranchClusterResizeRequest, error) { + path := path.Join(postgresBranchAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch), "changes") + req, err := p.client.newRequest(http.MethodPatch, path, resizeReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + change := &PostgresBranchClusterResizeRequest{} + if err := p.client.do(ctx, req, change); err != nil { + return nil, err + } + + // A 204 No Content response (requested configuration already matches the + // current one) leaves the body, and therefore the ID, empty. Surface that + // as a nil change request so callers can detect the no-op. + if change.ID == "" { + return nil, nil + } + + return change, nil +} + +// Schema returns the schema for the specified Postgres branch. +func (p *postgresBranchesService) Schema(ctx context.Context, schemaReq *PostgresBranchSchemaRequest) ([]*PostgresBranchSchema, error) { + path := path.Join(postgresBranchAPIPath(schemaReq.Organization, schemaReq.Database, schemaReq.Branch), "schema") + v := url.Values{} + if schemaReq.Namespace != "" { + v.Set("namespace", schemaReq.Namespace) + } + + req, err := p.client.newRequest(http.MethodGet, path, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + schemas := &postgresBranchSchemaResponse{} + if err := p.client.do(ctx, req, &schemas); err != nil { + return nil, err + } + + return schemas.Schemas, nil +} + +func postgresBranchesAPIPath(org, db string) string { + return path.Join(databasesAPIPath(org), db, "branches") +} + +func postgresBranchAPIPath(org, db, branch string) string { + return path.Join(postgresBranchesAPIPath(org, db), branch) +} diff --git a/internal/planetscale/postgres_branches_test.go b/internal/planetscale/postgres_branches_test.go new file mode 100644 index 000000000..ced770eed --- /dev/null +++ b/internal/planetscale/postgres_branches_test.go @@ -0,0 +1,448 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testPostgresBranch = "postgres-test-branch" + +func TestPostgresBranches_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": {"slug": "us-west", "display_name": "US West"}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "postgres-test-db" + + branch, err := client.PostgresBranches.Create(ctx, &CreatePostgresBranchRequest{ + Organization: org, + Database: name, + Region: "us-west", + Name: testPostgresBranch, + ParentBranch: "main", + }) + + want := &PostgresBranch{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(branch, qt.DeepEquals, want) +} + +func TestPostgresBranches_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "postgres-test-db" + + branches, err := client.PostgresBranches.List(ctx, &ListPostgresBranchesRequest{ + Organization: org, + Database: name, + }) + + want := []*PostgresBranch{{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(branches, qt.DeepEquals, want) +} + +func TestPostgresBranches_ListWithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Get("limit"), qt.Equals, "10") + c.Assert(r.URL.Query().Get("starting_after"), qt.Equals, "test-branch") + w.WriteHeader(200) + out := `{"data":[{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "postgres-test-db" + + branches, err := client.PostgresBranches.List(ctx, &ListPostgresBranchesRequest{ + Organization: org, + Database: name, + }, WithLimit(10), WithStartingAfter("test-branch")) + + want := []*PostgresBranch{{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(branches, qt.DeepEquals, want) +} + +func TestPostgresBranches_ListWithDefaultPerPage(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "100") + w.WriteHeader(200) + out := `{"data":[{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "postgres-test-db" + + branches, err := client.PostgresBranches.List(ctx, &ListPostgresBranchesRequest{ + Organization: org, + Database: name, + }) + + want := []*PostgresBranch{{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }} + + c.Assert(err, qt.IsNil) + c.Assert(branches, qt.DeepEquals, want) +} + +func TestPostgresBranches_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z","replicas":1}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + name := "postgres-test-db" + + branch, err := client.PostgresBranches.Get(ctx, &GetPostgresBranchRequest{ + Organization: org, + Database: name, + Branch: testPostgresBranch, + }) + + want := &PostgresBranch{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + Replicas: 1, + } + + c.Assert(err, qt.IsNil) + c.Assert(branch, qt.DeepEquals, want) +} + +func TestPostgresBranches_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/postgres-test-db/branches/postgres-test-branch") + c.Assert(r.URL.Query().Get("delete_descendants"), qt.Equals, "") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.PostgresBranches.Delete(ctx, &DeletePostgresBranchRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + }) + + c.Assert(err, qt.IsNil) +} + +func TestPostgresBranches_DeleteWithDescendants(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/postgres-test-db/branches/postgres-test-branch") + c.Assert(r.URL.Query().Get("delete_descendants"), qt.Equals, "true") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.PostgresBranches.Delete(ctx, &DeletePostgresBranchRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + DeleteDescendants: true, + }) + + c.Assert(err, qt.IsNil) +} + +func TestPostgresBranches_Schema(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data": [{"name": "test_schema", "raw": "CREATE TABLE test...", "html": "
CREATE TABLE test...
"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + schemas, err := client.PostgresBranches.Schema(ctx, &PostgresBranchSchemaRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + }) + + want := []*PostgresBranchSchema{ + { + Name: "test_schema", + Raw: "CREATE TABLE test...", + HTML: "
CREATE TABLE test...
", + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(schemas, qt.DeepEquals, want) +} + +func TestPostgresBranches_ListClusterSKUs(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `[ + { + "name": "PS_10", + "type": "ClusterSizeSku", + "display_name": "PS-10", + "cpu": "1/8", + "provider_instance_type": null, + "storage": null, + "ram": 1, + "enabled": true, + "provider": null, + "rate": null, + "replica_rate": null, + "default_vtgate": "VTG_5", + "default_vtgate_rate": null, + "sort_order": 1 + } + ]` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + skus, err := client.PostgresBranches.ListClusterSKUs(ctx, &ListBranchClusterSKUsRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + }) + + want := []*ClusterSKU{ + { + Name: "PS_10", + DisplayName: "PS-10", + CPU: "1/8", + Memory: 1, + Enabled: true, + DefaultVTGate: "VTG_5", + SortOrder: 1, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(skus, qt.DeepEquals, want) +} + +func TestPostgresBranches_Resize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/postgres-test-db/branches/postgres-test-branch/changes") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["cluster_size"], qt.Equals, "PS_10_GCP_X86") + // Replicas is unset, so it must be omitted from the request body. + _, hasReplicas := body["replicas"] + c.Assert(hasReplicas, qt.IsFalse) + + w.WriteHeader(200) + out := `{"id":"resize-1","state":"queued","cluster_name":"PS_10_GCP_X86","cluster_display_name":"PS-10","replicas":0,"previous_cluster_name":"PS_5_GCP_X86","previous_cluster_display_name":"PS-5","previous_replicas":0,"created_at":"2026-06-24T10:19:23.000Z","updated_at":"2026-06-24T10:19:23.000Z"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + change, err := client.PostgresBranches.Resize(ctx, &ResizePostgresBranchRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + ClusterSize: "PS_10_GCP_X86", + }) + + c.Assert(err, qt.IsNil) + c.Assert(change.ID, qt.Equals, "resize-1") + c.Assert(change.State, qt.Equals, "queued") + c.Assert(change.ClusterName, qt.Equals, "PS_10_GCP_X86") + c.Assert(change.PreviousClusterName, qt.Equals, "PS_5_GCP_X86") +} + +func TestPostgresBranches_ResizeNoOp(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/postgres-test-db/branches/postgres-test-branch/changes") + // The requested configuration already matches: 204 No Content. + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + change, err := client.PostgresBranches.Resize(ctx, &ResizePostgresBranchRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Branch: testPostgresBranch, + ClusterSize: "PS_5_GCP_X86", + }) + + c.Assert(err, qt.IsNil) + c.Assert(change, qt.IsNil) +} + +func TestPostgresBranches_CreateWithStorage(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodPost) + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + storage, ok := body["storage"].(map[string]any) + c.Assert(ok, qt.IsTrue, qt.Commentf("storage field should be a nested object")) + c.Assert(storage["minimum_storage_bytes"], qt.Equals, float64(10737418240)) + c.Assert(storage["maximum_storage_bytes"], qt.Equals, float64(107374182400)) + + out := `{"id":"postgres-test-branch","name":"postgres-test-branch","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": {"slug": "us-west", "display_name": "US West"}}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + minStorage := int64(10737418240) + maxStorage := int64(107374182400) + + branch, err := client.PostgresBranches.Create(ctx, &CreatePostgresBranchRequest{ + Organization: "my-org", + Database: "postgres-test-db", + Region: "us-west", + Name: testPostgresBranch, + ParentBranch: "main", + Storage: &StorageConfig{ + MinimumStorageBytes: &minStorage, + MaximumStorageBytes: &maxStorage, + }, + }) + + want := &PostgresBranch{ + ID: "postgres-test-branch", + Name: testPostgresBranch, + Region: Region{ + Slug: "us-west", + Name: "US West", + }, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(branch, qt.DeepEquals, want) +} diff --git a/internal/planetscale/postgres_roles.go b/internal/planetscale/postgres_roles.go new file mode 100644 index 000000000..a4e2b5ada --- /dev/null +++ b/internal/planetscale/postgres_roles.go @@ -0,0 +1,292 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// PostgresRole represents a PostgreSQL role in PlanetScale. +type PostgresRole struct { + ID string `json:"id"` + Name string `json:"name"` + AccessHostURL string `json:"access_host_url"` + DatabaseName string `json:"database_name"` + Password string `json:"password"` + Actor Actor `json:"actor"` + Username string `json:"username"` + WithReplication bool `json:"with_replication"` + CreatedAt time.Time `json:"created_at"` +} + +type postgresRolesResponse struct { + Roles []*PostgresRole `json:"data"` +} + +// ListPostgresRolesRequest encapsulates the request for listing all roles for a given database branch. +type ListPostgresRolesRequest struct { + Organization string + Database string + Branch string +} + +// GetPostgresRoleRequest encapsulates the request for getting a specific role for a given database branch. +type GetPostgresRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string +} + +// CreatePostgresRoleRequest encapsulates the request for creating role credentials for a database branch. +type CreatePostgresRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + InheritedRoles []string `json:"inherited_roles,omitempty"` + WithReplication bool `json:"with_replication,omitempty"` +} + +// UpdatePostgresRoleRequest encapsulates the request for updating a role name for a database branch. +type UpdatePostgresRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string `json:"-"` + Name string `json:"name"` +} + +// RenewPostgresRoleRequest encapsulates the request for renewing role expiration for a database branch. +type RenewPostgresRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string `json:"-"` +} + +// DeletePostgresRoleRequest encapsulates the request for deleting role credentials for a database branch. +type DeletePostgresRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string `json:"-"` + Successor string `json:"successor,omitempty"` +} + +// ResetDefaultRoleRequest encapsulates the request for resetting the default role of a Postgres database branch. +type ResetDefaultRoleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// ResetPostgresRolePasswordRequest encapsulates the request for resetting a role's password for a database branch. +type ResetPostgresRolePasswordRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string `json:"-"` +} + +// ReassignPostgresRoleObjectsRequest encapsulates the request for reassigning objects owned by one role to another role. +type ReassignPostgresRoleObjectsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + RoleId string `json:"-"` + Successor string `json:"successor"` +} + +// PostgresRolesService defines the interface for managing PostgreSQL roles in PlanetScale. +type PostgresRolesService interface { + List(context.Context, *ListPostgresRolesRequest, ...ListOption) ([]*PostgresRole, error) + Get(context.Context, *GetPostgresRoleRequest) (*PostgresRole, error) + Create(context.Context, *CreatePostgresRoleRequest) (*PostgresRole, error) + Update(context.Context, *UpdatePostgresRoleRequest) (*PostgresRole, error) + Renew(context.Context, *RenewPostgresRoleRequest) (*PostgresRole, error) + Delete(context.Context, *DeletePostgresRoleRequest) error + ResetDefaultRole(context.Context, *ResetDefaultRoleRequest) (*PostgresRole, error) + ResetPassword(context.Context, *ResetPostgresRolePasswordRequest) (*PostgresRole, error) + ReassignObjects(context.Context, *ReassignPostgresRoleObjectsRequest) error +} + +type postgresRolesService struct { + client *Client +} + +var _ PostgresRolesService = &postgresRolesService{} + +func NewPostgresRolesService(client *Client) *postgresRolesService { + return &postgresRolesService{ + client: client, + } +} + +// ResetDefaultRole resets the default role for a PostgreSQL database branch. +func (p *postgresRolesService) ResetDefaultRole(ctx context.Context, resetReq *ResetDefaultRoleRequest) (*PostgresRole, error) { + pathStr := path.Join(postgresBranchRolesAPIPath(resetReq.Organization, resetReq.Database, resetReq.Branch), "reset-default") + req, err := p.client.newRequest(http.MethodPost, pathStr, resetReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// List all existing roles for a database branch. +func (p *postgresRolesService) List(ctx context.Context, listReq *ListPostgresRolesRequest, opts ...ListOption) ([]*PostgresRole, error) { + pathStr := postgresBranchRolesAPIPath(listReq.Organization, listReq.Database, listReq.Branch) + + defaultOpts := defaultListOptions(WithPerPage(50)) + for _, opt := range opts { + err := opt(defaultOpts) + if err != nil { + return nil, err + } + } + + req, err := p.client.newRequest(http.MethodGet, pathStr, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating http request to list roles: %w", err) + } + + rolesResp := &postgresRolesResponse{} + if err := p.client.do(ctx, req, &rolesResp); err != nil { + return nil, err + } + + return rolesResp.Roles, nil +} + +// Get an existing role for a database branch. +func (p *postgresRolesService) Get(ctx context.Context, getReq *GetPostgresRoleRequest) (*PostgresRole, error) { + pathStr := postgresBranchRoleAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.RoleId) + req, err := p.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// Create role credentials for a database branch. +func (p *postgresRolesService) Create(ctx context.Context, createReq *CreatePostgresRoleRequest) (*PostgresRole, error) { + pathStr := postgresBranchRolesAPIPath(createReq.Organization, createReq.Database, createReq.Branch) + req, err := p.client.newRequest(http.MethodPost, pathStr, createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// Update role name for a database branch. +func (p *postgresRolesService) Update(ctx context.Context, updateReq *UpdatePostgresRoleRequest) (*PostgresRole, error) { + pathStr := postgresBranchRoleAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.RoleId) + req, err := p.client.newRequest(http.MethodPatch, pathStr, updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// Renew role expiration for a database branch. +func (p *postgresRolesService) Renew(ctx context.Context, renewReq *RenewPostgresRoleRequest) (*PostgresRole, error) { + pathStr := postgresBranchRoleRenewAPIPath(renewReq.Organization, renewReq.Database, renewReq.Branch, renewReq.RoleId) + req, err := p.client.newRequest(http.MethodPost, pathStr, renewReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// Delete role credentials for a database branch. +func (p *postgresRolesService) Delete(ctx context.Context, deleteReq *DeletePostgresRoleRequest) error { + pathStr := postgresBranchRoleAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.RoleId) + req, err := p.client.newRequest(http.MethodDelete, pathStr, deleteReq) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + err = p.client.do(ctx, req, nil) + return err +} + +// ResetPassword resets a role's password for a database branch. +func (p *postgresRolesService) ResetPassword(ctx context.Context, resetReq *ResetPostgresRolePasswordRequest) (*PostgresRole, error) { + pathStr := postgresBranchRoleResetPasswordAPIPath(resetReq.Organization, resetReq.Database, resetReq.Branch, resetReq.RoleId) + req, err := p.client.newRequest(http.MethodPost, pathStr, resetReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + role := &PostgresRole{} + if err := p.client.do(ctx, req, &role); err != nil { + return nil, err + } + + return role, nil +} + +// ReassignObjects reassigns objects owned by one role to another role. +func (p *postgresRolesService) ReassignObjects(ctx context.Context, reassignReq *ReassignPostgresRoleObjectsRequest) error { + pathStr := postgresBranchRoleReassignObjectsAPIPath(reassignReq.Organization, reassignReq.Database, reassignReq.Branch, reassignReq.RoleId) + req, err := p.client.newRequest(http.MethodPost, pathStr, reassignReq) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + err = p.client.do(ctx, req, nil) + return err +} + +func postgresBranchRoleRenewAPIPath(org, db, branch, roleId string) string { + return path.Join(postgresBranchRoleAPIPath(org, db, branch, roleId), "renew") +} + +func postgresBranchRoleResetPasswordAPIPath(org, db, branch, roleId string) string { + return path.Join(postgresBranchRoleAPIPath(org, db, branch, roleId), "reset") +} + +func postgresBranchRoleReassignObjectsAPIPath(org, db, branch, roleId string) string { + return path.Join(postgresBranchRoleAPIPath(org, db, branch, roleId), "reassign") +} + +func postgresBranchRoleAPIPath(org, db, branch, roleId string) string { + return path.Join(postgresBranchRolesAPIPath(org, db, branch), roleId) +} + +func postgresBranchRolesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "roles") +} diff --git a/internal/planetscale/postgres_roles_test.go b/internal/planetscale/postgres_roles_test.go new file mode 100644 index 000000000..6e9cf57a5 --- /dev/null +++ b/internal/planetscale/postgres_roles_test.go @@ -0,0 +1,553 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testRoleID = "AbC123xYz" + +func TestResetDefaultRole(t *testing.T) { + c := qt.New(t) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "POST") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/reset-default") + w.WriteHeader(200) + out := `{ + "id": "role-id", + "name": "default-postgres-role", + "access_host_url": "pg.psdb.cloud", + "database_name": "postgres", + "password": "secure-password", + "actor": {"id": "actor-id", "display_name": "actor-name"}, + "username": "postgres", + "created_at": "2025-07-15T10:19:23.000Z" + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.ResetDefaultRole(ctx, &ResetDefaultRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + want := &PostgresRole{ + ID: "role-id", + Name: "default-postgres-role", + AccessHostURL: "pg.psdb.cloud", + DatabaseName: "postgres", + Password: "secure-password", + Actor: Actor{ID: "actor-id", Name: "actor-name"}, + Username: "postgres", + CreatedAt: time.Date(2025, time.July, 15, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "GET") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles") + w.WriteHeader(200) + out := `{ + "data": + [ + { + "id": "AbC123xYz", + "name": "test-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "username": "test-user", + "created_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + roles, err := client.PostgresRoles.List(ctx, &ListPostgresRolesRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + want := []*PostgresRole{ + { + ID: testRoleID, + Name: "test-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Username: "test-user", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(roles, qt.DeepEquals, want) +} + +func TestPostgresRoles_ListEmpty(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "GET") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles") + w.WriteHeader(200) + out := `{"data":[]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + roles, err := client.PostgresRoles.List(ctx, &ListPostgresRolesRequest{ + Organization: org, + Database: db, + Branch: branch, + }) + + c.Assert(err, qt.IsNil) + c.Assert(roles, qt.HasLen, 0) +} + +func TestPostgresRoles_ListWithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "GET") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles") + // Verify pagination parameters are included in the request + c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "50") + + w.WriteHeader(200) + out := `{ + "data": + [ + { + "id": "AbC123xYz", + "name": "test-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "username": "test-user", + "created_at": "2021-01-14T10:19:23.000Z" + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + roles, err := client.PostgresRoles.List(ctx, &ListPostgresRolesRequest{ + Organization: org, + Database: db, + Branch: branch, + }, WithPage(2), WithPerPage(50)) + + want := []*PostgresRole{ + { + ID: testRoleID, + Name: "test-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Username: "test-user", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(roles, qt.DeepEquals, want) +} + +func TestPostgresRoles_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "GET") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz") + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "name": "test-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "password": "secret-password", + "username": "test-user", + "with_replication": true, + "created_at": "2021-01-14T10:19:23.000Z" +}`, testRoleID) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.Get(ctx, &GetPostgresRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + }) + + want := &PostgresRole{ + ID: testRoleID, + Name: "test-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Password: "secret-password", + Username: "test-user", + WithReplication: true, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "POST") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles") + + // Verify request body + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + var reqBody CreatePostgresRoleRequest + err = json.Unmarshal(body, &reqBody) + c.Assert(err, qt.IsNil) + c.Assert(reqBody.Name, qt.Equals, "new-role") + c.Assert(reqBody.TTL, qt.Equals, 3600) + c.Assert(reqBody.InheritedRoles, qt.DeepEquals, []string{"pg_read_all_data", "pg_write_all_data"}) + c.Assert(reqBody.WithReplication, qt.Equals, true) + + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "name": "new-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "password": "generated-password", + "username": "new-user", + "with_replication": true, + "created_at": "2021-01-14T10:19:23.000Z" +}`, testRoleID) + _, writeErr := w.Write([]byte(out)) + c.Assert(writeErr, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.Create(ctx, &CreatePostgresRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + Name: "new-role", + TTL: 3600, + InheritedRoles: []string{"pg_read_all_data", "pg_write_all_data"}, + WithReplication: true, + }) + + want := &PostgresRole{ + ID: testRoleID, + Name: "new-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Password: "generated-password", + Username: "new-user", + WithReplication: true, + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_Update(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "PATCH") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz") + + // Verify request body + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + var reqBody UpdatePostgresRoleRequest + err = json.Unmarshal(body, &reqBody) + c.Assert(err, qt.IsNil) + c.Assert(reqBody.Name, qt.Equals, "updated-role") + + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "name": "updated-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "password": "existing-password", + "username": "existing-user", + "created_at": "2021-01-14T10:19:23.000Z" +}`, testRoleID) + _, writeErr := w.Write([]byte(out)) + c.Assert(writeErr, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.Update(ctx, &UpdatePostgresRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + Name: "updated-role", + }) + + want := &PostgresRole{ + ID: testRoleID, + Name: "updated-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Password: "existing-password", + Username: "existing-user", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_Renew(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "POST") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz/renew") + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "name": "existing-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "password": "renewed-password", + "username": "existing-user", + "created_at": "2021-01-14T10:19:23.000Z" +}`, testRoleID) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.Renew(ctx, &RenewPostgresRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + }) + + want := &PostgresRole{ + ID: testRoleID, + Name: "existing-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Password: "renewed-password", + Username: "existing-user", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "DELETE") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz") + + // Verify request body + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + var reqBody DeletePostgresRoleRequest + err = json.Unmarshal(body, &reqBody) + c.Assert(err, qt.IsNil) + c.Assert(reqBody.Successor, qt.Equals, "default") + + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + err = client.PostgresRoles.Delete(ctx, &DeletePostgresRoleRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + Successor: "default", + }) + + c.Assert(err, qt.IsNil) +} + +func TestPostgresRoles_ResetPassword(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "POST") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz/reset") + w.WriteHeader(200) + out := fmt.Sprintf(`{ + "id": "%s", + "name": "existing-role", + "access_host_url": "test.planetscale.com", + "database_name": "test-db", + "password": "new-reset-password", + "username": "existing-user", + "created_at": "2021-01-14T10:19:23.000Z" +}`, testRoleID) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + role, err := client.PostgresRoles.ResetPassword(ctx, &ResetPostgresRolePasswordRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + }) + + want := &PostgresRole{ + ID: testRoleID, + Name: "existing-role", + AccessHostURL: "test.planetscale.com", + DatabaseName: "test-db", + Password: "new-reset-password", + Username: "existing-user", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(role, qt.DeepEquals, want) +} + +func TestPostgresRoles_ReassignObjects(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, "POST") + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/roles/AbC123xYz/reassign") + + // Verify request body + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + var reqBody ReassignPostgresRoleObjectsRequest + err = json.Unmarshal(body, &reqBody) + c.Assert(err, qt.IsNil) + c.Assert(reqBody.Successor, qt.Equals, "new-owner-role") + + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + org := "my-org" + db := "my-db" + branch := "my-branch" + + err = client.PostgresRoles.ReassignObjects(ctx, &ReassignPostgresRoleObjectsRequest{ + Organization: org, + Database: db, + Branch: branch, + RoleId: testRoleID, + Successor: "new-owner-role", + }) + + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/processlist.go b/internal/planetscale/processlist.go new file mode 100644 index 000000000..d1b98283d --- /dev/null +++ b/internal/planetscale/processlist.go @@ -0,0 +1,130 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" +) + +// Process is a single row from `SHOW FULL PROCESSLIST` on the resolved primary +// tablet. +type Process struct { + ID int64 `json:"id"` + User string `json:"user"` + Host string `json:"host"` + DB string `json:"db"` + Command string `json:"command"` + Time int64 `json:"time"` + State string `json:"state"` + Info string `json:"info"` +} + +// ProcesslistResult is the response from showing the process list. It echoes the +// keyspace/shard/tablet that were resolved so the caller knows where the +// processes (and their IDs) live. +type ProcesslistResult struct { + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + Tablet string `json:"tablet"` + Processes []Process `json:"processes"` +} + +// KillProcessResult is the response from killing a process. +type KillProcessResult struct { + Success bool `json:"success"` + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + Tablet string `json:"tablet"` + ID int64 `json:"id"` + Kind string `json:"kind"` +} + +// ProcesslistRequest lists the MySQL process list for a branch. Keyspace/Shard +// are only required to disambiguate multi-keyspace or sharded databases; for a +// single unsharded keyspace they may be left empty. +type ProcesslistRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + Shard string `json:"-"` +} + +// KillProcessRequest kills a single process by ID on a branch. Keyspace/Shard +// follow the same disambiguation rules as ProcesslistRequest. Kind is either +// "connection" (default) or "query". +type KillProcessRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"keyspace,omitempty"` + Shard string `json:"shard,omitempty"` + ID int64 `json:"id"` + Kind string `json:"kind,omitempty"` +} + +// ProcesslistService is an interface for viewing and killing MySQL processes on +// a Vitess branch. +type ProcesslistService interface { + List(context.Context, *ProcesslistRequest) (*ProcesslistResult, error) + Kill(context.Context, *KillProcessRequest) (*KillProcessResult, error) +} + +type processlistService struct { + client *Client +} + +var _ ProcesslistService = &processlistService{} + +type processlistResponse struct { + Data *ProcesslistResult `json:"data"` +} + +type killProcessResponse struct { + Data *KillProcessResult `json:"data"` +} + +func processlistAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "processlist") +} + +func (s *processlistService) List(ctx context.Context, req *ProcesslistRequest) (*ProcesslistResult, error) { + p := processlistAPIPath(req.Organization, req.Database, req.Branch) + v := url.Values{} + if req.Keyspace != "" { + v.Set("keyspace", req.Keyspace) + } + if req.Shard != "" { + v.Set("shard", req.Shard) + } + + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &processlistResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (s *processlistService) Kill(ctx context.Context, req *KillProcessRequest) (*KillProcessResult, error) { + p := path.Join(processlistAPIPath(req.Organization, req.Database, req.Branch), "kill") + + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &killProcessResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp.Data, nil +} diff --git a/internal/planetscale/processlist_test.go b/internal/planetscale/processlist_test.go new file mode 100644 index 000000000..155d48be2 --- /dev/null +++ b/internal/planetscale/processlist_test.go @@ -0,0 +1,108 @@ +package planetscale + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestProcesslist_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/processlist") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "commerce") + c.Assert(r.URL.Query().Get("shard"), qt.Equals, "-80") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"keyspace":"commerce","shard":"-80","tablet":"zone1-1001","processes":[{"id":101,"user":"vt_app","command":"Query","time":42,"info":"SELECT 1"}]}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + result, err := client.Processlist.List(ctx, &ProcesslistRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Shard: "-80", + }) + c.Assert(err, qt.IsNil) + c.Assert(result.Tablet, qt.Equals, "zone1-1001") + c.Assert(result.Processes, qt.HasLen, 1) + c.Assert(result.Processes[0].ID, qt.Equals, int64(101)) + c.Assert(result.Processes[0].User, qt.Equals, "vt_app") +} + +func TestProcesslist_List_OmitsEmptyParams(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Has("keyspace"), qt.IsFalse) + c.Assert(r.URL.Query().Has("shard"), qt.IsFalse) + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"keyspace":"main","shard":"-","tablet":"zone1-2001","processes":[]}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + result, err := client.Processlist.List(ctx, &ProcesslistRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + c.Assert(err, qt.IsNil) + c.Assert(result.Keyspace, qt.Equals, "main") +} + +func TestProcesslist_Kill(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/processlist/kill") + + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + var payload map[string]any + c.Assert(json.Unmarshal(body, &payload), qt.IsNil) + c.Assert(payload["id"], qt.Equals, float64(101)) + c.Assert(payload["kind"], qt.Equals, "query") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"success":true,"keyspace":"main","shard":"-","tablet":"zone1-2001","id":101,"kind":"query"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + result, err := client.Processlist.Kill(ctx, &KillProcessRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + ID: 101, + Kind: "query", + }) + c.Assert(err, qt.IsNil) + c.Assert(result.Success, qt.IsTrue) + c.Assert(result.ID, qt.Equals, int64(101)) + c.Assert(result.Kind, qt.Equals, "query") +} diff --git a/internal/planetscale/query_patterns.go b/internal/planetscale/query_patterns.go new file mode 100644 index 000000000..20b669d53 --- /dev/null +++ b/internal/planetscale/query_patterns.go @@ -0,0 +1,191 @@ +package planetscale + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "time" + + "github.com/hashicorp/go-cleanhttp" +) + +// QueryPatternsReport represents a query patterns report for a branch. +type QueryPatternsReport struct { + PublicID string `json:"id"` + State string `json:"state"` + Actor *Actor `json:"actor"` + URL string `json:"url"` + DownloadURL string `json:"download_url"` + CreatedAt time.Time `json:"created_at"` + FinishedAt time.Time `json:"finished_at"` +} + +type CreateQueryPatternsReportRequest struct { + Organization string + Database string + Branch string +} + +type GetQueryPatternsReportRequest struct { + Organization string + Database string + Branch string + Report string +} + +type DownloadQueryPatternsReportRequest struct { + Organization string + Database string + Branch string + Report string +} + +// QueryPatternsService is an interface for communicating with the PlanetScale +// query patterns API endpoints. +type QueryPatternsService interface { + CreateReport(context.Context, *CreateQueryPatternsReportRequest) (*QueryPatternsReport, error) + GetReport(context.Context, *GetQueryPatternsReportRequest) (*QueryPatternsReport, error) + DownloadReport(context.Context, *DownloadQueryPatternsReportRequest) (io.ReadCloser, error) +} + +type queryPatternsService struct { + client *Client +} + +var _ QueryPatternsService = &queryPatternsService{} + +func NewQueryPatternsService(client *Client) *queryPatternsService { + return &queryPatternsService{ + client: client, + } +} + +// CreateReport starts generating a new query patterns report for a branch. +func (s *queryPatternsService) CreateReport(ctx context.Context, createReq *CreateQueryPatternsReportRequest) (*QueryPatternsReport, error) { + path := queryPatternsAPIPath(createReq.Organization, createReq.Database, createReq.Branch) + req, err := s.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + report := &QueryPatternsReport{} + if err := s.client.do(ctx, req, &report); err != nil { + return nil, err + } + + return report, nil +} + +// GetReport returns a single query patterns report for a branch. +func (s *queryPatternsService) GetReport(ctx context.Context, getReq *GetQueryPatternsReportRequest) (*QueryPatternsReport, error) { + path := queryPatternsReportAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Report) + req, err := s.client.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + report := &QueryPatternsReport{} + if err := s.client.do(ctx, req, &report); err != nil { + return nil, err + } + + return report, nil +} + +// DownloadReport returns the content of a completed query patterns report. +// The caller must close the returned io.ReadCloser. +func (s *queryPatternsService) DownloadReport(ctx context.Context, downloadReq *DownloadQueryPatternsReportRequest) (io.ReadCloser, error) { + reqPath := path.Join(queryPatternsReportAPIPath(downloadReq.Organization, downloadReq.Database, downloadReq.Branch, downloadReq.Report), "download") + req, err := s.client.newRequest(http.MethodGet, reqPath, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + // The download endpoint redirects to blob storage. The client's + // credentials live in its transport, so following the redirect with that + // client would send the Authorization header to the storage host, which + // rejects requests carrying credentials beyond the presigned URL. Stop at + // the redirect and fetch its target with an unauthenticated client. + httpClient := *s.client.client + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + res, err := httpClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + + switch { + case res.StatusCode >= 300 && res.StatusCode < 400: + location, err := res.Location() + res.Body.Close() + if err != nil { + return nil, err + } + + blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location.String(), nil) + if err != nil { + return nil, err + } + blobReq.Header.Set("User-Agent", s.client.UserAgent) + + res, err = cleanhttp.DefaultClient().Do(blobReq) + if err != nil { + return nil, err + } + if res.StatusCode >= 300 { + res.Body.Close() + return nil, fmt.Errorf("downloading query patterns report: %s", http.StatusText(res.StatusCode)) + } + case res.StatusCode >= 400: + defer res.Body.Close() + return nil, s.client.handleResponse(ctx, res, nil) + } + + return decompressedReadCloser(res.Body) +} + +// decompressedReadCloser wraps body so gzip-compressed content is transparently +// decompressed, sniffing the gzip magic bytes so an uncompressed body passes +// through unchanged. +func decompressedReadCloser(body io.ReadCloser) (io.ReadCloser, error) { + head := make([]byte, 2) + n, err := io.ReadFull(body, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + body.Close() + return nil, err + } + + content := io.MultiReader(bytes.NewReader(head[:n]), body) + if n == 2 && head[0] == 0x1f && head[1] == 0x8b { + gz, err := gzip.NewReader(content) + if err != nil { + body.Close() + return nil, err + } + content = gz + } + + return &bodyReadCloser{Reader: content, body: body}, nil +} + +type bodyReadCloser struct { + io.Reader + body io.ReadCloser +} + +func (r *bodyReadCloser) Close() error { return r.body.Close() } + +func queryPatternsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "query-patterns") +} + +func queryPatternsReportAPIPath(org, db, branch, report string) string { + return path.Join(queryPatternsAPIPath(org, db, branch), report) +} diff --git a/internal/planetscale/query_patterns_test.go b/internal/planetscale/query_patterns_test.go new file mode 100644 index 000000000..9e715a750 --- /dev/null +++ b/internal/planetscale/query_patterns_test.go @@ -0,0 +1,180 @@ +package planetscale + +import ( + "compress/gzip" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +const testQueryPatternsCSV = "normalized_sql,query_count\nselect ?,10\n" + +func TestQueryPatterns_CreateReport(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns") + w.WriteHeader(201) + out := `{"id":"report1","state":"pending","actor":{"id":"actor1","type":"User","display_name":"user@example.com"},"url":"https://api.planetscale.com/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1","created_at":"2021-01-14T10:19:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + t.Cleanup(ts.Close) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + report, err := client.QueryPatterns.CreateReport(context.Background(), &CreateQueryPatternsReportRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + + want := &QueryPatternsReport{ + PublicID: "report1", + State: "pending", + Actor: &Actor{ + ID: "actor1", + Type: "User", + Name: "user@example.com", + }, + URL: "https://api.planetscale.com/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(report, qt.DeepEquals, want) +} + +func TestQueryPatterns_GetReport(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1") + w.WriteHeader(200) + out := `{"id":"report1","state":"completed","download_url":"https://api.planetscale.com/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1/download","created_at":"2021-01-14T10:19:23.000Z","finished_at":"2021-01-14T10:20:23.000Z"}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + t.Cleanup(ts.Close) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + report, err := client.QueryPatterns.GetReport(context.Background(), &GetQueryPatternsReportRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Report: "report1", + }) + + want := &QueryPatternsReport{ + PublicID: "report1", + State: "completed", + DownloadURL: "https://api.planetscale.com/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1/download", + CreatedAt: time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC), + FinishedAt: time.Date(2021, time.January, 14, 10, 20, 23, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(report, qt.DeepEquals, want) +} + +func TestQueryPatterns_DownloadReport(t *testing.T) { + c := qt.New(t) + + // blob simulates presigned storage: it rejects requests that carry the + // API's Authorization header. + blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Header.Get("Authorization"), qt.Equals, "") + w.Header().Set("Content-Type", "application/gzip") + gz := gzip.NewWriter(w) + _, err := gz.Write([]byte(testQueryPatternsCSV)) + c.Assert(err, qt.IsNil) + c.Assert(gz.Close(), qt.IsNil) + })) + t.Cleanup(blob.Close) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/query-patterns/report1/download") + c.Assert(r.Header.Get("Authorization"), qt.Equals, "tid:secret") + http.Redirect(w, r, blob.URL, http.StatusFound) + })) + t.Cleanup(ts.Close) + + client, err := NewClient(WithBaseURL(ts.URL), WithServiceToken("tid", "secret")) + c.Assert(err, qt.IsNil) + + body, err := client.QueryPatterns.DownloadReport(context.Background(), &DownloadQueryPatternsReportRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Report: "report1", + }) + c.Assert(err, qt.IsNil) + defer body.Close() + + data, err := io.ReadAll(body) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, testQueryPatternsCSV) +} + +func TestQueryPatterns_DownloadReportUncompressed(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv") + _, err := w.Write([]byte(testQueryPatternsCSV)) + c.Assert(err, qt.IsNil) + })) + t.Cleanup(ts.Close) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + body, err := client.QueryPatterns.DownloadReport(context.Background(), &DownloadQueryPatternsReportRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Report: "report1", + }) + c.Assert(err, qt.IsNil) + defer body.Close() + + data, err := io.ReadAll(body) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, testQueryPatternsCSV) +} + +func TestQueryPatterns_DownloadReportNotFound(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + _, err := w.Write([]byte(`{"code":"not_found","message":"Not Found"}`)) + c.Assert(err, qt.IsNil) + })) + t.Cleanup(ts.Close) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + _, err = client.QueryPatterns.DownloadReport(context.Background(), &DownloadQueryPatternsReportRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Report: "report1", + }) + c.Assert(err, qt.IsNotNil) + + var perr *Error + c.Assert(err, qt.ErrorAs, &perr) + c.Assert(perr.Code, qt.Equals, ErrNotFound) +} diff --git a/internal/planetscale/regions.go b/internal/planetscale/regions.go new file mode 100644 index 000000000..f7325e1cc --- /dev/null +++ b/internal/planetscale/regions.go @@ -0,0 +1,54 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" +) + +const regionsAPIPath = "v1/regions" + +type Region struct { + Slug string `json:"slug"` + Provider string `json:"provider"` + Name string `json:"display_name"` + Location string `json:"location"` + Enabled bool `json:"enabled"` + IsDefault bool `json:"current_default"` +} + +type regionsResponse struct { + Regions []*Region `json:"data"` +} + +type ListRegionsRequest struct{} + +type RegionsService interface { + List(ctx context.Context, req *ListRegionsRequest) ([]*Region, error) +} + +type regionsService struct { + client *Client +} + +var _ RegionsService = ®ionsService{} + +func NewRegionsSevice(client *Client) *regionsService { + return ®ionsService{ + client: client, + } +} + +func (r *regionsService) List(ctx context.Context, listReq *ListRegionsRequest) ([]*Region, error) { + req, err := r.client.newRequest(http.MethodGet, regionsAPIPath, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for list regions: %w", err) + } + + regionsResponse := ®ionsResponse{} + if err := r.client.do(ctx, req, ®ionsResponse); err != nil { + return nil, err + } + + return regionsResponse.Regions, nil +} diff --git a/internal/planetscale/regions_test.go b/internal/planetscale/regions_test.go new file mode 100644 index 000000000..a19b4be11 --- /dev/null +++ b/internal/planetscale/regions_test.go @@ -0,0 +1,56 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestRegions_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{ +"data": [ + { + "id": "my-cool-org", + "type": "Region", + "slug": "us-east", + "display_name": "US East", + "location": "Northern Virginia", + "provider": "AWS", + "enabled": true, + "current_default": true + } + ] +}` + + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + orgs, err := client.Regions.List(ctx, &ListRegionsRequest{}) + + c.Assert(err, qt.IsNil) + want := []*Region{ + { + Slug: "us-east", + Provider: "AWS", + Name: "US East", + Location: "Northern Virginia", + Enabled: true, + IsDefault: true, + }, + } + + c.Assert(orgs, qt.DeepEquals, want) +} diff --git a/internal/planetscale/schema_recommendations.go b/internal/planetscale/schema_recommendations.go new file mode 100644 index 000000000..a32f9b3d9 --- /dev/null +++ b/internal/planetscale/schema_recommendations.go @@ -0,0 +1,127 @@ +package planetscale + +import ( + "context" + "net/http" + "path" + "time" +) + +var _ SchemaRecommendationService = &schemaRecommendationService{} + +// SchemaRecommendationService is an interface for communicating with the PlanetScale +// Schema recommendation API. +type SchemaRecommendationService interface { + List(context.Context, *ListSchemaRecommendationsRequest, ...ListOption) ([]*SchemaRecommendation, error) + Get(context.Context, *GetSchemaRecommendationRequest) (*SchemaRecommendation, error) + Dismiss(context.Context, *DismissSchemaRecommendationRequest) (*SchemaRecommendation, error) +} + +type schemaRecommendationsResponse struct { + SchemaRecommendations []*SchemaRecommendation `json:"data"` +} + +// SchemaRecommendation represents a PlanetScale schema recommendation. +type SchemaRecommendation struct { + ID string `json:"id"` + HtmlURL string `json:"html_url"` + Title string `json:"title"` + Table string `json:"table_name"` + Keyspace string `json:"keyspace"` + DDLStatement string `json:"ddl_statement"` + Number int `json:"number"` + State string `json:"state"` + RecommendationType string `json:"recommendation_type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + AppliedAt *time.Time `json:"applied_at"` + DismissedAt *time.Time `json:"dismissed_at"` + ClosedByDeployRequest *ClosedByDeployRequest `json:"closed_by_deploy_request"` + DismissedBy *Actor `json:"dismissed_by"` +} + +type ClosedByDeployRequest struct { + ID string `json:"id"` + BranchID string `json:"branch_id"` + Number int `json:"number"` +} + +// ListSchemaRecommendationsRequest is the request for listing schema recommendations. +type ListSchemaRecommendationsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +// GetSchemaRecommendationRequest is the request for getting a schema recommendation. +type GetSchemaRecommendationRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` +} + +// DismissSchemaRecommendationRequest is the request for dismissing a schema recommendation. +type DismissSchemaRecommendationRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` +} + +type schemaRecommendationService struct { + client *Client +} + +func (s *schemaRecommendationService) List(ctx context.Context, request *ListSchemaRecommendationsRequest, opts ...ListOption) ([]*SchemaRecommendation, error) { + listOpts := defaultListOptions(opts...) + + req, err := s.client.newRequest(http.MethodGet, schemaRecommendationsAPIPath(request.Organization, request.Database), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + resp := &schemaRecommendationsResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.SchemaRecommendations, nil +} + +func (s *schemaRecommendationService) Get(ctx context.Context, request *GetSchemaRecommendationRequest) (*SchemaRecommendation, error) { + req, err := s.client.newRequest(http.MethodGet, schemaRecommendationAPIPath(request.Organization, request.Database, request.ID), nil) + if err != nil { + return nil, err + } + + schemaRecommendation := &SchemaRecommendation{} + if err := s.client.do(ctx, req, &schemaRecommendation); err != nil { + return nil, err + } + + return schemaRecommendation, nil +} + +func (s *schemaRecommendationService) Dismiss(ctx context.Context, request *DismissSchemaRecommendationRequest) (*SchemaRecommendation, error) { + req, err := s.client.newRequest(http.MethodPost, dismissSchemaRecommendationAPIPath(request.Organization, request.Database, request.ID), nil) + if err != nil { + return nil, err + } + + schemaRecommendation := &SchemaRecommendation{} + if err := s.client.do(ctx, req, &schemaRecommendation); err != nil { + return nil, err + } + + return schemaRecommendation, nil +} + +func schemaRecommendationsAPIPath(org, db string) string { + return path.Join("v1/organizations", org, "databases", db, "schema-recommendations") +} + +func schemaRecommendationAPIPath(org, db, id string) string { + return path.Join(schemaRecommendationsAPIPath(org, db), id) +} + +func dismissSchemaRecommendationAPIPath(org, db, id string) string { + return path.Join(schemaRecommendationAPIPath(org, db, id), "dismiss") +} diff --git a/internal/planetscale/schema_recommendations_test.go b/internal/planetscale/schema_recommendations_test.go new file mode 100644 index 000000000..6e4d2a4d4 --- /dev/null +++ b/internal/planetscale/schema_recommendations_test.go @@ -0,0 +1,204 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestSchemaRecommendations_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations") + + out := `{ + "current_page": 1, + "next_page": null, + "next_page_url": null, + "prev_page": null, + "prev_page_url": null, + "data": [{ + "id": "recommendation-123", + "html_url": "https://app.planetscale.com/my-org/planetscale-go-test-db/insights/recommendations/1", + "title": "Add index on users.email", + "table_name": "users", + "keyspace": "main", + "ddl_statement": "ALTER TABLE users ADD INDEX idx_email (email)", + "number": 1, + "state": "open", + "recommendation_type": "index", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z", + "applied_at": null, + "dismissed_at": null, + "closed_by_deploy_request": { + "id": "", + "branch_id": "", + "number": 0 + }, + "dismissed_by": { + "id": "", + "display_name": "" + } + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + recommendations, err := client.SchemaRecommendations.List(ctx, &ListSchemaRecommendationsRequest{ + Organization: testOrg, + Database: testDatabase, + }) + + c.Assert(err, qt.IsNil) + c.Assert(len(recommendations), qt.Equals, 1) + c.Assert(recommendations[0].ID, qt.Equals, "recommendation-123") + c.Assert(recommendations[0].Title, qt.Equals, "Add index on users.email") + c.Assert(recommendations[0].Table, qt.Equals, "users") + c.Assert(recommendations[0].Keyspace, qt.Equals, "main") + c.Assert(recommendations[0].State, qt.Equals, "open") + c.Assert(recommendations[0].RecommendationType, qt.Equals, "index") + c.Assert(recommendations[0].Number, qt.Equals, 1) +} + +func TestSchemaRecommendations_List_WithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "10") + + out := `{ + "current_page": 2, + "next_page": null, + "next_page_url": null, + "prev_page": 1, + "prev_page_url": "https://api.planetscale.com/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations?page=1", + "data": [] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + recommendations, err := client.SchemaRecommendations.List(ctx, &ListSchemaRecommendationsRequest{ + Organization: testOrg, + Database: testDatabase, + }, WithPage(2), WithPerPage(10)) + + c.Assert(err, qt.IsNil) + c.Assert(len(recommendations), qt.Equals, 0) +} + +func TestSchemaRecommendations_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations/recommendation-123") + + out := `{ + "id": "recommendation-123", + "html_url": "https://app.planetscale.com/my-org/planetscale-go-test-db/insights/recommendations/1", + "title": "Add index on users.email", + "table_name": "users", + "keyspace": "main", + "ddl_statement": "ALTER TABLE users ADD INDEX idx_email (email)", + "number": 1, + "state": "open", + "recommendation_type": "index", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z" + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + recommendation, err := client.SchemaRecommendations.Get(ctx, &GetSchemaRecommendationRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "recommendation-123", + }) + + c.Assert(err, qt.IsNil) + c.Assert(recommendation.ID, qt.Equals, "recommendation-123") + c.Assert(recommendation.Title, qt.Equals, "Add index on users.email") + c.Assert(recommendation.DDLStatement, qt.Equals, "ALTER TABLE users ADD INDEX idx_email (email)") + c.Assert(recommendation.State, qt.Equals, "open") +} + +func TestSchemaRecommendations_Dismiss(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations/recommendation-123/dismiss") + + out := `{ + "id": "recommendation-123", + "html_url": "https://app.planetscale.com/my-org/planetscale-go-test-db/insights/recommendations/1", + "title": "Add index on users.email", + "table_name": "users", + "keyspace": "main", + "ddl_statement": "ALTER TABLE users ADD INDEX idx_email (email)", + "number": 1, + "state": "dismissed", + "recommendation_type": "index", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-15T10:19:23.000Z", + "applied_at": null, + "dismissed_at": "2021-01-15T10:19:23.000Z", + "dismissed_by": { + "id": "user-123", + "display_name": "Test User" + } + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + recommendation, err := client.SchemaRecommendations.Dismiss(ctx, &DismissSchemaRecommendationRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "recommendation-123", + }) + + c.Assert(err, qt.IsNil) + c.Assert(recommendation.ID, qt.Equals, "recommendation-123") + c.Assert(recommendation.State, qt.Equals, "dismissed") + c.Assert(recommendation.DismissedAt, qt.IsNotNil) + c.Assert(recommendation.DismissedAt.Format(time.RFC3339Nano), qt.Equals, "2021-01-15T10:19:23Z") + c.Assert(recommendation.DismissedBy.ID, qt.Equals, "user-123") + c.Assert(recommendation.DismissedBy.Name, qt.Equals, "Test User") +} diff --git a/internal/planetscale/service_tokens.go b/internal/planetscale/service_tokens.go new file mode 100644 index 000000000..28fde955d --- /dev/null +++ b/internal/planetscale/service_tokens.go @@ -0,0 +1,216 @@ +package planetscale + +import ( + "context" + "net/http" + "path" + "time" +) + +var _ ServiceTokenService = &serviceTokenService{} + +// ServiceTokenService is an interface for communicating with the PlanetScale +// Service Token API. +type ServiceTokenService interface { + Create(context.Context, *CreateServiceTokenRequest) (*ServiceToken, error) + List(context.Context, *ListServiceTokensRequest) ([]*ServiceToken, error) + ListGrants(context.Context, *ListServiceTokenGrantsRequest) ([]*ServiceTokenGrant, error) + Delete(context.Context, *DeleteServiceTokenRequest) error + GetAccess(context.Context, *GetServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) + AddAccess(context.Context, *AddServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) + DeleteAccess(context.Context, *DeleteServiceTokenAccessRequest) error +} + +type serviceTokenService struct { + client *Client +} + +func (s *serviceTokenService) Create(ctx context.Context, createReq *CreateServiceTokenRequest) (*ServiceToken, error) { + req, err := s.client.newRequest(http.MethodPost, serviceTokensAPIPath(createReq.Organization), createReq) + if err != nil { + return nil, err + } + + st := &ServiceToken{} + if err := s.client.do(ctx, req, &st); err != nil { + return nil, err + } + + return st, nil +} + +func (s *serviceTokenService) List(ctx context.Context, listReq *ListServiceTokensRequest) ([]*ServiceToken, error) { + req, err := s.client.newRequest(http.MethodGet, serviceTokensAPIPath(listReq.Organization), nil) + if err != nil { + return nil, err + } + + tokenListResponse := serviceTokensResponse{} + if err := s.client.do(ctx, req, &tokenListResponse); err != nil { + return nil, err + } + + return tokenListResponse.ServiceTokens, nil +} + +func (s *serviceTokenService) Delete(ctx context.Context, delReq *DeleteServiceTokenRequest) error { + req, err := s.client.newRequest(http.MethodDelete, serviceTokenAPIPath(delReq.Organization, delReq.ID), nil) + if err != nil { + return err + } + + err = s.client.do(ctx, req, nil) + return err +} + +func (s *serviceTokenService) GetAccess(ctx context.Context, accessReq *GetServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) { + req, err := s.client.newRequest(http.MethodGet, serviceTokenAccessAPIPath(accessReq.Organization, accessReq.ID), nil) + if err != nil { + return nil, err + } + + tokenAccess := serviceTokenAccessResponse{} + if err := s.client.do(ctx, req, &tokenAccess); err != nil { + return nil, err + } + return tokenAccess.ServiceTokenAccesses, nil +} + +func (s *serviceTokenService) ListGrants(ctx context.Context, listReq *ListServiceTokenGrantsRequest) ([]*ServiceTokenGrant, error) { + req, err := s.client.newRequest(http.MethodGet, serviceTokenGrantsAPIPath(listReq.Organization, listReq.ID), nil) + if err != nil { + return nil, err + } + + tokenGrants := serviceTokenGrantsResponse{} + if err := s.client.do(ctx, req, &tokenGrants); err != nil { + return nil, err + } + return tokenGrants.ServiceTokenGrants, nil +} + +func (s *serviceTokenService) AddAccess(ctx context.Context, addReq *AddServiceTokenAccessRequest) ([]*ServiceTokenAccess, error) { + req, err := s.client.newRequest(http.MethodPost, serviceTokenAccessAPIPath(addReq.Organization, addReq.ID), addReq) + if err != nil { + return nil, err + } + + tokenAccess := serviceTokenAccessResponse{} + if err := s.client.do(ctx, req, &tokenAccess); err != nil { + return nil, err + } + return tokenAccess.ServiceTokenAccesses, nil +} + +func (s *serviceTokenService) DeleteAccess(ctx context.Context, delReq *DeleteServiceTokenAccessRequest) error { + req, err := s.client.newRequest(http.MethodDelete, serviceTokenAccessAPIPath(delReq.Organization, delReq.ID), delReq) + if err != nil { + return err + } + + err = s.client.do(ctx, req, nil) + return err +} + +type CreateServiceTokenRequest struct { + Organization string `json:"-"` + Name *string `json:"name,omitempty"` + TTL *int `json:"ttl,omitempty"` +} + +type ListServiceTokenGrantsRequest struct { + Organization string `json:"-"` + ID string `json:"-"` +} + +type DeleteServiceTokenRequest struct { + Organization string `json:"-"` + ID string `json:"-"` +} + +type ListServiceTokensRequest struct { + Organization string `json:"-"` +} + +type GetServiceTokenAccessRequest struct { + Organization string `json:"-"` + ID string `json:"-"` +} + +type AddServiceTokenAccessRequest struct { + Organization string `json:"-"` + ID string `json:"-"` + Database string `json:"database"` + Accesses []string `json:"access"` +} + +type DeleteServiceTokenAccessRequest struct { + Organization string `json:"-"` + ID string `json:"-"` + Database string `json:"database"` + Accesses []string `json:"access"` +} + +type ServiceToken struct { + ID string `json:"id"` + Type string `json:"type"` + Token string `json:"token"` + Name *string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` +} + +type ServiceTokenGrant struct { + ID string `json:"id"` + ResourceName string `json:"resource_name"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + Accesses []*ServiceTokenGrantAccess `json:"accesses"` +} + +type ServiceTokenGrantAccess struct { + Access string `json:"access"` + Description string `json:"description"` +} + +type serviceTokensResponse struct { + ServiceTokens []*ServiceToken `json:"data"` +} + +type ServiceTokenAccess struct { + ID string `json:"id"` + Access string `json:"access"` + Type string `json:"type"` + Resource ServiceTokenResource `json:"resource"` +} + +type ServiceTokenResource struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type serviceTokenAccessResponse struct { + ServiceTokenAccesses []*ServiceTokenAccess `json:"data"` +} + +type serviceTokenGrantsResponse struct { + ServiceTokenGrants []*ServiceTokenGrant `json:"data"` +} + +func serviceTokenAccessAPIPath(org, id string) string { + return path.Join(serviceTokensAPIPath(org), id, "access") +} + +func serviceTokenGrantsAPIPath(org, id string) string { + return path.Join(serviceTokensAPIPath(org), id, "grants") +} + +func serviceTokensAPIPath(org string) string { + return path.Join("v1/organizations", org, "service-tokens") +} + +func serviceTokenAPIPath(org, id string) string { + return path.Join(serviceTokensAPIPath(org), id) +} diff --git a/internal/planetscale/service_tokens_test.go b/internal/planetscale/service_tokens_test.go new file mode 100644 index 000000000..39efe4678 --- /dev/null +++ b/internal/planetscale/service_tokens_test.go @@ -0,0 +1,292 @@ +package planetscale + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestServiceTokens_Create(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"name\":\"my-token\"}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + out := `{"id":"test-id","type":"ServiceToken","token":"d2980bbd91a4ab878601ef0573a7af7b1b15e705","name":"my-token","created_at":"2021-01-14T10:19:23.000Z","last_used_at":"2021-01-15T12:30:00.000Z"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + tokenName := "my-token" + + snapshot, err := client.ServiceTokens.Create(ctx, &CreateServiceTokenRequest{ + Organization: testOrg, + Name: &tokenName, + }) + lastUsedAt := time.Date(2021, 1, 15, 12, 30, 0, 0, time.UTC) + want := &ServiceToken{ + ID: "test-id", + Type: "ServiceToken", + Token: "d2980bbd91a4ab878601ef0573a7af7b1b15e705", + Name: &tokenName, + CreatedAt: time.Date(2021, 1, 14, 10, 19, 23, 0, time.UTC), + LastUsedAt: &lastUsedAt, + } + + c.Assert(err, qt.IsNil) + c.Assert(snapshot, qt.DeepEquals, want) +} + +func TestServiceTokens_CreateWithTTL(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"name\":\"my-token\",\"ttl\":3600}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + out := `{"id":"test-id","type":"ServiceToken","token":"d2980bbd91a4ab878601ef0573a7af7b1b15e705","name":"my-token","created_at":"2021-01-14T10:19:23.000Z","last_used_at":"2021-01-15T12:30:00.000Z","expires_at":"2021-01-14T11:19:23.000Z"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + tokenName := "my-token" + ttl := 3600 + + snapshot, err := client.ServiceTokens.Create(ctx, &CreateServiceTokenRequest{ + Organization: testOrg, + Name: &tokenName, + TTL: &ttl, + }) + lastUsedAt := time.Date(2021, 1, 15, 12, 30, 0, 0, time.UTC) + expiresAt := time.Date(2021, 1, 14, 11, 19, 23, 0, time.UTC) + want := &ServiceToken{ + ID: "test-id", + Type: "ServiceToken", + Token: "d2980bbd91a4ab878601ef0573a7af7b1b15e705", + Name: &tokenName, + CreatedAt: time.Date(2021, 1, 14, 10, 19, 23, 0, time.UTC), + LastUsedAt: &lastUsedAt, + ExpiresAt: &expiresAt, + } + + c.Assert(err, qt.IsNil) + c.Assert(snapshot, qt.DeepEquals, want) +} + +func TestServiceTokens_ListGrants(t *testing.T) { + c := qt.New(t) + + out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"qbphfi83nxti","type":"ServiceTokenGrant","resource_name":"planetscale","resource_type":"Database","resource_id":"qbphfi83nxti","accesses":[{"access": "read_branch", "description": "Read database branch"}]}]}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + grants, err := client.ServiceTokens.ListGrants(ctx, &ListServiceTokenGrantsRequest{ + Organization: testOrg, + ID: "1234", + }) + + want := []*ServiceTokenGrant{ + { + ID: "qbphfi83nxti", + ResourceName: "planetscale", + ResourceType: "Database", + ResourceID: "qbphfi83nxti", + Accesses: []*ServiceTokenGrantAccess{{Access: "read_branch", Description: "Read database branch"}}, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(grants, qt.DeepEquals, want) +} + +func TestServiceTokens_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"txhc257pxjuc","type":"ServiceToken","token":null,"name":"list-token","created_at":"2021-01-14T10:19:23.000Z","last_used_at":null}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + snapshot, err := client.ServiceTokens.List(ctx, &ListServiceTokensRequest{ + Organization: testOrg, + }) + tokenName := "list-token" + want := []*ServiceToken{ + { + ID: "txhc257pxjuc", + Type: "ServiceToken", + Name: &tokenName, + CreatedAt: time.Date(2021, 1, 14, 10, 19, 23, 0, time.UTC), + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(snapshot, qt.DeepEquals, want) +} + +func TestServiceTokens_Delete(t *testing.T) { + c := qt.New(t) + + wantURL := "/v1/organizations/my-org/service-tokens/1234" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.URL.String(), qt.DeepEquals, wantURL) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.ServiceTokens.Delete(ctx, &DeleteServiceTokenRequest{ + Organization: testOrg, + ID: "1234", + }) + + c.Assert(err, qt.IsNil) +} + +func TestServiceTokens_GetAccess(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"hjqui654yu71","type":"DatabaseAccess","resource":{"id":"1lbjwnp48b6r","type":"Database","name":"hidden-river-4209","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"access":"read_comment"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + snapshot, err := client.ServiceTokens.GetAccess(ctx, &GetServiceTokenAccessRequest{ + Organization: testOrg, + ID: "1234", + }) + want := []*ServiceTokenAccess{ + { + ID: "hjqui654yu71", + Access: "read_comment", + Type: "DatabaseAccess", + Resource: ServiceTokenResource{ + ID: "1lbjwnp48b6r", + Name: "hidden-river-4209", + Type: "Database", + }, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(snapshot, qt.DeepEquals, want) +} + +func TestServiceTokens_AddAccess(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"database\":\"hidden-river-4209\",\"access\":[\"read_comment\"]}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + out := `{"type":"list","next_page":null,"prev_page":null,"data":[{"id":"hjqui654yu71","type":"DatabaseAccess","resource":{"id":"1lbjwnp48b6r","type":"Database","name":"hidden-river-4209","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z"},"access":"read_comment"}]}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + snapshot, err := client.ServiceTokens.AddAccess(ctx, &AddServiceTokenAccessRequest{ + Organization: testOrg, + ID: "1234", + Database: "hidden-river-4209", + Accesses: []string{"read_comment"}, + }) + want := []*ServiceTokenAccess{ + { + ID: "hjqui654yu71", + Access: "read_comment", + Type: "DatabaseAccess", + Resource: ServiceTokenResource{ + ID: "1lbjwnp48b6r", + Name: "hidden-river-4209", + Type: "Database", + }, + }, + } + + c.Assert(err, qt.IsNil) + c.Assert(snapshot, qt.DeepEquals, want) +} + +func TestServiceTokens_DeleteAccess(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"database\":\"hidden-river-4209\",\"access\":[\"read_comment\"]}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.ServiceTokens.DeleteAccess(ctx, &DeleteServiceTokenAccessRequest{ + Organization: testOrg, + ID: "1234", + Database: "hidden-river-4209", + Accesses: []string{"read_comment"}, + }) + + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/traffic_budgets.go b/internal/planetscale/traffic_budgets.go new file mode 100644 index 000000000..0719df5d5 --- /dev/null +++ b/internal/planetscale/traffic_budgets.go @@ -0,0 +1,217 @@ +package planetscale + +import ( + "context" + "net/http" + "path" + "time" +) + +var _ TrafficBudgetsService = &trafficBudgetsService{} + +// TrafficBudgetsService communicates with the PlanetScale traffic budgets API. +type TrafficBudgetsService interface { + List(context.Context, *ListTrafficBudgetsRequest) ([]*TrafficBudget, error) + Get(context.Context, *GetTrafficBudgetRequest) (*TrafficBudget, error) + Create(context.Context, *CreateTrafficBudgetRequest) (*TrafficBudget, error) + Update(context.Context, *UpdateTrafficBudgetRequest) (*TrafficBudget, error) + Delete(context.Context, *DeleteTrafficBudgetRequest) error +} + +// TrafficRuleTag is a tag attached to a traffic rule. +type TrafficRuleTag struct { + Type string `json:"type"` + KeyID string `json:"key_id"` + Key string `json:"key"` + Value string `json:"value"` + Source string `json:"source"` +} + +// TrafficRule represents a rule for a traffic budget. +type TrafficRule struct { + ID string `json:"id"` + Type string `json:"type"` + Kind string `json:"kind"` + Fingerprint *string `json:"fingerprint"` + Keyspace *string `json:"keyspace"` + Tags []TrafficRuleTag `json:"tags"` + Actor Actor `json:"actor"` + SyntaxHighlightedSQL string `json:"syntax_highlighted_sql"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrafficBudget represents a traffic budget on a branch. +type TrafficBudget struct { + ID string `json:"id"` + Type string `json:"type"` + + Name string `json:"name"` + Mode string `json:"mode"` + + Capacity *int `json:"capacity"` + Rate *int `json:"rate"` + Burst *int `json:"burst"` + Concurrency *int `json:"concurrency"` + WarningThreshold *int `json:"warning_threshold"` + + Rules []TrafficRule `json:"rules"` + + Actor Actor `json:"actor"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type trafficBudgetsResponse struct { + Data []*TrafficBudget `json:"data"` +} + +// ListTrafficBudgetsRequest is the request for listing traffic budgets. +type ListTrafficBudgetsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// GetTrafficBudgetRequest is the request for getting a traffic budget. +type GetTrafficBudgetRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + BudgetID string `json:"-"` +} + +// CreateTrafficBudgetRuleRequest describes a rule when creating or updating a budget. +type CreateTrafficBudgetRuleRequest struct { + Kind string `json:"kind"` + Fingerprint string `json:"fingerprint,omitempty"` + Keyspace string `json:"keyspace,omitempty"` + Tags *[]TrafficRuleTag `json:"tags,omitempty"` +} + +// CreateTrafficBudgetRequest is the request for creating a traffic budget. +type CreateTrafficBudgetRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Name string `json:"name"` + Mode string `json:"mode"` + + Capacity *int `json:"capacity,omitempty"` + Rate *int `json:"rate,omitempty"` + Burst *int `json:"burst,omitempty"` + Concurrency *int `json:"concurrency,omitempty"` + WarningThreshold *int `json:"warning_threshold,omitempty"` + + Rules *[]CreateTrafficBudgetRuleRequest `json:"rules,omitempty"` +} + +// UpdateTrafficBudgetRequest is the request for updating a traffic budget. +type UpdateTrafficBudgetRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + BudgetID string `json:"-"` + + Name *string `json:"name,omitempty"` + Mode *string `json:"mode,omitempty"` + + Capacity *int `json:"capacity,omitempty"` + Rate *int `json:"rate,omitempty"` + Burst *int `json:"burst,omitempty"` + Concurrency *int `json:"concurrency,omitempty"` + WarningThreshold *int `json:"warning_threshold,omitempty"` + + Rules *[]CreateTrafficBudgetRuleRequest `json:"rules,omitempty"` +} + +// DeleteTrafficBudgetRequest is the request for deleting a traffic budget. +type DeleteTrafficBudgetRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + BudgetID string `json:"-"` +} + +type trafficBudgetsService struct { + client *Client +} + +// NewTrafficBudgetsService returns a TrafficBudgetsService backed by client. +func NewTrafficBudgetsService(client *Client) *trafficBudgetsService { + return &trafficBudgetsService{client: client} +} + +func (s *trafficBudgetsService) List(ctx context.Context, listReq *ListTrafficBudgetsRequest) ([]*TrafficBudget, error) { + req, err := s.client.newRequest(http.MethodGet, trafficBudgetsAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) + if err != nil { + return nil, err + } + + resp := &trafficBudgetsResponse{} + if err := s.client.do(ctx, req, resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (s *trafficBudgetsService) Get(ctx context.Context, getReq *GetTrafficBudgetRequest) (*TrafficBudget, error) { + req, err := s.client.newRequest(http.MethodGet, trafficBudgetAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.BudgetID), nil) + if err != nil { + return nil, err + } + + budget := &TrafficBudget{} + if err := s.client.do(ctx, req, budget); err != nil { + return nil, err + } + + return budget, nil +} + +func (s *trafficBudgetsService) Create(ctx context.Context, createReq *CreateTrafficBudgetRequest) (*TrafficBudget, error) { + req, err := s.client.newRequest(http.MethodPost, trafficBudgetsAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) + if err != nil { + return nil, err + } + + budget := &TrafficBudget{} + if err := s.client.do(ctx, req, budget); err != nil { + return nil, err + } + + return budget, nil +} + +func (s *trafficBudgetsService) Update(ctx context.Context, updateReq *UpdateTrafficBudgetRequest) (*TrafficBudget, error) { + req, err := s.client.newRequest(http.MethodPatch, trafficBudgetAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.BudgetID), updateReq) + if err != nil { + return nil, err + } + + budget := &TrafficBudget{} + if err := s.client.do(ctx, req, budget); err != nil { + return nil, err + } + + return budget, nil +} + +func (s *trafficBudgetsService) Delete(ctx context.Context, deleteReq *DeleteTrafficBudgetRequest) error { + req, err := s.client.newRequest(http.MethodDelete, trafficBudgetAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.BudgetID), nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +func trafficBudgetsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "traffic", "budgets") +} + +func trafficBudgetAPIPath(org, db, branch, budgetID string) string { + return path.Join(trafficBudgetsAPIPath(org, db, branch), budgetID) +} diff --git a/internal/planetscale/traffic_budgets_test.go b/internal/planetscale/traffic_budgets_test.go new file mode 100644 index 000000000..d32c1ae56 --- /dev/null +++ b/internal/planetscale/traffic_budgets_test.go @@ -0,0 +1,434 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestTrafficBudgets_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets") + + w.WriteHeader(200) + out := `{"data":[ + { + "id":"qok87ki4xlau", + "type":"TrafficBudget", + "name":"my-budget", + "mode":"warn", + "capacity":0, + "rate":1, + "burst":null, + "concurrency":null, + "created_at":"2026-03-20T15:18:08.540Z", + "updated_at":"2026-03-20T15:21:36.068Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"}, + "rules":[ + { + "id":"ixrnbyuznjza", + "type":"TrafficRule", + "kind":"match", + "tags":[ + {"type":"TrafficRuleTag","key_id":"Bremote_address","key":"remote_address","value":"10.0.0.8/10","source":"system"} + ], + "fingerprint":null, + "keyspace":null, + "created_at":"2026-03-20T16:05:58.438Z", + "updated_at":"2026-03-20T16:05:58.438Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"} + } + ] + }, + { + "id":"0h41b131ivqd", + "type":"TrafficBudget", + "name":"IP Range", + "mode":"warn", + "capacity":200, + "rate":50, + "burst":100, + "concurrency":100, + "created_at":"2026-03-20T15:58:23.153Z", + "updated_at":"2026-03-20T15:58:23.153Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"}, + "rules":[ + { + "id":"96j6gkf2sbv8", + "type":"TrafficRule", + "kind":"match", + "tags":[ + {"type":"TrafficRuleTag","key_id":"Bremote_address","key":"remote_address","value":"10.0.0.0/8","source":"system"} + ], + "fingerprint":null, + "keyspace":null, + "created_at":"2026-03-20T15:58:23.177Z", + "updated_at":"2026-03-20T15:58:23.177Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"} + } + ] + } + ]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + budgets, err := client.TrafficBudgets.List(ctx, &ListTrafficBudgetsRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + }) + + c.Assert(err, qt.IsNil) + c.Assert(budgets, qt.HasLen, 2) + + actor := Actor{ID: "v1bxjxtt9c13", Type: "User", Name: "Alice"} + + want0 := &TrafficBudget{ + ID: "qok87ki4xlau", + Type: "TrafficBudget", + Name: "my-budget", + Mode: "warn", + Capacity: new(0), + Rate: new(1), + Actor: actor, + Rules: []TrafficRule{ + { + ID: "ixrnbyuznjza", + Type: "TrafficRule", + Kind: "match", + Tags: []TrafficRuleTag{ + {Type: "TrafficRuleTag", KeyID: "Bremote_address", Key: "remote_address", Value: "10.0.0.8/10", Source: "system"}, + }, + Actor: actor, + CreatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 438000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 438000000, time.UTC), + }, + }, + CreatedAt: time.Date(2026, time.March, 20, 15, 18, 8, 540000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 15, 21, 36, 68000000, time.UTC), + } + + want1 := &TrafficBudget{ + ID: "0h41b131ivqd", + Type: "TrafficBudget", + Name: "IP Range", + Mode: "warn", + Capacity: new(200), + Rate: new(50), + Burst: new(100), + Concurrency: new(100), + Actor: actor, + Rules: []TrafficRule{ + { + ID: "96j6gkf2sbv8", + Type: "TrafficRule", + Kind: "match", + Tags: []TrafficRuleTag{ + {Type: "TrafficRuleTag", KeyID: "Bremote_address", Key: "remote_address", Value: "10.0.0.0/8", Source: "system"}, + }, + Actor: actor, + CreatedAt: time.Date(2026, time.March, 20, 15, 58, 23, 177000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 15, 58, 23, 177000000, time.UTC), + }, + }, + CreatedAt: time.Date(2026, time.March, 20, 15, 58, 23, 153000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 15, 58, 23, 153000000, time.UTC), + } + + c.Assert(budgets[0], qt.DeepEquals, want0) + c.Assert(budgets[1], qt.DeepEquals, want1) +} + +func TestTrafficBudgets_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets/qok87ki4xlau") + + w.WriteHeader(200) + out := `{ + "id":"qok87ki4xlau", + "type":"TrafficBudget", + "name":"my-budget", + "mode":"warn", + "capacity":0, + "rate":1, + "burst":null, + "concurrency":null, + "created_at":"2026-03-20T15:18:08.540Z", + "updated_at":"2026-03-20T15:21:36.068Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"}, + "rules":[ + { + "id":"ixrnbyuznjza", + "type":"TrafficRule", + "kind":"match", + "tags":[ + {"type":"TrafficRuleTag","key_id":"Bremote_address","key":"remote_address","value":"10.0.0.8/10","source":"system"}, + {"type":"TrafficRuleTag","key_id":"Squery","key":"query","value":"a_query","source":"sql"} + ], + "fingerprint":null, + "keyspace":null, + "created_at":"2026-03-20T16:05:58.438Z", + "updated_at":"2026-03-20T16:05:58.438Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"} + } + ] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + budget, err := client.TrafficBudgets.Get(ctx, &GetTrafficBudgetRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + BudgetID: "qok87ki4xlau", + }) + + actor := Actor{ID: "v1bxjxtt9c13", Type: "User", Name: "Alice"} + + want := &TrafficBudget{ + ID: "qok87ki4xlau", + Type: "TrafficBudget", + Name: "my-budget", + Mode: "warn", + Capacity: new(0), + Rate: new(1), + Actor: actor, + Rules: []TrafficRule{ + { + ID: "ixrnbyuznjza", + Type: "TrafficRule", + Kind: "match", + Tags: []TrafficRuleTag{ + {Type: "TrafficRuleTag", KeyID: "Bremote_address", Key: "remote_address", Value: "10.0.0.8/10", Source: "system"}, + {Type: "TrafficRuleTag", KeyID: "Squery", Key: "query", Value: "a_query", Source: "sql"}, + }, + Actor: actor, + CreatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 438000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 438000000, time.UTC), + }, + }, + CreatedAt: time.Date(2026, time.March, 20, 15, 18, 8, 540000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 15, 21, 36, 68000000, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(budget, qt.DeepEquals, want) +} + +func TestTrafficBudgets_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets") + + w.WriteHeader(200) + out := `{ + "id":"3ohd8d28icus", + "type":"TrafficBudget", + "name":"query", + "mode":"warn", + "capacity":200, + "rate":50, + "burst":100, + "concurrency":100, + "created_at":"2026-03-20T16:05:06.866Z", + "updated_at":"2026-03-20T16:05:06.866Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"}, + "rules":[ + { + "id":"rv8lbt2t02sz", + "type":"TrafficRule", + "kind":"match", + "tags":[ + {"type":"TrafficRuleTag","key_id":"Bremote_address","key":"remote_address","value":"192.168.1.1","source":"system"}, + {"type":"TrafficRuleTag","key_id":"Squery","key":"query","value":"a_query","source":"sql"} + ], + "fingerprint":null, + "keyspace":null, + "created_at":"2026-03-20T16:05:06.894Z", + "updated_at":"2026-03-20T16:05:06.894Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"} + } + ] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + budget, err := client.TrafficBudgets.Create(ctx, &CreateTrafficBudgetRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + Name: "query", + Mode: "warn", + Capacity: new(200), + Rate: new(50), + Burst: new(100), + Concurrency: new(100), + Rules: &[]CreateTrafficBudgetRuleRequest{ + { + Kind: "match", + Tags: &[]TrafficRuleTag{ + {Key: "remote_address", Value: "192.168.1.1", Source: "system"}, + {Key: "query", Value: "a_query", Source: "sql"}, + }, + }, + }, + }) + + actor := Actor{ID: "v1bxjxtt9c13", Type: "User", Name: "Alice"} + + want := &TrafficBudget{ + ID: "3ohd8d28icus", + Type: "TrafficBudget", + Name: "query", + Mode: "warn", + Capacity: new(200), + Rate: new(50), + Burst: new(100), + Concurrency: new(100), + Actor: actor, + Rules: []TrafficRule{ + { + ID: "rv8lbt2t02sz", + Type: "TrafficRule", + Kind: "match", + Tags: []TrafficRuleTag{ + {Type: "TrafficRuleTag", KeyID: "Bremote_address", Key: "remote_address", Value: "192.168.1.1", Source: "system"}, + {Type: "TrafficRuleTag", KeyID: "Squery", Key: "query", Value: "a_query", Source: "sql"}, + }, + Actor: actor, + CreatedAt: time.Date(2026, time.March, 20, 16, 5, 6, 894000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 16, 5, 6, 894000000, time.UTC), + }, + }, + CreatedAt: time.Date(2026, time.March, 20, 16, 5, 6, 866000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 16, 5, 6, 866000000, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(budget, qt.DeepEquals, want) +} + +func TestTrafficBudgets_Update(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets/qok87ki4xlau") + + w.WriteHeader(200) + out := `{ + "id":"qok87ki4xlau", + "type":"TrafficBudget", + "name":"my-budget updated", + "mode":"enforce", + "capacity":500, + "rate":100, + "burst":200, + "concurrency":50, + "created_at":"2026-03-20T15:18:08.540Z", + "updated_at":"2026-03-20T17:30:00.000Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"}, + "rules":[] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + name := "my-budget updated" + mode := "enforce" + capacity := 500 + rate := 100 + burst := 200 + concurrency := 50 + + budget, err := client.TrafficBudgets.Update(ctx, &UpdateTrafficBudgetRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + BudgetID: "qok87ki4xlau", + Name: &name, + Mode: &mode, + Capacity: &capacity, + Rate: &rate, + Burst: &burst, + Concurrency: &concurrency, + }) + + want := &TrafficBudget{ + ID: "qok87ki4xlau", + Type: "TrafficBudget", + Name: "my-budget updated", + Mode: "enforce", + Capacity: new(500), + Rate: new(100), + Burst: new(200), + Concurrency: new(50), + Actor: Actor{ID: "v1bxjxtt9c13", Type: "User", Name: "Alice"}, + Rules: []TrafficRule{}, + CreatedAt: time.Date(2026, time.March, 20, 15, 18, 8, 540000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 17, 30, 0, 0, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(budget, qt.DeepEquals, want) +} + +func TestTrafficBudgets_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets/qok87ki4xlau") + + w.WriteHeader(http.StatusNoContent) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.TrafficBudgets.Delete(ctx, &DeleteTrafficBudgetRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + BudgetID: "qok87ki4xlau", + }) + + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/traffic_rules.go b/internal/planetscale/traffic_rules.go new file mode 100644 index 000000000..41d946f3e --- /dev/null +++ b/internal/planetscale/traffic_rules.go @@ -0,0 +1,76 @@ +package planetscale + +import ( + "context" + "net/http" + "path" +) + +var _ TrafficRulesService = &trafficRulesService{} + +// TrafficRulesService communicates with the PlanetScale traffic rules API. +type TrafficRulesService interface { + Create(context.Context, *CreateTrafficRuleRequest) (*TrafficRule, error) + Delete(context.Context, *DeleteTrafficRuleRequest) error +} + +// CreateTrafficRuleRequest is the request for creating a traffic rule on a budget. +type CreateTrafficRuleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + BudgetID string `json:"-"` + Kind string `json:"kind"` + Fingerprint *string `json:"fingerprint,omitempty"` + Keyspace *string `json:"keyspace,omitempty"` + Tags *[]TrafficRuleTag `json:"tags,omitempty"` +} + +// DeleteTrafficRuleRequest is the request for deleting a traffic rule. +type DeleteTrafficRuleRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + BudgetID string `json:"-"` + RuleID string `json:"-"` +} + +type trafficRulesService struct { + client *Client +} + +// NewTrafficRulesService returns a TrafficRulesService backed by client. +func NewTrafficRulesService(client *Client) *trafficRulesService { + return &trafficRulesService{client: client} +} + +func (s *trafficRulesService) Create(ctx context.Context, createReq *CreateTrafficRuleRequest) (*TrafficRule, error) { + req, err := s.client.newRequest(http.MethodPost, trafficBudgetRulesAPIPath(createReq.Organization, createReq.Database, createReq.Branch, createReq.BudgetID), createReq) + if err != nil { + return nil, err + } + + rule := &TrafficRule{} + if err := s.client.do(ctx, req, rule); err != nil { + return nil, err + } + + return rule, nil +} + +func (s *trafficRulesService) Delete(ctx context.Context, deleteReq *DeleteTrafficRuleRequest) error { + req, err := s.client.newRequest(http.MethodDelete, trafficBudgetRuleAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.BudgetID, deleteReq.RuleID), nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +func trafficBudgetRulesAPIPath(org, db, branch, budgetID string) string { + return path.Join(trafficBudgetAPIPath(org, db, branch, budgetID), "rules") +} + +func trafficBudgetRuleAPIPath(org, db, branch, budgetID, ruleID string) string { + return path.Join(trafficBudgetRulesAPIPath(org, db, branch, budgetID), ruleID) +} diff --git a/internal/planetscale/traffic_rules_test.go b/internal/planetscale/traffic_rules_test.go new file mode 100644 index 000000000..491c5f03b --- /dev/null +++ b/internal/planetscale/traffic_rules_test.go @@ -0,0 +1,94 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestTrafficRules_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets/qok87ki4xlau/rules") + + w.WriteHeader(200) + out := `{ + "id":"j1d8ecc2su4e", + "type":"TrafficRule", + "kind":"match", + "tags":[ + {"type":"TrafficRuleTag","key_id":"Squery","key":"query","value":"a_query","source":"sql"} + ], + "fingerprint":null, + "keyspace":null, + "created_at":"2026-03-20T16:05:58.461Z", + "updated_at":"2026-03-20T16:05:58.461Z", + "actor":{"id":"v1bxjxtt9c13","type":"User","display_name":"Alice"} + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + rule, err := client.TrafficRules.Create(ctx, &CreateTrafficRuleRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + BudgetID: "qok87ki4xlau", + Kind: "match", + Tags: &[]TrafficRuleTag{ + {Key: "query", Value: "a_query", Source: "sql"}, + }, + }) + + want := &TrafficRule{ + ID: "j1d8ecc2su4e", + Type: "TrafficRule", + Kind: "match", + Tags: []TrafficRuleTag{ + {Type: "TrafficRuleTag", KeyID: "Squery", Key: "query", Value: "a_query", Source: "sql"}, + }, + Actor: Actor{ID: "v1bxjxtt9c13", Type: "User", Name: "Alice"}, + CreatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 461000000, time.UTC), + UpdatedAt: time.Date(2026, time.March, 20, 16, 5, 58, 461000000, time.UTC), + } + + c.Assert(err, qt.IsNil) + c.Assert(rule, qt.DeepEquals, want) +} + +func TestTrafficRules_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/planetscale-go-test-db-branch/traffic/budgets/qok87ki4xlau/rules/j1d8ecc2su4e") + + w.WriteHeader(http.StatusNoContent) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.TrafficRules.Delete(ctx, &DeleteTrafficRuleRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: testBranch, + BudgetID: "qok87ki4xlau", + RuleID: "j1d8ecc2su4e", + }) + + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/vtctld.go b/internal/planetscale/vtctld.go new file mode 100644 index 000000000..41d2053d2 --- /dev/null +++ b/internal/planetscale/vtctld.go @@ -0,0 +1,11 @@ +package planetscale + +import "encoding/json" + +// vtctldDataResponse is the shared response envelope for all vtctld API responses. +// The Data field contains the raw JSON response from the vtctld command. +// +//lint:ignore U1000 used by service files in dependent PRs +type vtctldDataResponse struct { + Data json.RawMessage `json:"data"` +} diff --git a/internal/planetscale/vtctld_general.go b/internal/planetscale/vtctld_general.go new file mode 100644 index 000000000..5ea3ecfec --- /dev/null +++ b/internal/planetscale/vtctld_general.go @@ -0,0 +1,378 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strconv" +) + +// VtctldService is an interface for interacting with the general vtctld endpoints of the +// PlanetScale API. +type VtctldService interface { + ListWorkflows(context.Context, *VtctldListWorkflowsRequest) (json.RawMessage, error) + ListKeyspaces(context.Context, *VtctldListKeyspacesRequest) (json.RawMessage, error) + GetRoutingRules(context.Context, *VtctldGetRoutingRulesRequest) (json.RawMessage, error) + GetShard(context.Context, *VtctldGetShardRequest) (json.RawMessage, error) + SetShardTabletControl(context.Context, *VtctldSetShardTabletControlRequest) (json.RawMessage, error) + RefreshStateByShard(context.Context, *VtctldRefreshStateByShardRequest) (json.RawMessage, error) + ListTablets(context.Context, *ListBranchTabletsRequest) ([]*TabletGroup, error) + StartWorkflow(context.Context, *VtctldStartWorkflowRequest) (json.RawMessage, error) + StopWorkflow(context.Context, *VtctldStopWorkflowRequest) (json.RawMessage, error) + GetThrottlerStatus(context.Context, *VtctldGetThrottlerStatusRequest) (json.RawMessage, error) + CheckThrottler(context.Context, *VtctldCheckThrottlerRequest) (json.RawMessage, error) + UpdateThrottlerConfig(context.Context, *VtctldUpdateThrottlerConfigRequest) (json.RawMessage, error) + GetOperation(context.Context, *GetVtctldOperationRequest) (*VtctldOperation, error) +} + +type VtctldListWorkflowsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + Workflow string `json:"-"` + IncludeLogs *bool `json:"-"` +} + +type VtctldListKeyspacesRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` +} + +// VtctldGetRoutingRulesRequest is a request for reading live routing rules +// from the cluster via vtctld. +type VtctldGetRoutingRulesRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` +} + +// VtctldGetShardRequest is a request for reading a shard record from the +// cluster via vtctld. +type VtctldGetShardRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + Shard string `json:"-"` +} + +// VtctldSetShardTabletControlRequest is a request for updating shard tablet +// controls via vtctld. +type VtctldSetShardTabletControlRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + TabletType string `json:"tablet_type"` + Cells []string `json:"cells,omitempty"` + DeniedTables []string `json:"denied_tables,omitempty"` + Remove *bool `json:"remove,omitempty"` + DisableQueryService *bool `json:"disable_query_service,omitempty"` +} + +// VtctldRefreshStateByShardRequest reloads tablet records for all tablets in +// a shard via vtctld. +type VtctldRefreshStateByShardRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + Cells []string `json:"cells,omitempty"` +} + +// VtctldStartWorkflowRequest is a request for starting a workflow. +type VtctldStartWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + Keyspace string `json:"keyspace"` +} + +// VtctldStopWorkflowRequest is a request for stopping a workflow. +type VtctldStopWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + Keyspace string `json:"keyspace"` +} + +// VtctldGetThrottlerStatusRequest is a request for reading the tablet throttler +// status from a single tablet. The tablet is identified by its alias, as +// discovered via ListTablets. +type VtctldGetThrottlerStatusRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + TabletAlias string `json:"-"` +} + +// VtctldCheckThrottlerRequest is a request for issuing a throttler check against +// a single tablet. The tablet is identified by its alias, as discovered via +// ListTablets. +type VtctldCheckThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + TabletAlias string `json:"tablet_alias"` + // AppName is the app to issue the check on behalf of (e.g. "online-ddl"). If + // empty, the throttler's default app is used. + AppName string `json:"app_name,omitempty"` + // Scope is the scope of the check, either "shard" or "self". If empty, the + // throttler's default scope is used. + Scope string `json:"scope,omitempty"` + // SkipRequestHeartbeats instructs the throttler not to renew its heartbeat + // lease while serving this check. + SkipRequestHeartbeats *bool `json:"skip_request_heartbeats,omitempty"` + // OkIfNotExists instructs the throttler to return OK even if the requested + // metric does not exist. + OkIfNotExists *bool `json:"ok_if_not_exists,omitempty"` +} + +// VtctldThrottledAppConfig configures a single throttled app rule for +// UpdateThrottlerConfig. +type VtctldThrottledAppConfig struct { + // Name is the app to throttle (e.g. "online-ddl", "vreplication", or an + // Online DDL migration UUID). + Name string `json:"name"` + // Ratio is the fraction of operations to throttle for the app, 0.00-1.00 in + // increments of 0.01. + Ratio *float64 `json:"ratio,omitempty"` + // ExpireAt is an optional RFC3339 expiration time for the app rule. It must + // be in the future. Omit to throttle until changed. + ExpireAt string `json:"expire_at,omitempty"` +} + +// VtctldUpdateThrottlerConfigRequest is a request for updating the tablet +// throttler configuration for a keyspace. +type VtctldUpdateThrottlerConfigRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Keyspace string `json:"keyspace"` + // Enabled controls whether the tablet throttler is enabled for the keyspace. + // It is required: the API has no tri-state, so omitting it would disable the + // throttler. + Enabled bool `json:"enabled"` + // Threshold is the threshold for the default throttler check (replication lag + // in seconds). It must be >= 0; the server defaults to 5.0 when omitted. + Threshold *float64 `json:"threshold,omitempty"` + // Apps configures zero or more per-app throttling rules. + Apps []VtctldThrottledAppConfig `json:"apps,omitempty"` +} + +type vtctldService struct { + client *Client +} + +var _ VtctldService = &vtctldService{} + +func vtctldWorkflowsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "workflows") +} + +func vtctldKeyspacesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "keyspaces") +} + +func vtctldRoutingRulesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "routing-rules") +} + +func vtctldShardAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "shard") +} + +func vtctldShardTabletControlAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "shard", "tablet-control") +} + +func vtctldShardRefreshStateAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "shard", "refresh-state") +} + +func (s *vtctldService) ListWorkflows(ctx context.Context, req *VtctldListWorkflowsRequest) (json.RawMessage, error) { + p := vtctldWorkflowsAPIPath(req.Organization, req.Database, req.Branch) + v := url.Values{} + v.Set("keyspace", req.Keyspace) + if req.Workflow != "" { + v.Set("workflow", req.Workflow) + } + if req.IncludeLogs != nil { + v.Set("include_logs", strconv.FormatBool(*req.IncludeLogs)) + } + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vtctldService) ListKeyspaces(ctx context.Context, req *VtctldListKeyspacesRequest) (json.RawMessage, error) { + p := vtctldKeyspacesAPIPath(req.Organization, req.Database, req.Branch) + v := url.Values{} + if req.Name != "" { + v.Set("name", req.Name) + } + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vtctldService) GetRoutingRules(ctx context.Context, req *VtctldGetRoutingRulesRequest) (json.RawMessage, error) { + p := vtctldRoutingRulesAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vtctldService) GetShard(ctx context.Context, req *VtctldGetShardRequest) (json.RawMessage, error) { + p := vtctldShardAPIPath(req.Organization, req.Database, req.Branch) + v := url.Values{} + v.Set("keyspace", req.Keyspace) + v.Set("shard", req.Shard) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func vtctldWorkflowAPIPath(org, db, branch, workflow string) string { + return path.Join(vtctldWorkflowsAPIPath(org, db, branch), workflow) +} + +func (s *vtctldService) StartWorkflow(ctx context.Context, req *VtctldStartWorkflowRequest) (json.RawMessage, error) { + p := path.Join(vtctldWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "start") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vtctldService) StopWorkflow(ctx context.Context, req *VtctldStopWorkflowRequest) (json.RawMessage, error) { + p := path.Join(vtctldWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "stop") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func vtctldThrottlerAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "throttler") +} + +// GetThrottlerStatus reads the tablet throttler status from a single tablet. +func (s *vtctldService) GetThrottlerStatus(ctx context.Context, req *VtctldGetThrottlerStatusRequest) (json.RawMessage, error) { + p := path.Join(vtctldThrottlerAPIPath(req.Organization, req.Database, req.Branch), "status") + v := url.Values{} + v.Set("tablet_alias", req.TabletAlias) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// SetShardTabletControl updates tablet controls on a shard via vtctld. +func (s *vtctldService) SetShardTabletControl(ctx context.Context, req *VtctldSetShardTabletControlRequest) (json.RawMessage, error) { + p := vtctldShardTabletControlAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodPut, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// RefreshStateByShard reloads tablet records for all tablets in a shard via vtctld. +func (s *vtctldService) RefreshStateByShard(ctx context.Context, req *VtctldRefreshStateByShardRequest) (json.RawMessage, error) { + p := vtctldShardRefreshStateAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// CheckThrottler issues a throttler check against a single tablet. +func (s *vtctldService) CheckThrottler(ctx context.Context, req *VtctldCheckThrottlerRequest) (json.RawMessage, error) { + p := path.Join(vtctldThrottlerAPIPath(req.Organization, req.Database, req.Branch), "check") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// UpdateThrottlerConfig updates the tablet throttler configuration for a keyspace. +func (s *vtctldService) UpdateThrottlerConfig(ctx context.Context, req *VtctldUpdateThrottlerConfigRequest) (json.RawMessage, error) { + p := path.Join(vtctldThrottlerAPIPath(req.Organization, req.Database, req.Branch), "config") + httpReq, err := s.client.newRequest(http.MethodPut, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} diff --git a/internal/planetscale/vtctld_general_test.go b/internal/planetscale/vtctld_general_test.go new file mode 100644 index 000000000..bfc2285e0 --- /dev/null +++ b/internal/planetscale/vtctld_general_test.go @@ -0,0 +1,568 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestVtctld_ListWorkflows(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/workflows") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "my-keyspace") + c.Assert(r.URL.Query().Get("workflow"), qt.Equals, "my-workflow") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.ListWorkflows(ctx, &VtctldListWorkflowsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "my-keyspace", + Workflow: "my-workflow", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVtctld_ListWorkflows_NoWorkflowFilter(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/workflows") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "my-keyspace") + c.Assert(r.URL.Query().Get("workflow"), qt.Equals, "") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.ListWorkflows(ctx, &VtctldListWorkflowsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVtctld_ListWorkflows_IncludeLogs(t *testing.T) { + tests := []struct { + name string + includeLogs *bool + expectIncludeParam bool + expectedInclude string + }{ + {name: "include_logs false", includeLogs: boolPtr(false), expectIncludeParam: true, expectedInclude: "false"}, + {name: "include_logs omitted", includeLogs: nil, expectIncludeParam: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/workflows") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "my-keyspace") + + _, hasIncludeLogs := r.URL.Query()["include_logs"] + if tt.expectIncludeParam { + c.Assert(hasIncludeLogs, qt.IsTrue) + c.Assert(r.URL.Query().Get("include_logs"), qt.Equals, tt.expectedInclude) + } else { + c.Assert(hasIncludeLogs, qt.IsFalse) + } + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.ListWorkflows(ctx, &VtctldListWorkflowsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "my-keyspace", + IncludeLogs: tt.includeLogs, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) + }) + } +} + +func TestVtctld_GetRoutingRules(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/routing-rules") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"rules":[]}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.GetRoutingRules(ctx, &VtctldGetRoutingRulesRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"rules":[]}`) +} + +func TestVtctld_GetShard(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/shard") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "commerce") + c.Assert(r.URL.Query().Get("shard"), qt.Equals, "-") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"keyspace":"commerce","name":"-"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.GetShard(ctx, &VtctldGetShardRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Shard: "-", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"keyspace":"commerce","name":"-"}`) +} + +func TestVtctld_SetShardTabletControl(t *testing.T) { + c := qt.New(t) + + remove := true + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPut) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/shard/tablet-control") + + var body VtctldSetShardTabletControlRequest + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body.Keyspace, qt.Equals, "commerce") + c.Assert(body.Shard, qt.Equals, "-") + c.Assert(body.TabletType, qt.Equals, "rdonly") + c.Assert(body.DeniedTables, qt.DeepEquals, []string{"customers"}) + c.Assert(body.Remove, qt.Not(qt.IsNil)) + c.Assert(*body.Remove, qt.Equals, true) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.SetShardTabletControl(ctx, &VtctldSetShardTabletControlRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Shard: "-", + TabletType: "rdonly", + DeniedTables: []string{"customers"}, + Remove: &remove, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{}`) +} + +func TestVtctld_RefreshStateByShard(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/shard/refresh-state") + + var body VtctldRefreshStateByShardRequest + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body.Keyspace, qt.Equals, "commerce") + c.Assert(body.Shard, qt.Equals, "-") + c.Assert(body.Cells, qt.DeepEquals, []string{"zone1"}) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.RefreshStateByShard(ctx, &VtctldRefreshStateByShardRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Shard: "-", + Cells: []string{"zone1"}, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{}`) +} + +func TestVtctld_ListKeyspaces(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/keyspaces") + c.Assert(r.URL.Query().Get("name"), qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.ListKeyspaces(ctx, &VtctldListKeyspacesRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVtctld_StartWorkflow(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/workflows/my-workflow/start") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["keyspace"], qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"summary":"started"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.StartWorkflow(ctx, &VtctldStartWorkflowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + Keyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"summary":"started"}`) +} + +func TestVtctld_StopWorkflow(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/workflows/my-workflow/stop") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["keyspace"], qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"summary":"stopped"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.StopWorkflow(ctx, &VtctldStopWorkflowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + Keyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"summary":"stopped"}`) +} + +func TestVtctld_ListKeyspaces_NoNameFilter(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/keyspaces") + c.Assert(r.URL.Query().Get("name"), qt.Equals, "") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.ListKeyspaces(ctx, &VtctldListKeyspacesRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func float64Ptr(v float64) *float64 { + return &v +} + +func TestVtctld_GetThrottlerStatus(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/throttler/status") + c.Assert(r.URL.Query().Get("tablet_alias"), qt.Equals, "zone1-0000000100") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"keyspace":"commerce","enabled":true}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.GetThrottlerStatus(ctx, &VtctldGetThrottlerStatusRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + TabletAlias: "zone1-0000000100", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"keyspace":"commerce","enabled":true}`) +} + +func TestVtctld_CheckThrottler(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/throttler/check") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["tablet_alias"], qt.Equals, "zone1-0000000100") + c.Assert(body["app_name"], qt.Equals, "online-ddl") + c.Assert(body["scope"], qt.Equals, "self") + c.Assert(body["skip_request_heartbeats"], qt.Equals, true) + c.Assert(body["ok_if_not_exists"], qt.Equals, true) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"response_code":"THROTTLER_RESPONSE_CODE_OK"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Vtctld.CheckThrottler(ctx, &VtctldCheckThrottlerRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + TabletAlias: "zone1-0000000100", + AppName: "online-ddl", + Scope: "self", + SkipRequestHeartbeats: boolPtr(true), + OkIfNotExists: boolPtr(true), + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"response_code":"THROTTLER_RESPONSE_CODE_OK"}`) +} + +func TestVtctld_CheckThrottler_MinimalBody(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["tablet_alias"], qt.Equals, "zone1-0000000100") + + // Optional fields are omitted entirely when unset. + _, hasAppName := body["app_name"] + c.Assert(hasAppName, qt.IsFalse) + _, hasSkip := body["skip_request_heartbeats"] + c.Assert(hasSkip, qt.IsFalse) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"response_code":"THROTTLER_RESPONSE_CODE_OK"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.CheckThrottler(ctx, &VtctldCheckThrottlerRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + TabletAlias: "zone1-0000000100", + }) + c.Assert(err, qt.IsNil) +} + +func TestVtctld_UpdateThrottlerConfig(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPut) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/throttler/config") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["keyspace"], qt.Equals, "commerce") + c.Assert(body["enabled"], qt.Equals, true) + c.Assert(body["threshold"], qt.Equals, float64(2.5)) + + apps, ok := body["apps"].([]interface{}) + c.Assert(ok, qt.IsTrue) + c.Assert(len(apps), qt.Equals, 1) + app := apps[0].(map[string]interface{}) + c.Assert(app["name"], qt.Equals, "online-ddl") + c.Assert(app["ratio"], qt.Equals, float64(0.5)) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.UpdateThrottlerConfig(ctx, &VtctldUpdateThrottlerConfigRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Enabled: true, + Threshold: float64Ptr(2.5), + Apps: []VtctldThrottledAppConfig{ + {Name: "online-ddl", Ratio: float64Ptr(0.5)}, + }, + }) + c.Assert(err, qt.IsNil) +} + +func TestVtctld_UpdateThrottlerConfig_DisableSendsEnabledFalse(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + // enabled is a plain bool, so it is always present in the body, even + // when false. The server has no tri-state, so this must be explicit. + _, hasEnabled := body["enabled"] + c.Assert(hasEnabled, qt.IsTrue) + c.Assert(body["enabled"], qt.Equals, false) + + // Optional threshold/apps are omitted when unset. + _, hasThreshold := body["threshold"] + c.Assert(hasThreshold, qt.IsFalse) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.UpdateThrottlerConfig(ctx, &VtctldUpdateThrottlerConfigRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Enabled: false, + }) + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/vtctld_lookup_vindex.go b/internal/planetscale/vtctld_lookup_vindex.go new file mode 100644 index 000000000..8d95a6d39 --- /dev/null +++ b/internal/planetscale/vtctld_lookup_vindex.go @@ -0,0 +1,179 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" +) + +// LookupVindexService is an interface for interacting with the LookupVindex endpoints of the +// PlanetScale API. +type LookupVindexService interface { + Create(context.Context, *LookupVindexCreateRequest) (json.RawMessage, error) + Show(context.Context, *LookupVindexShowRequest) (json.RawMessage, error) + Externalize(context.Context, *LookupVindexExternalizeRequest) (json.RawMessage, error) + Internalize(context.Context, *LookupVindexInternalizeRequest) (json.RawMessage, error) + Cancel(context.Context, *LookupVindexCancelRequest) (json.RawMessage, error) + Complete(context.Context, *LookupVindexCompleteRequest) (json.RawMessage, error) +} + +type LookupVindexCreateRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Name string `json:"name"` + TableKeyspace string `json:"table_keyspace"` + Keyspace string `json:"keyspace,omitempty"` + TableOwner string `json:"table_owner,omitempty"` + TableName string `json:"table_name,omitempty"` + Type string `json:"type,omitempty"` + Cells []string `json:"cells,omitempty"` + TabletTypes []string `json:"tablet_types,omitempty"` + TabletTypesInPreferenceOrder *bool `json:"tablet_types_in_preference_order,omitempty"` + IgnoreNulls *bool `json:"ignore_nulls,omitempty"` + TableOwnerColumns []string `json:"table_owner_columns,omitempty"` + TableVindexType string `json:"table_vindex_type,omitempty"` + ContinueAfterCopyWithOwner *bool `json:"continue_after_copy_with_owner,omitempty"` +} + +type LookupVindexShowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` + TableKeyspace string `json:"-"` +} + +type LookupVindexExternalizeRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` + TableKeyspace string `json:"table_keyspace"` + Keyspace string `json:"keyspace,omitempty"` + Delete *bool `json:"delete,omitempty"` +} + +type LookupVindexInternalizeRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` + TableKeyspace string `json:"table_keyspace"` + Keyspace string `json:"keyspace,omitempty"` +} + +type LookupVindexCancelRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` + TableKeyspace string `json:"table_keyspace"` +} + +type LookupVindexCompleteRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"-"` + TableKeyspace string `json:"table_keyspace"` + Keyspace string `json:"keyspace,omitempty"` +} + +type lookupVindexService struct { + client *Client +} + +var _ LookupVindexService = &lookupVindexService{} + +func lookupVindexVindexesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "lookup-vindex", "vindexes") +} + +func lookupVindexAPIPath(org, db, branch, name string) string { + return path.Join(lookupVindexVindexesAPIPath(org, db, branch), name) +} + +func (s *lookupVindexService) Create(ctx context.Context, req *LookupVindexCreateRequest) (json.RawMessage, error) { + p := lookupVindexVindexesAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *lookupVindexService) Show(ctx context.Context, req *LookupVindexShowRequest) (json.RawMessage, error) { + p := lookupVindexAPIPath(req.Organization, req.Database, req.Branch, req.Name) + v := url.Values{} + v.Set("table_keyspace", req.TableKeyspace) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *lookupVindexService) Externalize(ctx context.Context, req *LookupVindexExternalizeRequest) (json.RawMessage, error) { + p := path.Join(lookupVindexAPIPath(req.Organization, req.Database, req.Branch, req.Name), "externalize") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *lookupVindexService) Internalize(ctx context.Context, req *LookupVindexInternalizeRequest) (json.RawMessage, error) { + p := path.Join(lookupVindexAPIPath(req.Organization, req.Database, req.Branch, req.Name), "internalize") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *lookupVindexService) Cancel(ctx context.Context, req *LookupVindexCancelRequest) (json.RawMessage, error) { + p := path.Join(lookupVindexAPIPath(req.Organization, req.Database, req.Branch, req.Name), "cancel") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *lookupVindexService) Complete(ctx context.Context, req *LookupVindexCompleteRequest) (json.RawMessage, error) { + p := path.Join(lookupVindexAPIPath(req.Organization, req.Database, req.Branch, req.Name), "complete") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} diff --git a/internal/planetscale/vtctld_lookup_vindex_test.go b/internal/planetscale/vtctld_lookup_vindex_test.go new file mode 100644 index 000000000..585e49c59 --- /dev/null +++ b/internal/planetscale/vtctld_lookup_vindex_test.go @@ -0,0 +1,214 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func boolPtr(v bool) *bool { + return &v +} + +func TestLookupVindex_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["name"], qt.Equals, "my-vindex") + c.Assert(body["table_keyspace"], qt.Equals, "my-keyspace") + c.Assert(body["ignore_nulls"], qt.Equals, false) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Create(ctx, &LookupVindexCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + IgnoreNulls: boolPtr(false), + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestLookupVindex_Show(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes/my-vindex") + c.Assert(r.URL.Query().Get("table_keyspace"), qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Show(ctx, &LookupVindexShowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestLookupVindex_Externalize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes/my-vindex/externalize") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["table_keyspace"], qt.Equals, "my-keyspace") + c.Assert(body["delete"], qt.Equals, false) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Externalize(ctx, &LookupVindexExternalizeRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + Delete: boolPtr(false), + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestLookupVindex_Internalize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes/my-vindex/internalize") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["table_keyspace"], qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Internalize(ctx, &LookupVindexInternalizeRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestLookupVindex_Cancel(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes/my-vindex/cancel") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["table_keyspace"], qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Cancel(ctx, &LookupVindexCancelRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestLookupVindex_Complete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/lookup-vindex/vindexes/my-vindex/complete") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["table_keyspace"], qt.Equals, "my-keyspace") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.LookupVindex.Complete(ctx, &LookupVindexCompleteRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Name: "my-vindex", + TableKeyspace: "my-keyspace", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} diff --git a/internal/planetscale/vtctld_materialize.go b/internal/planetscale/vtctld_materialize.go new file mode 100644 index 000000000..b1f1b2470 --- /dev/null +++ b/internal/planetscale/vtctld_materialize.go @@ -0,0 +1,165 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strconv" +) + +// MaterializeService is an interface for interacting with the Materialize endpoints of the +// PlanetScale API. +type MaterializeService interface { + Create(context.Context, *MaterializeCreateRequest) (json.RawMessage, error) + Show(context.Context, *MaterializeShowRequest) (json.RawMessage, error) + Start(context.Context, *MaterializeStartRequest) (json.RawMessage, error) + Stop(context.Context, *MaterializeStopRequest) (json.RawMessage, error) + Cancel(context.Context, *MaterializeCancelRequest) (json.RawMessage, error) +} + +// MaterializeCreateRequest is a request for creating a Materialize workflow. +type MaterializeCreateRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Workflow string `json:"workflow"` + TargetKeyspace string `json:"target_keyspace"` + SourceKeyspace string `json:"source_keyspace"` + TableSettings json.RawMessage `json:"table_settings"` + Cells []string `json:"cells,omitempty"` + ReferenceTables []string `json:"reference_tables,omitempty"` + TabletTypes []string `json:"tablet_types,omitempty"` + StopAfterCopy *bool `json:"stop_after_copy,omitempty"` + TabletTypesInPreferenceOrder *bool `json:"tablet_types_in_preference_order,omitempty"` + DeferSecondaryKeys *bool `json:"defer_secondary_keys,omitempty"` + AtomicCopy *bool `json:"atomic_copy,omitempty"` + OnDDL string `json:"on_ddl,omitempty"` + SourceTimeZone string `json:"source_time_zone,omitempty"` +} + +// MaterializeShowRequest is a request for showing a Materialize workflow. +type MaterializeShowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"-"` + IncludeLogs *bool `json:"-"` +} + +// MaterializeStartRequest is a request for starting a Materialize workflow. +type MaterializeStartRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` +} + +// MaterializeStopRequest is a request for stopping a Materialize workflow. +type MaterializeStopRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` +} + +// MaterializeCancelRequest is a request for canceling a Materialize workflow. +type MaterializeCancelRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + KeepData *bool `json:"keep_data,omitempty"` + KeepRoutingRules *bool `json:"keep_routing_rules,omitempty"` +} + +type materializeService struct { + client *Client +} + +var _ MaterializeService = &materializeService{} + +func materializeWorkflowsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "materialize", "workflows") +} + +func materializeWorkflowAPIPath(org, db, branch, workflow string) string { + return path.Join(materializeWorkflowsAPIPath(org, db, branch), workflow) +} + +func (s *materializeService) Create(ctx context.Context, req *MaterializeCreateRequest) (json.RawMessage, error) { + p := materializeWorkflowsAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *materializeService) Show(ctx context.Context, req *MaterializeShowRequest) (json.RawMessage, error) { + p := materializeWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow) + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + if req.IncludeLogs != nil { + v.Set("include_logs", strconv.FormatBool(*req.IncludeLogs)) + } + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *materializeService) Start(ctx context.Context, req *MaterializeStartRequest) (json.RawMessage, error) { + p := path.Join(materializeWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "start") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *materializeService) Stop(ctx context.Context, req *MaterializeStopRequest) (json.RawMessage, error) { + p := path.Join(materializeWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "stop") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *materializeService) Cancel(ctx context.Context, req *MaterializeCancelRequest) (json.RawMessage, error) { + p := path.Join(materializeWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "cancel") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} diff --git a/internal/planetscale/vtctld_materialize_test.go b/internal/planetscale/vtctld_materialize_test.go new file mode 100644 index 000000000..275b67f30 --- /dev/null +++ b/internal/planetscale/vtctld_materialize_test.go @@ -0,0 +1,309 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMaterialize_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["workflow"], qt.Equals, "my-workflow") + c.Assert(body["target_keyspace"], qt.Equals, "target") + c.Assert(body["source_keyspace"], qt.Equals, "source") + + _, hasStopAfterCopy := body["stop_after_copy"] + _, hasAtomicCopy := body["atomic_copy"] + _, hasDeferSecondaryKeys := body["defer_secondary_keys"] + _, hasTabletTypesInPreferenceOrder := body["tablet_types_in_preference_order"] + c.Assert(hasStopAfterCopy, qt.IsFalse) + c.Assert(hasAtomicCopy, qt.IsFalse) + c.Assert(hasDeferSecondaryKeys, qt.IsFalse) + c.Assert(hasTabletTypesInPreferenceOrder, qt.IsFalse) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"summary":"created"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Materialize.Create(ctx, &MaterializeCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + TableSettings: json.RawMessage(`[{"target_table":"t1","source_expression":"select * from t1"}]`), + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"summary":"created"}`) +} + +func TestMaterialize_CreateWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + stopAfterCopy, hasStopAfterCopy := body["stop_after_copy"] + atomicCopy, hasAtomicCopy := body["atomic_copy"] + deferSecondaryKeys, hasDeferSecondaryKeys := body["defer_secondary_keys"] + tabletTypesInPreferenceOrder, hasTabletTypesInPreferenceOrder := body["tablet_types_in_preference_order"] + c.Assert(hasStopAfterCopy, qt.IsTrue) + c.Assert(hasAtomicCopy, qt.IsTrue) + c.Assert(hasDeferSecondaryKeys, qt.IsTrue) + c.Assert(hasTabletTypesInPreferenceOrder, qt.IsTrue) + c.Assert(stopAfterCopy, qt.Equals, false) + c.Assert(atomicCopy, qt.Equals, false) + c.Assert(deferSecondaryKeys, qt.Equals, false) + c.Assert(tabletTypesInPreferenceOrder, qt.Equals, false) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"summary":"created"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + data, err := client.Materialize.Create(ctx, &MaterializeCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + TableSettings: json.RawMessage(`[{"target_table":"t1","source_expression":"select * from t1"}]`), + StopAfterCopy: &falseValue, + AtomicCopy: &falseValue, + DeferSecondaryKeys: &falseValue, + TabletTypesInPreferenceOrder: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"summary":"created"}`) +} + +func TestMaterialize_Show(t *testing.T) { + testCases := []struct { + name string + includeLogs *bool + expectIncludeParam bool + expectedInclude string + }{ + {name: "include_logs true", includeLogs: boolPtr(true), expectIncludeParam: true, expectedInclude: "true"}, + {name: "include_logs false", includeLogs: boolPtr(false), expectIncludeParam: true, expectedInclude: "false"}, + {name: "include_logs omitted", includeLogs: nil, expectIncludeParam: false}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows/my-workflow") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + _, hasIncludeLogs := r.URL.Query()["include_logs"] + if tc.expectIncludeParam { + c.Assert(hasIncludeLogs, qt.IsTrue) + c.Assert(r.URL.Query().Get("include_logs"), qt.Equals, tc.expectedInclude) + } else { + c.Assert(hasIncludeLogs, qt.IsFalse) + } + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Materialize.Show(ctx, &MaterializeShowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + IncludeLogs: tc.includeLogs, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) + }) + } +} + +func TestMaterialize_Start(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows/my-workflow/start") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Materialize.Start(ctx, &MaterializeStartRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestMaterialize_Stop(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows/my-workflow/stop") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Materialize.Stop(ctx, &MaterializeStopRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestMaterialize_Cancel(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows/my-workflow/cancel") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + _, hasKeepData := body["keep_data"] + _, hasKeepRoutingRules := body["keep_routing_rules"] + c.Assert(hasKeepData, qt.IsFalse) + c.Assert(hasKeepRoutingRules, qt.IsFalse) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.Materialize.Cancel(ctx, &MaterializeCancelRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestMaterialize_CancelWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/materialize/workflows/my-workflow/cancel") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + keepData, hasKeepData := body["keep_data"] + keepRoutingRules, hasKeepRoutingRules := body["keep_routing_rules"] + c.Assert(hasKeepData, qt.IsTrue) + c.Assert(hasKeepRoutingRules, qt.IsTrue) + c.Assert(keepData, qt.Equals, false) + c.Assert(keepRoutingRules, qt.Equals, false) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + data, err := client.Materialize.Cancel(ctx, &MaterializeCancelRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + KeepData: &falseValue, + KeepRoutingRules: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} diff --git a/internal/planetscale/vtctld_move_tables.go b/internal/planetscale/vtctld_move_tables.go new file mode 100644 index 000000000..a6ac73a90 --- /dev/null +++ b/internal/planetscale/vtctld_move_tables.go @@ -0,0 +1,197 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" +) + +// MoveTablesService is an interface for interacting with the MoveTables endpoints of the +// PlanetScale API. +type MoveTablesService interface { + Create(context.Context, *MoveTablesCreateRequest) (*VtctldOperationReference, error) + Show(context.Context, *MoveTablesShowRequest) (json.RawMessage, error) + Status(context.Context, *MoveTablesStatusRequest) (json.RawMessage, error) + SwitchTraffic(context.Context, *MoveTablesSwitchTrafficRequest) (*VtctldOperationReference, error) + ReverseTraffic(context.Context, *MoveTablesReverseTrafficRequest) (*VtctldOperationReference, error) + Cancel(context.Context, *MoveTablesCancelRequest) (*VtctldOperationReference, error) + Complete(context.Context, *MoveTablesCompleteRequest) (*VtctldOperationReference, error) +} + +// MoveTablesCreateRequest is a request for creating a MoveTables workflow. +type MoveTablesCreateRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + Workflow string `json:"workflow"` + TargetKeyspace string `json:"target_keyspace"` + SourceKeyspace string `json:"source_keyspace"` + Tables []string `json:"tables,omitempty"` + AllTables *bool `json:"all_tables,omitempty"` + AutoStart *bool `json:"auto_start,omitempty"` + StopAfterCopy *bool `json:"stop_after_copy,omitempty"` + DeferSecondaryKeys *bool `json:"defer_secondary_keys,omitempty"` + OnDDL string `json:"on_ddl,omitempty"` + ShardedAutoIncrementHandling string `json:"sharded_auto_increment_handling,omitempty"` + GlobalKeyspace string `json:"global_keyspace,omitempty"` + SourceTimeZone string `json:"source_time_zone,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + Cells []string `json:"cells,omitempty"` + TabletTypes []string `json:"tablet_types,omitempty"` + ExcludeTables []string `json:"exclude_tables,omitempty"` + AtomicCopy *bool `json:"atomic_copy,omitempty"` +} + +// MoveTablesShowRequest is a request for showing a MoveTables workflow. +type MoveTablesShowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"-"` +} + +// MoveTablesStatusRequest is a request for getting the status of a MoveTables workflow. +type MoveTablesStatusRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"-"` +} + +// MoveTablesSwitchTrafficRequest is a request for switching traffic for a MoveTables workflow. +type MoveTablesSwitchTrafficRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + TabletTypes []string `json:"tablet_types,omitempty"` + MaxReplicationLagAllowed *int64 `json:"max_replication_lag_allowed,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` + InitializeTargetSequences *bool `json:"initialize_target_sequences,omitempty"` +} + +// MoveTablesReverseTrafficRequest is a request for reversing traffic for a MoveTables workflow. +type MoveTablesReverseTrafficRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + TabletTypes []string `json:"tablet_types,omitempty"` + MaxReplicationLagAllowed *int64 `json:"max_replication_lag_allowed,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` +} + +// MoveTablesCancelRequest is a request for canceling a MoveTables workflow. +type MoveTablesCancelRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + KeepData *bool `json:"keep_data,omitempty"` + KeepRoutingRules *bool `json:"keep_routing_rules,omitempty"` +} + +// MoveTablesCompleteRequest is a request for completing a MoveTables workflow. +type MoveTablesCompleteRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + KeepData *bool `json:"keep_data,omitempty"` + KeepRoutingRules *bool `json:"keep_routing_rules,omitempty"` + RenameTables *bool `json:"rename_tables,omitempty"` + DryRun *bool `json:"dry_run,omitempty"` +} + +type moveTablesService struct { + client *Client +} + +var _ MoveTablesService = &moveTablesService{} + +func moveTablesWorkflowsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "move-tables", "workflows") +} + +func moveTablesWorkflowAPIPath(org, db, branch, workflow string) string { + return path.Join(moveTablesWorkflowsAPIPath(org, db, branch), workflow) +} + +func (s *moveTablesService) Create(ctx context.Context, req *MoveTablesCreateRequest) (*VtctldOperationReference, error) { + p := moveTablesWorkflowsAPIPath(req.Organization, req.Database, req.Branch) + return s.enqueueOperation(ctx, p, req) +} + +func (s *moveTablesService) Show(ctx context.Context, req *MoveTablesShowRequest) (json.RawMessage, error) { + p := moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow) + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *moveTablesService) Status(ctx context.Context, req *MoveTablesStatusRequest) (json.RawMessage, error) { + p := path.Join(moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "status") + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *moveTablesService) SwitchTraffic(ctx context.Context, req *MoveTablesSwitchTrafficRequest) (*VtctldOperationReference, error) { + p := path.Join(moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "switch-traffic") + return s.enqueueOperation(ctx, p, req) +} + +func (s *moveTablesService) ReverseTraffic(ctx context.Context, req *MoveTablesReverseTrafficRequest) (*VtctldOperationReference, error) { + p := path.Join(moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "reverse-traffic") + return s.enqueueOperation(ctx, p, req) +} + +func (s *moveTablesService) Cancel(ctx context.Context, req *MoveTablesCancelRequest) (*VtctldOperationReference, error) { + p := path.Join(moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "cancel") + return s.enqueueOperation(ctx, p, req) +} + +func (s *moveTablesService) Complete(ctx context.Context, req *MoveTablesCompleteRequest) (*VtctldOperationReference, error) { + p := path.Join(moveTablesWorkflowAPIPath(req.Organization, req.Database, req.Branch, req.Workflow), "complete") + return s.enqueueOperation(ctx, p, req) +} + +func (s *moveTablesService) enqueueOperation(ctx context.Context, p string, payload interface{}) (*VtctldOperationReference, error) { + httpReq, err := s.client.newRequest(http.MethodPost, p, payload) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &VtctldOperationReference{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/planetscale/vtctld_move_tables_test.go b/internal/planetscale/vtctld_move_tables_test.go new file mode 100644 index 000000000..4ac34dd9c --- /dev/null +++ b/internal/planetscale/vtctld_move_tables_test.go @@ -0,0 +1,614 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMoveTables_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["workflow"], qt.Equals, "my-workflow") + c.Assert(body["target_keyspace"], qt.Equals, "target") + c.Assert(body["source_keyspace"], qt.Equals, "source") + + _, hasAllTables := body["all_tables"] + _, hasAutoStart := body["auto_start"] + _, hasStopAfterCopy := body["stop_after_copy"] + _, hasDeferSecondaryKeys := body["defer_secondary_keys"] + _, hasAtomicCopy := body["atomic_copy"] + _, hasCells := body["cells"] + _, hasTabletTypes := body["tablet_types"] + _, hasExcludeTables := body["exclude_tables"] + _, hasTenantID := body["tenant_id"] + _, hasGlobalKeyspace := body["global_keyspace"] + c.Assert(hasAllTables, qt.IsFalse) + c.Assert(hasAutoStart, qt.IsFalse) + c.Assert(hasStopAfterCopy, qt.IsFalse) + c.Assert(hasDeferSecondaryKeys, qt.IsFalse) + c.Assert(hasAtomicCopy, qt.IsFalse) + c.Assert(hasCells, qt.IsFalse) + c.Assert(hasTabletTypes, qt.IsFalse) + c.Assert(hasExcludeTables, qt.IsFalse) + c.Assert(hasTenantID, qt.IsFalse) + c.Assert(hasGlobalKeyspace, qt.IsFalse) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"create-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.Create(ctx, &MoveTablesCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "create-op"}) +} + +func TestMoveTables_CreateWithTenantID(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["tenant_id"], qt.Equals, "tenant-123") + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"create-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.Create(ctx, &MoveTablesCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + TenantID: "tenant-123", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "create-op"}) +} + +func TestMoveTables_CreateWithGlobalKeyspace(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["global_keyspace"], qt.Equals, "global") + c.Assert(body["sharded_auto_increment_handling"], qt.Equals, "REPLACE") + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"create-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.Create(ctx, &MoveTablesCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + ShardedAutoIncrementHandling: "REPLACE", + GlobalKeyspace: "global", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "create-op"}) +} + +func TestMoveTables_CreateWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + allTables, hasAllTables := body["all_tables"] + autoStart, hasAutoStart := body["auto_start"] + stopAfterCopy, hasStopAfterCopy := body["stop_after_copy"] + deferSecondaryKeys, hasDeferSecondaryKeys := body["defer_secondary_keys"] + atomicCopy, hasAtomicCopy := body["atomic_copy"] + c.Assert(hasAllTables, qt.IsTrue) + c.Assert(hasAutoStart, qt.IsTrue) + c.Assert(hasStopAfterCopy, qt.IsTrue) + c.Assert(hasDeferSecondaryKeys, qt.IsTrue) + c.Assert(hasAtomicCopy, qt.IsTrue) + c.Assert(allTables, qt.Equals, false) + c.Assert(autoStart, qt.Equals, false) + c.Assert(stopAfterCopy, qt.Equals, false) + c.Assert(deferSecondaryKeys, qt.Equals, false) + c.Assert(atomicCopy, qt.Equals, false) + + cells, hasCells := body["cells"] + excludeTables, hasExcludeTables := body["exclude_tables"] + tabletTypes, hasTabletTypes := body["tablet_types"] + c.Assert(hasCells, qt.IsTrue) + c.Assert(hasExcludeTables, qt.IsTrue) + c.Assert(hasTabletTypes, qt.IsTrue) + c.Assert(cells, qt.DeepEquals, []interface{}{"cell1", "cell2"}) + c.Assert(excludeTables, qt.DeepEquals, []interface{}{"internal_logs"}) + c.Assert(tabletTypes, qt.DeepEquals, []interface{}{"REPLICA", "RDONLY"}) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"create-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + ref, err := client.MoveTables.Create(ctx, &MoveTablesCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + SourceKeyspace: "source", + Tables: []string{"sales"}, + AllTables: &falseValue, + AutoStart: &falseValue, + StopAfterCopy: &falseValue, + DeferSecondaryKeys: &falseValue, + AtomicCopy: &falseValue, + Cells: []string{"cell1", "cell2"}, + ExcludeTables: []string{"internal_logs"}, + TabletTypes: []string{"REPLICA", "RDONLY"}, + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "create-op"}) +} + +func TestMoveTables_Show(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.MoveTables.Show(ctx, &MoveTablesShowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestMoveTables_Status(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/status") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.MoveTables.Status(ctx, &MoveTablesStatusRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestMoveTables_SwitchTraffic(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/switch-traffic") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + _, hasDryRun := body["dry_run"] + _, hasInitializeTargetSequences := body["initialize_target_sequences"] + _, hasMaxReplicationLagAllowed := body["max_replication_lag_allowed"] + c.Assert(hasDryRun, qt.IsFalse) + c.Assert(hasInitializeTargetSequences, qt.IsFalse) + c.Assert(hasMaxReplicationLagAllowed, qt.IsFalse) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"switch-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.SwitchTraffic(ctx, &MoveTablesSwitchTrafficRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "switch-op"}) +} + +func TestMoveTables_SwitchTrafficWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/switch-traffic") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + dryRun, hasDryRun := body["dry_run"] + initializeTargetSequences, hasInitializeTargetSequences := body["initialize_target_sequences"] + maxReplicationLagAllowed, hasMaxReplicationLagAllowed := body["max_replication_lag_allowed"] + c.Assert(hasDryRun, qt.IsTrue) + c.Assert(hasInitializeTargetSequences, qt.IsTrue) + c.Assert(hasMaxReplicationLagAllowed, qt.IsTrue) + c.Assert(dryRun, qt.Equals, false) + c.Assert(initializeTargetSequences, qt.Equals, false) + c.Assert(maxReplicationLagAllowed, qt.Equals, float64(30)) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"switch-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + maxLag := int64(30) + ref, err := client.MoveTables.SwitchTraffic(ctx, &MoveTablesSwitchTrafficRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + MaxReplicationLagAllowed: &maxLag, + DryRun: &falseValue, + InitializeTargetSequences: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "switch-op"}) +} + +func TestMoveTables_ReverseTraffic(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/reverse-traffic") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + _, hasDryRun := body["dry_run"] + _, hasTabletTypes := body["tablet_types"] + _, hasMaxReplicationLagAllowed := body["max_replication_lag_allowed"] + c.Assert(hasDryRun, qt.IsFalse) + c.Assert(hasTabletTypes, qt.IsFalse) + c.Assert(hasMaxReplicationLagAllowed, qt.IsFalse) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"reverse-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.ReverseTraffic(ctx, &MoveTablesReverseTrafficRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "reverse-op"}) +} + +func TestMoveTables_ReverseTrafficWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/reverse-traffic") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + dryRun, hasDryRun := body["dry_run"] + c.Assert(hasDryRun, qt.IsTrue) + c.Assert(dryRun, qt.Equals, false) + + tabletTypes, hasTabletTypes := body["tablet_types"] + maxReplicationLagAllowed, hasMaxReplicationLagAllowed := body["max_replication_lag_allowed"] + c.Assert(hasTabletTypes, qt.IsTrue) + c.Assert(hasMaxReplicationLagAllowed, qt.IsTrue) + c.Assert(tabletTypes, qt.DeepEquals, []interface{}{"REPLICA", "RDONLY"}) + c.Assert(maxReplicationLagAllowed, qt.Equals, float64(60)) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"reverse-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + maxLag := int64(60) + ref, err := client.MoveTables.ReverseTraffic(ctx, &MoveTablesReverseTrafficRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + TabletTypes: []string{"REPLICA", "RDONLY"}, + MaxReplicationLagAllowed: &maxLag, + DryRun: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "reverse-op"}) +} + +func TestMoveTables_Cancel(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/cancel") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + _, hasKeepData := body["keep_data"] + _, hasKeepRoutingRules := body["keep_routing_rules"] + c.Assert(hasKeepData, qt.IsFalse) + c.Assert(hasKeepRoutingRules, qt.IsFalse) + + w.WriteHeader(202) + _, err = w.Write([]byte(`{"id":"cancel-op-123"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + op, err := client.MoveTables.Cancel(ctx, &MoveTablesCancelRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(op.ID, qt.Equals, "cancel-op-123") +} + +func TestMoveTables_CancelWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/cancel") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + keepData, hasKeepData := body["keep_data"] + keepRoutingRules, hasKeepRoutingRules := body["keep_routing_rules"] + c.Assert(hasKeepData, qt.IsTrue) + c.Assert(hasKeepRoutingRules, qt.IsTrue) + c.Assert(keepData, qt.Equals, false) + c.Assert(keepRoutingRules, qt.Equals, false) + + w.WriteHeader(202) + _, err = w.Write([]byte(`{"id":"cancel-op-456"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + op, err := client.MoveTables.Cancel(ctx, &MoveTablesCancelRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + KeepData: &falseValue, + KeepRoutingRules: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(op.ID, qt.Equals, "cancel-op-456") +} + +func TestMoveTables_Complete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/complete") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + _, hasKeepData := body["keep_data"] + _, hasKeepRoutingRules := body["keep_routing_rules"] + _, hasRenameTables := body["rename_tables"] + _, hasDryRun := body["dry_run"] + c.Assert(hasKeepData, qt.IsFalse) + c.Assert(hasKeepRoutingRules, qt.IsFalse) + c.Assert(hasRenameTables, qt.IsFalse) + c.Assert(hasDryRun, qt.IsFalse) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"complete-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + ref, err := client.MoveTables.Complete(ctx, &MoveTablesCompleteRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "complete-op"}) +} + +func TestMoveTables_CompleteWithExplicitFalseValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/move-tables/workflows/my-workflow/complete") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + keepData, hasKeepData := body["keep_data"] + keepRoutingRules, hasKeepRoutingRules := body["keep_routing_rules"] + renameTables, hasRenameTables := body["rename_tables"] + dryRun, hasDryRun := body["dry_run"] + c.Assert(hasKeepData, qt.IsTrue) + c.Assert(hasKeepRoutingRules, qt.IsTrue) + c.Assert(hasRenameTables, qt.IsTrue) + c.Assert(hasDryRun, qt.IsTrue) + c.Assert(keepData, qt.Equals, false) + c.Assert(keepRoutingRules, qt.Equals, false) + c.Assert(renameTables, qt.Equals, false) + c.Assert(dryRun, qt.Equals, false) + + w.WriteHeader(http.StatusAccepted) + _, err = w.Write([]byte(`{"id":"complete-op"}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + falseValue := false + ctx := context.Background() + ref, err := client.MoveTables.Complete(ctx, &MoveTablesCompleteRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + KeepData: &falseValue, + KeepRoutingRules: &falseValue, + RenameTables: &falseValue, + DryRun: &falseValue, + }) + c.Assert(err, qt.IsNil) + c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "complete-op"}) +} diff --git a/internal/planetscale/vtctld_operations.go b/internal/planetscale/vtctld_operations.go new file mode 100644 index 000000000..13cf19611 --- /dev/null +++ b/internal/planetscale/vtctld_operations.go @@ -0,0 +1,62 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path" + "time" +) + +// GetVtctldOperationRequest is a request for retrieving a vtctld operation. +type GetVtctldOperationRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + ID string `json:"-"` +} + +// VtctldOperationReference identifies an accepted vtctld operation that can be +// polled later. +type VtctldOperationReference struct { + ID string `json:"id"` +} + +// VtctldOperation represents a generic vtctld operation resource. +type VtctldOperation struct { + ID string `json:"id"` + Type string `json:"type"` + Action string `json:"action"` + Timeout int `json:"timeout"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` + State string `json:"state"` + Completed bool `json:"completed"` + Metadata json.RawMessage `json:"metadata"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} + +func vtctldOperationsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vtctld", "operations") +} + +func vtctldOperationAPIPath(org, db, branch, id string) string { + return path.Join(vtctldOperationsAPIPath(org, db, branch), id) +} + +func (s *vtctldService) GetOperation(ctx context.Context, req *GetVtctldOperationRequest) (*VtctldOperation, error) { + p := vtctldOperationAPIPath(req.Organization, req.Database, req.Branch, req.ID) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &VtctldOperation{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/planetscale/vtctld_operations_test.go b/internal/planetscale/vtctld_operations_test.go new file mode 100644 index 000000000..b0e22c48c --- /dev/null +++ b/internal/planetscale/vtctld_operations_test.go @@ -0,0 +1,64 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestVtctldOperations_Get(t *testing.T) { + c := qt.New(t) + + createdAt := time.Date(2026, time.March, 6, 12, 0, 0, 0, time.UTC) + completedAt := createdAt.Add(2 * time.Minute) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vtctld/operations/op-123") + + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "id":"op-123", + "type":"VtctldOperation", + "action":"move_tables_switch_traffic", + "timeout":300, + "created_at":"2026-03-06T12:00:00Z", + "completed_at":"2026-03-06T12:02:00Z", + "state":"completed", + "completed":true, + "metadata":{"workflow":"migrate_commerce","target_keyspace":"commerce"}, + "result":{"summary":"done"}, + "error":"" + }`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + operation, err := client.Vtctld.GetOperation(context.Background(), &GetVtctldOperationRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + ID: "op-123", + }) + c.Assert(err, qt.IsNil) + c.Assert(operation.ID, qt.Equals, "op-123") + c.Assert(operation.Action, qt.Equals, "move_tables_switch_traffic") + c.Assert(operation.State, qt.Equals, "completed") + c.Assert(operation.Completed, qt.IsTrue) + c.Assert(operation.Timeout, qt.Equals, 300) + c.Assert(operation.CreatedAt, qt.Equals, createdAt) + c.Assert(*operation.CompletedAt, qt.Equals, completedAt) + c.Assert(string(operation.Metadata), qt.JSONEquals, map[string]interface{}{ + "workflow": "migrate_commerce", + "target_keyspace": "commerce", + }) + c.Assert(string(operation.Result), qt.Equals, `{"summary":"done"}`) + c.Assert(operation.Error, qt.Equals, "") +} diff --git a/internal/planetscale/vtctld_planned_reparent.go b/internal/planetscale/vtctld_planned_reparent.go new file mode 100644 index 000000000..c5e3c14dd --- /dev/null +++ b/internal/planetscale/vtctld_planned_reparent.go @@ -0,0 +1,75 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" +) + +// PlannedReparentShardService is an interface for interacting with the +// PlannedReparentShard endpoints of the PlanetScale API. +type PlannedReparentShardService interface { + Create(context.Context, *PlannedReparentShardRequest) (*VtctldOperation, error) + Get(context.Context, *GetPlannedReparentShardRequest) (*VtctldOperation, error) +} + +// PlannedReparentShardRequest is a request for creating a planned reparent +// shard operation. +type PlannedReparentShardRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + NewPrimary string `json:"new_primary"` +} + +// GetPlannedReparentShardRequest is a request for retrieving a planned reparent +// shard operation. +type GetPlannedReparentShardRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + ID string `json:"-"` +} + +type plannedReparentShardService struct { + client *Client +} + +var _ PlannedReparentShardService = &plannedReparentShardService{} + +func plannedReparentAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "planned-reparent") +} + +func (s *plannedReparentShardService) Create(ctx context.Context, req *PlannedReparentShardRequest) (*VtctldOperation, error) { + p := plannedReparentAPIPath(req.Organization, req.Database, req.Branch) + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &VtctldOperation{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} + +func (s *plannedReparentShardService) Get(ctx context.Context, req *GetPlannedReparentShardRequest) (*VtctldOperation, error) { + p := path.Join(plannedReparentAPIPath(req.Organization, req.Database, req.Branch), req.ID) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &VtctldOperation{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/planetscale/vtctld_tablets.go b/internal/planetscale/vtctld_tablets.go new file mode 100644 index 000000000..f9da5bcb3 --- /dev/null +++ b/internal/planetscale/vtctld_tablets.go @@ -0,0 +1,80 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strings" +) + +// TabletGroup represents a group of tablets in a keyspace/shard. +type TabletGroup struct { + Type string `json:"type"` + Keyspace string `json:"keyspace"` + Shard string `json:"shard"` + Tablets []Tablet `json:"tablets"` +} + +// Tablet represents an individual vttablet. +type Tablet struct { + Alias string `json:"alias"` + Role string `json:"role"` + Cell string `json:"cell"` +} + +// ListBranchTabletsRequest is a request for listing tablets on a branch. +type ListBranchTabletsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + // The following are optional filters that mirror vtctldclient GetTablets. + + // Keyspace, if set, only returns tablets in this keyspace. + Keyspace string `json:"-"` + // Shard, if set, only returns tablets in this shard. It requires Keyspace + // to also be set. + Shard string `json:"-"` + // TabletType, if set, only returns tablets of this type (e.g. "primary", + // "replica", "rdonly"). + TabletType string `json:"-"` + // TabletAliases, if set, only returns the tablets with these aliases (e.g. + // "zone1-0000000100"). When set, the other filters are ignored. + TabletAliases []string `json:"-"` +} + +func tabletsAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "tablets") +} + +func (s *vtctldService) ListTablets(ctx context.Context, req *ListBranchTabletsRequest) ([]*TabletGroup, error) { + p := tabletsAPIPath(req.Organization, req.Database, req.Branch) + + v := url.Values{} + if req.Keyspace != "" { + v.Set("keyspace", req.Keyspace) + } + if req.Shard != "" { + v.Set("shard", req.Shard) + } + if req.TabletType != "" { + v.Set("tablet_type", req.TabletType) + } + if len(req.TabletAliases) > 0 { + v.Set("tablet_alias", strings.Join(req.TabletAliases, ",")) + } + + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + var resp []*TabletGroup + if err := s.client.do(ctx, httpReq, &resp); err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/planetscale/vtctld_tablets_test.go b/internal/planetscale/vtctld_tablets_test.go new file mode 100644 index 000000000..8379c59e6 --- /dev/null +++ b/internal/planetscale/vtctld_tablets_test.go @@ -0,0 +1,110 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestVtctld_ListTablets(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/tablets") + + // No filters set, so none should be present on the request. + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "") + c.Assert(r.URL.Query().Get("shard"), qt.Equals, "") + c.Assert(r.URL.Query().Get("tablet_type"), qt.Equals, "") + c.Assert(r.URL.Query().Get("tablet_alias"), qt.Equals, "") + + w.WriteHeader(200) + _, err := w.Write([]byte(`[{"type":"primary","keyspace":"commerce","shard":"-","tablets":[{"alias":"zone1-100","role":"primary","cell":"zone1"}]}]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + groups, err := client.Vtctld.ListTablets(ctx, &ListBranchTabletsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + c.Assert(err, qt.IsNil) + c.Assert(groups, qt.DeepEquals, []*TabletGroup{ + { + Type: "primary", + Keyspace: "commerce", + Shard: "-", + Tablets: []Tablet{ + {Alias: "zone1-100", Role: "primary", Cell: "zone1"}, + }, + }, + }) +} + +func TestVtctld_ListTablets_Filters(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/tablets") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "commerce") + c.Assert(r.URL.Query().Get("shard"), qt.Equals, "-80") + c.Assert(r.URL.Query().Get("tablet_type"), qt.Equals, "replica") + + w.WriteHeader(200) + _, err := w.Write([]byte(`[]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.ListTablets(ctx, &ListBranchTabletsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + Shard: "-80", + TabletType: "replica", + }) + c.Assert(err, qt.IsNil) +} + +func TestVtctld_ListTablets_TabletAliases(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/tablets") + // Multiple aliases are sent as a single comma-separated value. + c.Assert(r.URL.Query().Get("tablet_alias"), qt.Equals, "zone1-0000000100,zone1-0000000101") + + w.WriteHeader(200) + _, err := w.Write([]byte(`[]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.ListTablets(ctx, &ListBranchTabletsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + TabletAliases: []string{"zone1-0000000100", "zone1-0000000101"}, + }) + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/vtctld_vdiff.go b/internal/planetscale/vtctld_vdiff.go new file mode 100644 index 000000000..b3af32de4 --- /dev/null +++ b/internal/planetscale/vtctld_vdiff.go @@ -0,0 +1,188 @@ +package planetscale + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" +) + +// VDiffService is an interface for interacting with the VDiff endpoints of the +// PlanetScale API. +type VDiffService interface { + Create(context.Context, *VDiffCreateRequest) (json.RawMessage, error) + List(context.Context, *VDiffListRequest) (json.RawMessage, error) + Show(context.Context, *VDiffShowRequest) (json.RawMessage, error) + Stop(context.Context, *VDiffStopRequest) (json.RawMessage, error) + Resume(context.Context, *VDiffResumeRequest) (json.RawMessage, error) + Delete(context.Context, *VDiffDeleteRequest) (json.RawMessage, error) +} + +type VDiffCreateRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + + TargetKeyspace string `json:"target_keyspace"` + AutoRetry *bool `json:"auto_retry,omitempty"` + AutoStart *bool `json:"auto_start,omitempty"` + DebugQuery bool `json:"debug_query,omitempty"` + OnlyPKs bool `json:"only_pks,omitempty"` + UpdateTableStats bool `json:"update_table_stats,omitempty"` + Verbose bool `json:"verbose,omitempty"` + Tables []string `json:"tables,omitempty"` + TabletTypes []string `json:"tablet_types,omitempty"` + TabletSelectionPreference string `json:"tablet_selection_preference,omitempty"` + FilteredReplicationWaitTime *int `json:"filtered_replication_wait_time,omitempty"` + MaxReportSampleRows *int `json:"max_report_sample_rows,omitempty"` + MaxExtraRowsToCompare *int `json:"max_extra_rows_to_compare,omitempty"` + RowDiffColumnTruncateAt *int `json:"row_diff_column_truncate_at,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type VDiffListRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + TargetKeyspace string `json:"-"` +} + +type VDiffShowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + UUID string `json:"-"` + TargetKeyspace string `json:"-"` +} + +type VDiffStopRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + UUID string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + TargetShards []string `json:"target_shards,omitempty"` +} + +type VDiffResumeRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + UUID string `json:"-"` + TargetKeyspace string `json:"target_keyspace"` + TargetShards []string `json:"target_shards,omitempty"` +} + +type VDiffDeleteRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Workflow string `json:"-"` + UUID string `json:"-"` + TargetKeyspace string `json:"-"` +} + +type vdiffService struct { + client *Client +} + +var _ VDiffService = &vdiffService{} + +func vdiffVDiffsAPIPath(org, db, branch, workflow string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "vdiff", "workflows", workflow, "vdiffs") +} + +func vdiffVDiffAPIPath(org, db, branch, workflow, uuid string) string { + return path.Join(vdiffVDiffsAPIPath(org, db, branch, workflow), uuid) +} + +func (s *vdiffService) Create(ctx context.Context, req *VDiffCreateRequest) (json.RawMessage, error) { + p := vdiffVDiffsAPIPath(req.Organization, req.Database, req.Branch, req.Workflow) + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vdiffService) List(ctx context.Context, req *VDiffListRequest) (json.RawMessage, error) { + p := vdiffVDiffsAPIPath(req.Organization, req.Database, req.Branch, req.Workflow) + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vdiffService) Show(ctx context.Context, req *VDiffShowRequest) (json.RawMessage, error) { + p := vdiffVDiffAPIPath(req.Organization, req.Database, req.Branch, req.Workflow, req.UUID) + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vdiffService) Stop(ctx context.Context, req *VDiffStopRequest) (json.RawMessage, error) { + p := path.Join(vdiffVDiffAPIPath(req.Organization, req.Database, req.Branch, req.Workflow, req.UUID), "stop") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vdiffService) Resume(ctx context.Context, req *VDiffResumeRequest) (json.RawMessage, error) { + p := path.Join(vdiffVDiffAPIPath(req.Organization, req.Database, req.Branch, req.Workflow, req.UUID), "resume") + httpReq, err := s.client.newRequest(http.MethodPost, p, req) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s *vdiffService) Delete(ctx context.Context, req *VDiffDeleteRequest) (json.RawMessage, error) { + p := vdiffVDiffAPIPath(req.Organization, req.Database, req.Branch, req.Workflow, req.UUID) + v := url.Values{} + v.Set("target_keyspace", req.TargetKeyspace) + httpReq, err := s.client.newRequest(http.MethodDelete, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + resp := &vtctldDataResponse{} + if err := s.client.do(ctx, httpReq, resp); err != nil { + return nil, err + } + return resp.Data, nil +} diff --git a/internal/planetscale/vtctld_vdiff_test.go b/internal/planetscale/vtctld_vdiff_test.go new file mode 100644 index 000000000..bd6e9494b --- /dev/null +++ b/internal/planetscale/vtctld_vdiff_test.go @@ -0,0 +1,255 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestVDiff_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + _, hasFilteredReplicationWaitTime := body["filtered_replication_wait_time"] + _, hasMaxReportSampleRows := body["max_report_sample_rows"] + _, hasMaxExtraRowsToCompare := body["max_extra_rows_to_compare"] + _, hasRowDiffColumnTruncateAt := body["row_diff_column_truncate_at"] + _, hasLimit := body["limit"] + c.Assert(hasFilteredReplicationWaitTime, qt.IsFalse) + c.Assert(hasMaxReportSampleRows, qt.IsFalse) + c.Assert(hasMaxExtraRowsToCompare, qt.IsFalse) + c.Assert(hasRowDiffColumnTruncateAt, qt.IsFalse) + c.Assert(hasLimit, qt.IsFalse) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.Create(ctx, &VDiffCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_CreateWithExplicitZeroValues(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + c.Assert(body["filtered_replication_wait_time"], qt.Equals, float64(0)) + c.Assert(body["max_report_sample_rows"], qt.Equals, float64(0)) + c.Assert(body["max_extra_rows_to_compare"], qt.Equals, float64(0)) + c.Assert(body["row_diff_column_truncate_at"], qt.Equals, float64(0)) + c.Assert(body["limit"], qt.Equals, float64(0)) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + zero := 0 + ctx := context.Background() + data, err := client.VDiff.Create(ctx, &VDiffCreateRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + FilteredReplicationWaitTime: &zero, + MaxReportSampleRows: &zero, + MaxExtraRowsToCompare: &zero, + RowDiffColumnTruncateAt: &zero, + Limit: &zero, + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.List(ctx, &VDiffListRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_Show(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs/my-uuid") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.Show(ctx, &VDiffShowRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + UUID: "my-uuid", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_Stop(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs/my-uuid/stop") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.Stop(ctx, &VDiffStopRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + UUID: "my-uuid", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_Resume(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs/my-uuid/resume") + + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["target_keyspace"], qt.Equals, "target") + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.Resume(ctx, &VDiffResumeRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + UUID: "my-uuid", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} + +func TestVDiff_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/my-branch/vdiff/workflows/my-workflow/vdiffs/my-uuid") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":{"result":"ok"}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.VDiff.Delete(ctx, &VDiffDeleteRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Workflow: "my-workflow", + UUID: "my-uuid", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `{"result":"ok"}`) +} diff --git a/internal/planetscale/webhooks.go b/internal/planetscale/webhooks.go new file mode 100644 index 000000000..3e253013e --- /dev/null +++ b/internal/planetscale/webhooks.go @@ -0,0 +1,177 @@ +package planetscale + +import ( + "context" + "net/http" + "path" + "time" +) + +var _ WebhooksService = &webhooksService{} + +// WebhooksService is an interface for communicating with the PlanetScale +// Webhooks API. +type WebhooksService interface { + List(context.Context, *ListWebhooksRequest, ...ListOption) ([]*Webhook, error) + Create(context.Context, *CreateWebhookRequest) (*Webhook, error) + Get(context.Context, *GetWebhookRequest) (*Webhook, error) + Update(context.Context, *UpdateWebhookRequest) (*Webhook, error) + Delete(context.Context, *DeleteWebhookRequest) error + Test(context.Context, *TestWebhookRequest) error +} + +type webhooksResponse struct { + Webhooks []*Webhook `json:"data"` +} + +// Webhook represents a PlanetScale webhook. +type Webhook struct { + ID string `json:"id"` + URL string `json:"url"` + Secret string `json:"secret"` + Enabled bool `json:"enabled"` + LastSentResult string `json:"last_sent_result"` + LastSentSuccess bool `json:"last_sent_success"` + LastSentAt time.Time `json:"last_sent_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Events []string `json:"events"` +} + +// ListWebhooksRequest is the request for listing webhooks. +type ListWebhooksRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +// CreateWebhookRequest is the request for creating a webhook. +type CreateWebhookRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + URL string `json:"url"` + Enabled *bool `json:"enabled,omitempty"` + Events []string `json:"events,omitempty"` +} + +// GetWebhookRequest is the request for getting a webhook. +type GetWebhookRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` +} + +// UpdateWebhookRequest is the request for updating a webhook. +type UpdateWebhookRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` + URL *string `json:"url,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Events []string `json:"events,omitempty"` +} + +// DeleteWebhookRequest is the request for deleting a webhook. +type DeleteWebhookRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` +} + +// TestWebhookRequest is the request for testing a webhook. +type TestWebhookRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` +} + +type webhooksService struct { + client *Client +} + +func (w *webhooksService) List(ctx context.Context, listReq *ListWebhooksRequest, opts ...ListOption) ([]*Webhook, error) { + listOpts := defaultListOptions(opts...) + + req, err := w.client.newRequest(http.MethodGet, webhooksAPIPath(listReq.Organization, listReq.Database), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + resp := &webhooksResponse{} + if err := w.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Webhooks, nil +} + +func (w *webhooksService) Create(ctx context.Context, createReq *CreateWebhookRequest) (*Webhook, error) { + req, err := w.client.newRequest(http.MethodPost, webhooksAPIPath(createReq.Organization, createReq.Database), createReq) + if err != nil { + return nil, err + } + + webhook := &Webhook{} + if err := w.client.do(ctx, req, &webhook); err != nil { + return nil, err + } + + return webhook, nil +} + +func (w *webhooksService) Get(ctx context.Context, getReq *GetWebhookRequest) (*Webhook, error) { + req, err := w.client.newRequest(http.MethodGet, webhookAPIPath(getReq.Organization, getReq.Database, getReq.ID), nil) + if err != nil { + return nil, err + } + + webhook := &Webhook{} + if err := w.client.do(ctx, req, &webhook); err != nil { + return nil, err + } + + return webhook, nil +} + +func (w *webhooksService) Update(ctx context.Context, updateReq *UpdateWebhookRequest) (*Webhook, error) { + req, err := w.client.newRequest(http.MethodPatch, webhookAPIPath(updateReq.Organization, updateReq.Database, updateReq.ID), updateReq) + if err != nil { + return nil, err + } + + webhook := &Webhook{} + if err := w.client.do(ctx, req, &webhook); err != nil { + return nil, err + } + + return webhook, nil +} + +func (w *webhooksService) Delete(ctx context.Context, deleteReq *DeleteWebhookRequest) error { + req, err := w.client.newRequest(http.MethodDelete, webhookAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.ID), nil) + if err != nil { + return err + } + + return w.client.do(ctx, req, nil) +} + +func (w *webhooksService) Test(ctx context.Context, testReq *TestWebhookRequest) error { + req, err := w.client.newRequest(http.MethodPost, webhookTestAPIPath(testReq.Organization, testReq.Database, testReq.ID), nil) + if err != nil { + return err + } + + return w.client.do(ctx, req, nil) +} + +func webhooksAPIPath(org, db string) string { + return path.Join("v1/organizations", org, "databases", db, "webhooks") +} + +func webhookAPIPath(org, db, id string) string { + return path.Join(webhooksAPIPath(org, db), id) +} + +func webhookTestAPIPath(org, db, id string) string { + return path.Join(webhookAPIPath(org, db, id), "test") +} diff --git a/internal/planetscale/webhooks_test.go b/internal/planetscale/webhooks_test.go new file mode 100644 index 000000000..8b73c5a0b --- /dev/null +++ b/internal/planetscale/webhooks_test.go @@ -0,0 +1,287 @@ +package planetscale + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestWebhooks_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks") + + out := `{ + "current_page": 1, + "next_page": null, + "next_page_url": null, + "prev_page": null, + "prev_page_url": null, + "data": [{ + "id": "webhook-123", + "url": "https://example.com/webhook", + "secret": "secret-123", + "enabled": true, + "last_sent_result": "success", + "last_sent_success": true, + "last_sent_at": "2021-01-14T10:19:23.000Z", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z", + "events": ["branch.ready", "deploy_request.opened"] + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + webhooks, err := client.Webhooks.List(ctx, &ListWebhooksRequest{ + Organization: testOrg, + Database: testDatabase, + }) + + c.Assert(err, qt.IsNil) + c.Assert(len(webhooks), qt.Equals, 1) + c.Assert(webhooks[0].ID, qt.Equals, "webhook-123") + c.Assert(webhooks[0].URL, qt.Equals, "https://example.com/webhook") + c.Assert(webhooks[0].Enabled, qt.IsTrue) + c.Assert(webhooks[0].Events, qt.DeepEquals, []string{"branch.ready", "deploy_request.opened"}) +} + +func TestWebhooks_List_WithPagination(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Query().Get("page"), qt.Equals, "2") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "10") + + out := `{ + "current_page": 2, + "next_page": null, + "next_page_url": null, + "prev_page": 1, + "prev_page_url": "https://api.planetscale.com/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks?page=1", + "data": [] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + webhooks, err := client.Webhooks.List(ctx, &ListWebhooksRequest{ + Organization: testOrg, + Database: testDatabase, + }, WithPage(2), WithPerPage(10)) + + c.Assert(err, qt.IsNil) + c.Assert(len(webhooks), qt.Equals, 0) +} + +func TestWebhooks_Create(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"url\":\"https://example.com/webhook\",\"enabled\":true,\"events\":[\"branch.ready\"]}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(201) + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks") + + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + out := `{ + "id": "webhook-123", + "url": "https://example.com/webhook", + "secret": "generated-secret", + "enabled": true, + "last_sent_result": "", + "last_sent_success": false, + "last_sent_at": "0001-01-01T00:00:00.000Z", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z", + "events": ["branch.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() + enabled := true + + webhook, err := client.Webhooks.Create(ctx, &CreateWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + URL: "https://example.com/webhook", + Enabled: &enabled, + Events: []string{"branch.ready"}, + }) + + c.Assert(err, qt.IsNil) + c.Assert(webhook.ID, qt.Equals, "webhook-123") + c.Assert(webhook.URL, qt.Equals, "https://example.com/webhook") + c.Assert(webhook.Secret, qt.Equals, "generated-secret") + c.Assert(webhook.Enabled, qt.IsTrue) + c.Assert(webhook.CreatedAt, qt.Equals, time.Date(2021, 1, 14, 10, 19, 23, 0, time.UTC)) +} + +func TestWebhooks_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks/webhook-123") + + out := `{ + "id": "webhook-123", + "url": "https://example.com/webhook", + "secret": "secret-123", + "enabled": true, + "last_sent_result": "success", + "last_sent_success": true, + "last_sent_at": "2021-01-14T10:19:23.000Z", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-14T10:19:23.000Z", + "events": ["branch.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() + + webhook, err := client.Webhooks.Get(ctx, &GetWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + }) + + c.Assert(err, qt.IsNil) + c.Assert(webhook.ID, qt.Equals, "webhook-123") + c.Assert(webhook.URL, qt.Equals, "https://example.com/webhook") + c.Assert(webhook.LastSentSuccess, qt.IsTrue) +} + +func TestWebhooks_Update(t *testing.T) { + c := qt.New(t) + + wantBody := []byte("{\"url\":\"https://example.com/new-webhook\",\"enabled\":false}\n") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks/webhook-123") + + data, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(data, qt.DeepEquals, wantBody) + + out := `{ + "id": "webhook-123", + "url": "https://example.com/new-webhook", + "secret": "secret-123", + "enabled": false, + "last_sent_result": "success", + "last_sent_success": true, + "last_sent_at": "2021-01-14T10:19:23.000Z", + "created_at": "2021-01-14T10:19:23.000Z", + "updated_at": "2021-01-15T10:19:23.000Z", + "events": ["branch.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() + newURL := "https://example.com/new-webhook" + enabled := false + + webhook, err := client.Webhooks.Update(ctx, &UpdateWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + URL: &newURL, + Enabled: &enabled, + }) + + c.Assert(err, qt.IsNil) + c.Assert(webhook.ID, qt.Equals, "webhook-123") + c.Assert(webhook.URL, qt.Equals, "https://example.com/new-webhook") + c.Assert(webhook.Enabled, qt.IsFalse) + c.Assert(webhook.UpdatedAt, qt.Equals, time.Date(2021, 1, 15, 10, 19, 23, 0, time.UTC)) +} + +func TestWebhooks_Delete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks/webhook-123") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.Webhooks.Delete(ctx, &DeleteWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + }) + + c.Assert(err, qt.IsNil) +} + +func TestWebhooks_Test(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/webhooks/webhook-123/test") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + err = client.Webhooks.Test(ctx, &TestWebhookRequest{ + Organization: testOrg, + Database: testDatabase, + ID: "webhook-123", + }) + + c.Assert(err, qt.IsNil) +} diff --git a/internal/planetscale/workflows.go b/internal/planetscale/workflows.go new file mode 100644 index 000000000..f1a7d5a69 --- /dev/null +++ b/internal/planetscale/workflows.go @@ -0,0 +1,420 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +type Workflow struct { + ID string `json:"id"` + Name string `json:"name"` + Number uint64 `json:"number"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + CancelledAt *time.Time `json:"cancelled_at"` + ReversedAt *time.Time `json:"reversed_at"` + RetriedAt *time.Time `json:"retried_at"` + DataCopyCompletedAt *time.Time `json:"data_copy_completed_at"` + CutoverAt *time.Time `json:"cutover_at"` + ReplicasSwitched bool `json:"replicas_switched"` + PrimariesSwitched bool `json:"primaries_switched"` + SwitchReplicasAt *time.Time `json:"switch_replicas_at"` + SwitchPrimariesAt *time.Time `json:"switch_primaries_at"` + VerifyDataAt *time.Time `json:"verify_data_at"` + + Branch DatabaseBranch `json:"branch"` + SourceKeyspace Keyspace `json:"source_keyspace"` + TargetKeyspace Keyspace `json:"target_keyspace"` + + Actor Actor `json:"actor"` + VerifyDataBy *Actor `json:"verify_data_by"` + ReversedBy *Actor `json:"reversed_by"` + SwitchReplicasBy *Actor `json:"switch_replicas_by"` + SwitchPrimariesBy *Actor `json:"switch_primaries_by"` + CancelledBy *Actor `json:"cancelled_by"` + CompletedBy *Actor `json:"completed_by"` + RetriedBy *Actor `json:"retried_by"` + CutoverBy *Actor `json:"cutover_by"` + ReversedCutoverBy *Actor `json:"reversed_cutover_by"` + + Streams []*WorkflowStream `json:"streams"` + Tables []*WorkflowTable `json:"tables"` + VDiff *WorkflowVDiff `json:"vdiff"` +} + +type WorkflowStream struct { + PublicID string `json:"id"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TargetShard string `json:"target_shard"` + SourceShard string `json:"source_shard"` + Position string `json:"position"` + StopPosition string `json:"stop_position"` + RowsCopied int64 `json:"rows_copied"` + ComponentThrottled *string `json:"component_throttled"` + ComponentThrottledAt *time.Time `json:"component_throttled_at"` + PrimaryServing bool `json:"primary_serving"` + Info string `json:"info"` + Logs []WorkflowStreamLog `json:"logs"` +} + +type WorkflowStreamLog struct { + PublicID string `json:"id"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Message string `json:"message"` + LogType string `json:"log_type"` +} + +type WorkflowTable struct { + PublicID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RowsCopied uint64 `json:"rows_copied"` + RowsTotal uint64 `json:"rows_total"` + RowsPercentage uint `json:"rows_percentage"` +} + +type WorkflowVDiff struct { + PublicID string `json:"id"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + HasMismatch bool `json:"has_mismatch"` + ProgressPercentage uint `json:"progress_percentage"` + EtaSeconds uint64 `json:"eta_seconds"` + TableReports []WorkflowVDiffTableReport `json:"table_reports"` +} + +type WorkflowVDiffTableReport struct { + PublicID string `json:"id"` + TableName string `json:"table_name"` + Shard string `json:"shard"` + MismatchedRowsCount int64 `json:"mismatched_rows_count"` + ExtraSourceRowsCount int64 `json:"extra_source_rows_count"` + ExtraTargetRowsCount int64 `json:"extra_target_rows_count"` + ExtraSourceRows []interface{} `json:"extra_source_rows"` + ExtraTargetRows []interface{} `json:"extra_target_rows"` + MismatchedRows []interface{} `json:"mismatched_rows"` + SampleExtraSourceRowsQuery string `json:"sample_extra_source_rows_query"` + SampleExtraTargetRowsQuery string `json:"sample_extra_target_rows_query"` + SampleMismatchedRowsQuery string `json:"sample_mismatched_rows_query"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListWorkflowsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +type GetWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type VerifyDataWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type SwitchReplicasWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type SwitchPrimariesWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type ReverseTrafficWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type CutoverWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type ReverseCutoverWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type CompleteWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type RetryWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type CancelWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + WorkflowNumber uint64 `json:"-"` +} + +type CreateWorkflowRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"branch_name"` + Name string `json:"name"` + SourceKeyspace string `json:"source_keyspace"` + TargetKeyspace string `json:"target_keyspace"` + Tables []string `json:"tables"` + GlobalKeyspace *string `json:"global_keyspace"` + DeferSecondaryKeys *bool `json:"defer_secondary_keys"` + OnDDL *string `json:"on_ddl"` +} + +// WorkflowsService is an interface for interacting with the workflow endpoints of the PlanetScale API +type WorkflowsService interface { + List(context.Context, *ListWorkflowsRequest) ([]*Workflow, error) + Get(context.Context, *GetWorkflowRequest) (*Workflow, error) + Create(context.Context, *CreateWorkflowRequest) (*Workflow, error) + VerifyData(context.Context, *VerifyDataWorkflowRequest) (*Workflow, error) + SwitchReplicas(context.Context, *SwitchReplicasWorkflowRequest) (*Workflow, error) + SwitchPrimaries(context.Context, *SwitchPrimariesWorkflowRequest) (*Workflow, error) + ReverseTraffic(context.Context, *ReverseTrafficWorkflowRequest) (*Workflow, error) + Cutover(context.Context, *CutoverWorkflowRequest) (*Workflow, error) + ReverseCutover(context.Context, *ReverseCutoverWorkflowRequest) (*Workflow, error) + Complete(context.Context, *CompleteWorkflowRequest) (*Workflow, error) + Retry(context.Context, *RetryWorkflowRequest) (*Workflow, error) + Cancel(context.Context, *CancelWorkflowRequest) (*Workflow, error) +} + +type workflowsService struct { + client *Client +} + +var _ WorkflowsService = &workflowsService{} + +func NewWorkflowsService(client *Client) *workflowsService { + return &workflowsService{client} +} + +type workflowsResponse struct { + Workflows []*Workflow `json:"data"` +} + +func (ws *workflowsService) List(ctx context.Context, listReq *ListWorkflowsRequest) ([]*Workflow, error) { + req, err := ws.client.newRequest(http.MethodGet, workflowsAPIPath(listReq.Organization, listReq.Database), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflows := &workflowsResponse{} + + if err := ws.client.do(ctx, req, workflows); err != nil { + return nil, err + } + + return workflows.Workflows, nil +} + +func (ws *workflowsService) Get(ctx context.Context, getReq *GetWorkflowRequest) (*Workflow, error) { + req, err := ws.client.newRequest(http.MethodGet, workflowAPIPath(getReq.Organization, getReq.Database, getReq.WorkflowNumber), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) Create(ctx context.Context, createReq *CreateWorkflowRequest) (*Workflow, error) { + req, err := ws.client.newRequest(http.MethodPost, workflowsAPIPath(createReq.Organization, createReq.Database), createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) VerifyData(ctx context.Context, verifyDataReq *VerifyDataWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(verifyDataReq.Organization, verifyDataReq.Database, verifyDataReq.WorkflowNumber), "verify-data") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) SwitchReplicas(ctx context.Context, switchReplicasReq *SwitchReplicasWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(switchReplicasReq.Organization, switchReplicasReq.Database, switchReplicasReq.WorkflowNumber), "switch-replicas") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) SwitchPrimaries(ctx context.Context, switchPrimariesReq *SwitchPrimariesWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(switchPrimariesReq.Organization, switchPrimariesReq.Database, switchPrimariesReq.WorkflowNumber), "switch-primaries") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) ReverseTraffic(ctx context.Context, reverseTrafficReq *ReverseTrafficWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(reverseTrafficReq.Organization, reverseTrafficReq.Database, reverseTrafficReq.WorkflowNumber), "reverse-traffic") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) Cutover(ctx context.Context, cutoverReq *CutoverWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(cutoverReq.Organization, cutoverReq.Database, cutoverReq.WorkflowNumber), "cutover") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) ReverseCutover(ctx context.Context, reverseCutoverReq *ReverseCutoverWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(reverseCutoverReq.Organization, reverseCutoverReq.Database, reverseCutoverReq.WorkflowNumber), "reverse-cutover") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) Complete(ctx context.Context, completeReq *CompleteWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(completeReq.Organization, completeReq.Database, completeReq.WorkflowNumber), "complete") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) Retry(ctx context.Context, retryReq *RetryWorkflowRequest) (*Workflow, error) { + pathStr := path.Join(workflowAPIPath(retryReq.Organization, retryReq.Database, retryReq.WorkflowNumber), "retry") + req, err := ws.client.newRequest(http.MethodPatch, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func (ws *workflowsService) Cancel(ctx context.Context, cancelReq *CancelWorkflowRequest) (*Workflow, error) { + path := workflowAPIPath(cancelReq.Organization, cancelReq.Database, cancelReq.WorkflowNumber) + req, err := ws.client.newRequest(http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + workflow := &Workflow{} + + if err := ws.client.do(ctx, req, workflow); err != nil { + return nil, err + } + + return workflow, nil +} + +func workflowsAPIPath(org, db string) string { + return path.Join(databasesAPIPath(org), db, "workflows") +} + +func workflowAPIPath(org, db string, number uint64) string { + return path.Join(workflowsAPIPath(org, db), fmt.Sprintf("%d", number)) +} diff --git a/internal/planetscale/workflows_test.go b/internal/planetscale/workflows_test.go new file mode 100644 index 000000000..05775ab2f --- /dev/null +++ b/internal/planetscale/workflows_test.go @@ -0,0 +1,510 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestWorkflows_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"type":"list","current_page":1,"next_page":null,"next_page_url":null,"prev_page":null,"prev_page_url":null,"data":[{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"completed","created_at":"2025-03-18T16:22:15.293Z","updated_at":"2025-03-18T16:22:15.293Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"ddi0rgmj636p","type":"Branch","name":"main","created_at":"2025-03-18T16:22:14.872Z","deleted_at":null,"updated_at":"2025-03-18T16:22:15.169Z"},"source_keyspace":{"id":"ki6zinvzi973","type":"BranchKeyspace","name":"source-keyspace","created_at":"2025-03-18T16:22:15.016Z","deleted_at":null,"updated_at":"2025-03-18T16:22:15.128Z"},"target_keyspace":{"id":"n4bqtq0akviv","type":"BranchKeyspace","name":"target-keyspace","created_at":"2025-03-18T16:22:15.240Z","deleted_at":null,"updated_at":"2025-03-18T16:22:15.240Z"},"actor":{"id":"lcuyaidzbteb","type":"User","display_name":"kyudcz4fng@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflows, err := client.Workflows.List(ctx, &ListWorkflowsRequest{ + Organization: "foo", + Database: "bar", + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(len(workflows), qt.Equals, 1) + c.Assert(workflows[0].ID, qt.Equals, wantID) + c.Assert(workflows[0].Name, qt.Equals, "shard-table") + c.Assert(workflows[0].Number, qt.Equals, uint64(1)) +} + +func TestWorkflows_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"pending","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Get(ctx, &GetWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(workflow.VDiff.State, qt.Equals, "pending") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_Create(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"pending","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"table-1","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10},{"name":"table-2"}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Create(ctx, &CreateWorkflowRequest{ + Organization: "foo", + Database: "bar", + Branch: "branch", + Name: "my-workflow", + SourceKeyspace: "source-keyspace", + TargetKeyspace: "target-keyspace", + Tables: []string{"table1", "table2"}, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(workflow.VDiff.State, qt.Equals, "pending") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 2) +} + +func TestWorkflows_CreateWithNewOptions(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"pending","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"table-1","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10},{"name":"table-2"}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPost) + var request struct { + GlobalKeyspace *string `json:"global_keyspace"` + DeferSecondaryKeys *bool `json:"defer_secondary_keys"` + OnDDL *string `json:"on_ddl"` + } + err = json.NewDecoder(r.Body).Decode(&request) + c.Assert(err, qt.IsNil) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows") + c.Assert(*request.GlobalKeyspace, qt.Equals, "baz") + c.Assert(*request.DeferSecondaryKeys, qt.Equals, true) + c.Assert(*request.OnDDL, qt.Equals, "EXEC_IGNORE") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + onDDL := "EXEC_IGNORE" + globalKeyspace := "baz" + deferSecondaryKeys := true + + workflow, err := client.Workflows.Create(ctx, &CreateWorkflowRequest{ + Organization: "foo", + Database: "bar", + Branch: "branch", + Name: "my-workflow", + SourceKeyspace: "source-keyspace", + TargetKeyspace: "target-keyspace", + Tables: []string{"table1", "table2"}, + OnDDL: &onDDL, + GlobalKeyspace: &globalKeyspace, + DeferSecondaryKeys: &deferSecondaryKeys, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(workflow.VDiff.State, qt.Equals, "pending") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 2) +} + +func TestWorfklows_VerifyData(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"pending","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"table-1","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10},{"name":"table-2"}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/verify-data") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.VerifyData(ctx, &VerifyDataWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(workflow.VDiff.State, qt.Equals, "pending") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 2) +} + +func TestWorkflows_SwitchReplicas(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"switched_replicas","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/switch-replicas") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.SwitchReplicas(ctx, &SwitchReplicasWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "switched_replicas") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_SwitchPrimaries(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"switched_primaries","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/switch-primaries") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.SwitchPrimaries(ctx, &SwitchPrimariesWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "switched_primaries") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_ReverseTraffic(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"running","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/reverse-traffic") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.ReverseTraffic(ctx, &ReverseTrafficWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "running") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_Cutover(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"cutover","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/cutover") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Cutover(ctx, &CutoverWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "cutover") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_ReverseCutover(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"reversed_cutover","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/reverse-cutover") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.ReverseCutover(ctx, &ReverseCutoverWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "reversed_cutover") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_Complete(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"complete","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/complete") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Complete(ctx, &CompleteWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "complete") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_Retry(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"running","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1/retry") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Retry(ctx, &RetryWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "running") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} + +func TestWorkflows_Cancel(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"thisisanid","type":"Workflow","name":"shard-table","number":1,"state":"cancelled","created_at":"2025-03-18T17:21:55.546Z","updated_at":"2025-03-18T17:21:55.618Z","started_at":null,"completed_at":null,"cancelled_at":null,"reversed_at":null,"retried_at":null,"data_copy_completed_at":null,"cutover_at":null,"replicas_switched":false,"primaries_switched":false,"switch_replicas_at":null,"switch_primaries_at":null,"verify_data_at":null,"workflow_type":"move_tables","workflow_subtype":null,"may_retry":false,"verified_data_stale":false,"branch":{"id":"hjcq437nimp2","type":"Branch","name":"branch","created_at":"2025-03-18T17:21:55.194Z","updated_at":"2025-03-18T17:21:55.434Z","restore_checklist_completed_at":null,"schema_last_updated_at":"2025-03-18T17:21:55.360Z","mysql_address":"us-east.connect.psdb.cloud","mysql_provider_address":"aws.connect.psdb.cloud","schema_ready":true,"state":"ready","vtgate_size":"vg.c1.nano","vtgate_count":1,"cluster_rate_name":"PS_10","mysql_edge_address":"aws.connect.psdb.cloud","ready":true,"metal":false,"production":true,"safe_migrations":true,"sharded":true,"shard_count":4,"stale_schema":false,"index_usage_enabled":true,"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"restored_from_branch":null,"private_connectivity":false,"private_edge_connectivity":false,"html_url":"http://app.pscaledev.com:3001/organization1/weathered-bush-4453/main","has_replicas":true,"has_read_only_replicas":false,"url":"http://api.pscaledev.com:3000/v1/organizations/organization1/databases/weathered-bush-4453/branches/main","region":{"id":"ps-region-id","type":"Region","provider":"AWS","enabled":true,"public_ip_addresses":[],"display_name":"AWS us-east-1","location":"Ashburn, Virginia","slug":"us-east","current_default":true},"parent_branch":null},"source_keyspace":{"id":"w7l4fekda4xg","type":"BranchKeyspace","name":"source-keyspace","shards":1,"sharded":false,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.284Z","updated_at":"2025-03-18T17:21:55.390Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"target_keyspace":{"id":"65qnxzwehl6f","type":"BranchKeyspace","name":"target-keyspace","shards":4,"sharded":true,"replicas":2,"extra_replicas":0,"created_at":"2025-03-18T17:21:55.499Z","updated_at":"2025-03-18T17:21:55.499Z","cluster_rate_name":"PS_10","cluster_rate_display_name":"PS-10","resizing":false,"ready":true,"metal":false,"vector_pool_allocation":null,"resize_pending":false},"actor":{"id":"55cloymikacf","type":"User","display_name":"wabz2zww54@pscaledev.com","avatar_url":"https://app.planetscale.com/gravatar-fallback.png"},"verify_data_by":null,"reversed_by":null,"switch_replicas_by":null,"switch_primaries_by":null,"cancelled_by":null,"completed_by":null,"retried_by":null,"cutover_by":null,"reversed_cutover_by":null,"streams":[{"id":"z7orf7caq72o","type":"WorkflowStream","state":"copying","created_at":"2025-03-18T17:21:55.598Z","updated_at":"2025-03-18T17:21:55.598Z","vitess_id":1,"target_shard":"-80","source_shard":"-80","target_tablet_uid":"target-uid","target_tablet_cell":"target-cell","position":"position","stop_position":"stop-position","rows_copied":10,"component_throttled":null,"component_throttled_at":null,"primary_serving":false,"info":"important info"}],"tables":[{"id":"5lje0cf2dvdi","type":"WorkflowTable","name":"cool-snowflake-2076","created_at":"2025-03-18T17:21:55.584Z","updated_at":"2025-03-18T17:21:55.584Z","rows_copied":10,"rows_total":100,"rows_percentage":10}],"vdiff":{"id":"n86yn5nr26zc","type":"WorkflowVDiff","state":"pending","created_at":"2025-03-18T17:21:55.614Z","updated_at":"2025-03-18T17:21:55.614Z","started_at":null,"completed_at":null,"has_mismatch":false,"progress_percentage":null,"eta_seconds":null,"table_reports":[{"id":"rw5saem6ltq0","type":"WorkflowVDiffTableReport","table_name":"users","shard":"-","mismatched_rows_count":0,"extra_source_rows_count":0,"extra_target_rows_count":0,"extra_source_rows":[],"extra_target_rows":[],"mismatched_rows":[],"sample_extra_source_rows_query":null,"sample_extra_target_rows_query":null,"sample_mismatched_rows_query":null,"created_at":"2025-03-18T17:21:55.624Z","updated_at":"2025-03-18T17:21:55.624Z"}]}}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/workflows/1") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + + workflow, err := client.Workflows.Cancel(ctx, &CancelWorkflowRequest{ + Organization: "foo", + Database: "bar", + WorkflowNumber: 1, + }) + + wantID := "thisisanid" + + c.Assert(err, qt.IsNil) + c.Assert(workflow.ID, qt.Equals, wantID) + c.Assert(workflow.Name, qt.Equals, "shard-table") + c.Assert(workflow.Number, qt.Equals, uint64(1)) + c.Assert(workflow.State, qt.Equals, "cancelled") + c.Assert(workflow.SourceKeyspace.Name, qt.Equals, "source-keyspace") + c.Assert(workflow.TargetKeyspace.Name, qt.Equals, "target-keyspace") + c.Assert(workflow.Branch.Name, qt.Equals, "branch") + c.Assert(len(workflow.Streams), qt.Equals, 1) + c.Assert(len(workflow.Tables), qt.Equals, 1) +} diff --git a/internal/promptutil/branch.go b/internal/promptutil/branch.go index a45ce0a2c..ad048eb71 100644 --- a/internal/promptutil/branch.go +++ b/internal/promptutil/branch.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/AlecAivazis/survey/v2" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/printer" diff --git a/internal/roleutil/roleutil.go b/internal/roleutil/roleutil.go index 2f8fad434..6fd9d8f0a 100644 --- a/internal/roleutil/roleutil.go +++ b/internal/roleutil/roleutil.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - ps "github.com/planetscale/planetscale-go/planetscale" + ps "github.com/planetscale/cli/internal/planetscale" ) // Options represents the options to create a new Postgres role diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go index 296c3eb91..df7143b93 100644 --- a/internal/sqlquery/sqlquery.go +++ b/internal/sqlquery/sqlquery.go @@ -14,9 +14,9 @@ import ( "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/passwordutil" + ps "github.com/planetscale/cli/internal/planetscale" "github.com/planetscale/cli/internal/proxyutil" "github.com/planetscale/cli/internal/roleutil" - ps "github.com/planetscale/planetscale-go/planetscale" "vitess.io/vitess/go/mysql" )