Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 9 additions & 50 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ limitations under the License.
package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -127,28 +125,26 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver,
return 0
}

var cmdErr *cmdutil.Error
if errors.As(err, &cmdErr) && cmdErr.Handled {
if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok && cmdErr.Handled {
if cmdErr.ExitCode != 0 {
return cmdErr.ExitCode
}
return cmdutil.ActionRequestedExitCode
}

if isRootOrgFlagError(err) {
return printRootOrgFlagError(format, err)
if format == printer.JSON {
return cmdutil.ReportGlobalJSONError(os.Stdout, os.Stderr, err)
}

// print any user specific messages first
switch format {
case printer.JSON:
fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err)
default:
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
if isRootOrgFlagError(err) {
fmt.Fprintf(os.Stderr, "Error: %s\nHint: --org belongs on resource commands, not on pscale root. Example: pscale database list --org <org> --format json\n", err)
return cmdutil.FatalErrExitCode
}

fmt.Fprintf(os.Stderr, "Error: %s\n", err)

// check if a sub command wants to return a specific exit code
if errors.As(err, &cmdErr) {
if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok {
return cmdErr.ExitCode
}

Expand All @@ -162,43 +158,6 @@ func isRootOrgFlagError(err error) bool {
return strings.Contains(err.Error(), "unknown flag: --org")
}

func printRootOrgFlagError(format printer.Format, err error) int {
const hint = "--org belongs on resource commands, not on pscale root. Example: pscale database list --org <org> --format json"

if format == printer.JSON {
resp := struct {
Status string `json:"status"`
Code string `json:"code"`
Error string `json:"error"`
Hint string `json:"hint"`
NextSteps []string `json:"next_steps"`
}{
Status: "error",
Code: "INVALID_FLAG_PLACEMENT",
Error: err.Error(),
Hint: hint,
NextSteps: []string{
cmdutil.AgentGuideCmd(),
cmdutil.AgentAuthCheckCmd(),
cmdutil.AgentDatabaseListCmd("<org>"),
},
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if marshalErr := enc.Encode(resp); marshalErr != nil {
fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err)
return cmdutil.FatalErrExitCode
}
fmt.Fprint(os.Stdout, buf.String())
return cmdutil.FatalErrExitCode
}

fmt.Fprintf(os.Stderr, "Error: %s\nHint: %s\n", err, hint)
return cmdutil.FatalErrExitCode
}

// runCmd adds all child commands to the root command, sets flags
// appropriately, and runs the root command.
func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer.Format, debug *bool, sigc chan os.Signal, signals []os.Signal) error {
Expand Down
17 changes: 10 additions & 7 deletions internal/cmd/workflow/cancel.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,24 @@ marks it as cancelled, allowing you to start a new workflow if needed.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
db, num := args[0], args[1]
wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num}

client, err := ch.Client()
if err != nil {
return err
return wfCtx.handle(ch, err)
}

var number uint64
number, err = strconv.ParseUint(num, 10, 64)
if err != nil {
return err
return wfCtx.handle(ch, err)
}

if !force {
if ch.Printer.Format() != printer.Human {
return fmt.Errorf(`cannot cancel workflow with the output format "%s" (run with --force to override)`, ch.Printer.Format())
if ch.Printer.Format() == printer.JSON {
return wfCtx.reportConfirmationRequired(ch, "CANCEL_CONFIRMATION_REQUIRED",
"Cancel stops the workflow and marks it as cancelled",
cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cancel", "--force"))
}

if !printer.IsTTY {
Expand Down Expand Up @@ -78,10 +81,10 @@ marks it as cancelled, allowing you to start a new workflow if needed.`,
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))
return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)))
default:
return cmdutil.HandleError(err)
return wfCtx.handle(ch, cmdutil.HandleError(err))
}
}
end()
Expand Down
11 changes: 6 additions & 5 deletions internal/cmd/workflow/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
db, num := args[0], args[1]
wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num}

client, err := ch.Client()
if err != nil {
return err
return wfCtx.handle(ch, err)
}

var number uint64
number, err = strconv.ParseUint(num, 10, 64)
if err != nil {
return err
return wfCtx.handle(ch, err)
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Marking workflow %s in database %s as complete…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db)))
Expand All @@ -42,10 +43,10 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))
return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)))
default:
return cmdutil.HandleError(err)
return wfCtx.handle(ch, cmdutil.HandleError(err))
}
}
end()
Expand Down
28 changes: 24 additions & 4 deletions internal/cmd/workflow/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,29 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
db, branch := args[0], args[1]
org := ch.Config.Organization

client, err := ch.Client()
if err != nil {
return err
return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err)
}

org := ch.Config.Organization

if flags.interactive {
return createInteractive(ctx, ch, org, db, branch, flags)
}

if err := validateCreateFlags(flags); err != nil {
return reportMissingCreateFlags(ch, org, db, branch, err)
}

end := ch.Printer.PrintProgress("Creating workflow…")
defer end()

workflow, err := createWorkflow(ctx, client, org, db, branch, flags)
end()

if err != nil {
return err
return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err)
}

if ch.Printer.Format() == printer.Human {
Expand Down Expand Up @@ -111,6 +114,23 @@ func createWorkflow(ctx context.Context, client *ps.Client, org, db, branch stri
return workflow, nil
}

func validateCreateFlags(flags createFlags) error {
var missing []string
if flags.sourceKeyspace == "" {
missing = append(missing, "--source-keyspace")
}
if flags.targetKeyspace == "" {
missing = append(missing, "--target-keyspace")
}
if len(flags.tables) == 0 {
missing = append(missing, "--tables")
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))
}

func createInteractive(ctx context.Context, ch *cmdutil.Helper, org, db, branch string, flags createFlags) error {
client, err := ch.Client()
if err != nil {
Expand Down
23 changes: 13 additions & 10 deletions internal/cmd/workflow/cutover.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,24 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
db, num := args[0], args[1]
wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num}

client, err := ch.Client()
if err != nil {
return err
return wfCtx.handle(ch, err)
}

var number uint64
number, err = strconv.ParseUint(num, 10, 64)
if err != nil {
return err
return wfCtx.handle(ch, err)
}

if !force {
if ch.Printer.Format() != printer.Human {
return fmt.Errorf(`cannot cutover with the output format "%s" (run with --force to override)`, ch.Printer.Format())
if ch.Printer.Format() == printer.JSON {
return wfCtx.reportConfirmationRequired(ch, "CUTOVER_CONFIRMATION_REQUIRED",
"Cutover deletes moved tables from the source keyspace and ends replication",
cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cutover", "--force"))
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

if !printer.IsTTY {
Expand All @@ -54,10 +57,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))
return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)))
default:
return cmdutil.HandleError(err)
return wfCtx.handle(ch, cmdutil.HandleError(err))
}
}

Expand Down Expand Up @@ -93,10 +96,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))
return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s",
printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)))
default:
return cmdutil.HandleError(err)
return wfCtx.handle(ch, cmdutil.HandleError(err))
}
}
end()
Expand Down
107 changes: 107 additions & 0 deletions internal/cmd/workflow/json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package workflow

import (
"strings"

"github.com/planetscale/cli/internal/cmdutil"
"github.com/planetscale/cli/internal/printer"
ps "github.com/planetscale/planetscale-go/planetscale"
)

// errorContext carries workflow command context so JSON error responses can
// include remediation commands with real values instead of placeholders.
type errorContext struct {
Org string
Database string
Branch string
Number string
}

// handle converts err into a structured JSON error when the printer is in
// JSON mode; in human mode it returns err unchanged.
func (c errorContext) handle(ch *cmdutil.Helper, err error) error {
if err == nil || ch.Printer.Format() != printer.JSON {
return err
}
if cmdutil.ErrCode(err) == ps.ErrNotFound || strings.Contains(err.Error(), "does not exist") {
return c.report(ch, "error", "NOT_FOUND", err.Error(), c.notFoundNextSteps())
}
return c.report(ch, "error", "WORKFLOW_ERROR", err.Error(), c.defaultNextSteps())
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

// reportConfirmationRequired reports a confirmation-gated action in JSON mode.
// Callers gate on JSON format before calling.
func (c errorContext) reportConfirmationRequired(ch *cmdutil.Helper, code, message, retryCmd string) error {
return c.report(ch, "action_required", code, message, []string{
"Ask the user to approve this workflow action",
retryCmd,
})
}

func reportMissingCreateFlags(ch *cmdutil.Helper, org, database, branch string, err error) error {
if ch.Printer.Format() != printer.JSON {
return err
}
c := errorContext{Org: org, Database: database, Branch: branch}
return c.report(ch, "action_required", "MISSING_FLAGS", err.Error(), []string{
cmdutil.AgentKeyspaceListCmd(org, database, branch),
cmdutil.AgentWorkflowCreateCmd(org, database, branch),
})
}

func (c errorContext) report(ch *cmdutil.Helper, status, code, message string, nextSteps []string) error {
payload := map[string]any{
"status": status,
"error": message,
"issues": []map[string]string{
{
"code": code,
"message": message,
},
},
"next_steps": nextSteps,
}
if c.Database != "" {
payload["database"] = c.Database
}
if c.Branch != "" {
payload["branch"] = c.Branch
}
if c.Number != "" {
payload["workflow_number"] = c.Number
}

if err := ch.Printer.PrintJSON(payload); err != nil {
return err
}
exitCode := cmdutil.FatalErrExitCode
if status == "action_required" {
exitCode = cmdutil.ActionRequestedExitCode
}
return cmdutil.JSONReportedError(exitCode)
}

func (c errorContext) notFoundNextSteps() []string {
steps := []string{
cmdutil.AgentDatabaseListCmd(c.Org),
cmdutil.AgentWorkflowListCmd(c.Org, c.Database),
}
if c.Branch != "" {
steps = append(steps, cmdutil.AgentKeyspaceListCmd(c.Org, c.Database, c.Branch))
}
if c.Number != "" {
steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number))
}
return steps
}

func (c errorContext) defaultNextSteps() []string {
steps := []string{cmdutil.AgentAuthCheckCmd()}
if c.Database != "" {
steps = append(steps, cmdutil.AgentWorkflowListCmd(c.Org, c.Database))
if c.Number != "" {
steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number))
}
}
return steps
}
Loading