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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,18 @@ completion Generate shell completion script

Use `bwh <command> --help` to view detailed options and usage examples for each command.

### Abuse and Notification Writes
### Write API Safety

```bash
bwh reinstall --os debian-12-x86_64 --dry-run
bwh reset-password --dry-run
bwh ssh set "ssh-ed25519 AAAA..." --dry-run
bwh migrate start us-west --dry-run
bwh abuse unsuspend <record_id> --dry-run
bwh abuse resolve-policy <record_id> --dry-run
bwh notifications set <preference_id> <on|off> --dry-run
```

Use `--dry-run` to validate and preview without calling write APIs. Add `--yes` only when you want to skip the y/N prompt.
Most commands that call KiwiVM write APIs support `--dry-run` to validate and preview without calling the write API. Add `--yes` only when you want to skip the y/N prompt. Existing `--force` flags on dangerous commands such as `kill` and `reinstall` remain supported for compatibility.

## Build

Expand Down
9 changes: 6 additions & 3 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,15 +261,18 @@ completion 生成 shell 自动补全脚本

使用 `bwh <command> --help` 查看每个命令的详细选项和用法示例。

### Abuse 与通知写命令
### 写 API 安全

```bash
bwh reinstall --os debian-12-x86_64 --dry-run
bwh reset-password --dry-run
bwh ssh set "ssh-ed25519 AAAA..." --dry-run
bwh migrate start us-west --dry-run
bwh abuse unsuspend <record_id> --dry-run
bwh abuse resolve-policy <record_id> --dry-run
bwh notifications set <preference_id> <on|off> --dry-run
```

使用 `--dry-run` 做校验和预览,不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。
大多数会调用 KiwiVM 写 API 的命令都支持 `--dry-run`,用于校验和预览,但不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。`kill`、`reinstall` 等危险命令原有的 `--force` 仍保留以兼容旧脚本

## 构建

Expand Down
56 changes: 18 additions & 38 deletions cmd/bwh/abuse.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,6 @@ var abuseCmd = &cli.Command{
},
}

var abuseWriteFlags = []cli.Flag{
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "skip confirmation prompt",
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "validate and show the write action without calling the write API",
},
}

var abuseSuspensionsCmd = &cli.Command{
Name: "suspensions",
Usage: "show service suspension details",
Expand Down Expand Up @@ -78,7 +66,7 @@ var abuseUnsuspendCmd = &cli.Command{
Name: "unsuspend",
Usage: "clear a soft abuse issue and unsuspend the VPS",
ArgsUsage: "<record_id>",
Flags: abuseWriteFlags,
Flags: writeFlags(),
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() != 1 {
return fmt.Errorf("record_id is required")
Expand All @@ -93,15 +81,15 @@ var abuseUnsuspendCmd = &cli.Command{
return err
}

return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation)
return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation)
},
}

var abuseResolvePolicyCmd = &cli.Command{
Name: "resolve-policy",
Usage: "mark a soft policy violation as resolved",
ArgsUsage: "<record_id>",
Flags: abuseWriteFlags,
Flags: writeFlags(),
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() != 1 {
return fmt.Errorf("record_id is required")
Expand All @@ -116,7 +104,7 @@ var abuseResolvePolicyCmd = &cli.Command{
return err
}

return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation)
return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation)
},
}

Expand All @@ -127,8 +115,6 @@ type abuseAPI interface {
ResolvePolicyViolation(context.Context, int) error
}

type confirmationFunc func(string) (bool, error)

func displaySuspensionDetails(resp *client.SuspensionDetailsResponse) {
fmt.Printf("\n🚫 SUSPENSION DETAILS\n")
fmt.Printf(" Suspensions (YTD): %d\n", resp.SuspensionCount)
Expand Down Expand Up @@ -226,18 +212,15 @@ func runAbuseUnsuspend(ctx context.Context, api abuseAPI, resolvedName string, r
return fmt.Errorf("suspension case #%d cannot be resolved through API; contact support", recordID)
}
if dryRun {
fmt.Printf("DRY RUN: would call unsuspend for case #%d on instance %s\n", recordID, resolvedName)
printDryRun("unsuspend", resolvedName, fmt.Sprintf("case: #%d", recordID))
return nil
}
if !skipConfirm {
confirmed, err := confirm(fmt.Sprintf("Unsuspend VPS by clearing case #%d?", recordID))
if err != nil {
return err
}
if !confirmed {
fmt.Printf("Operation cancelled\n")
return nil
}
confirmed, err := confirmWrite(fmt.Sprintf("Unsuspend VPS by clearing case #%d?", recordID), skipConfirm, confirm)
if err != nil {
return err
}
if !confirmed {
return nil
}

if err := api.Unsuspend(ctx, recordID); err != nil {
Expand All @@ -263,18 +246,15 @@ func runAbuseResolvePolicy(ctx context.Context, api abuseAPI, resolvedName strin
return fmt.Errorf("policy violation case #%d cannot be resolved through API; contact support", recordID)
}
if dryRun {
fmt.Printf("DRY RUN: would call resolvePolicyViolation for case #%d on instance %s\n", recordID, resolvedName)
printDryRun("resolvePolicyViolation", resolvedName, fmt.Sprintf("case: #%d", recordID))
return nil
}
if !skipConfirm {
confirmed, err := confirm(fmt.Sprintf("Mark policy violation case #%d as resolved?", recordID))
if err != nil {
return err
}
if !confirmed {
fmt.Printf("Operation cancelled\n")
return nil
}
confirmed, err := confirmWrite(fmt.Sprintf("Mark policy violation case #%d as resolved?", recordID), skipConfirm, confirm)
if err != nil {
return err
}
if !confirmed {
return nil
}

if err := api.ResolvePolicyViolation(ctx, recordID); err != nil {
Expand Down
98 changes: 51 additions & 47 deletions cmd/bwh/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,70 +104,74 @@ var backupCopyToSnapshotCmd = &cli.Command{
Aliases: []string{"cts"},
Usage: "copy a backup to a restorable snapshot",
ArgsUsage: "<backup_token>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "skip confirmation prompt",
},
},
Flags: writeFlags(),
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() != 1 {
return fmt.Errorf("backup token is required")
}
backupToken := cmd.Args().First()

// Validate backup token format before making API calls
if err := validateBackupToken(backupToken); err != nil {
return err
}

bwhClient, resolvedName, err := createBWHClient(cmd)
if err != nil {
return err
}

// First, verify the backup exists by listing backups
backupsResp, err := bwhClient.ListBackups(ctx)
if err != nil {
return fmt.Errorf("failed to list backups: %w", err)
}
return runBackupCopyToSnapshot(ctx, bwhClient, resolvedName, backupToken, cmd.Bool("dry-run"), skipConfirm(cmd), promptConfirmation)
},
}

backup, exists := backupsResp.Backups[backupToken]
if !exists {
return fmt.Errorf("backup with token '%s' not found", backupToken)
}
type backupCopyAPI interface {
ListBackups(context.Context) (*client.BackupListResponse, error)
CopyBackupToSnapshot(context.Context, string) error
}

// Show backup info for confirmation
fmt.Printf("Target backup for instance '%s':\n", resolvedName)
fmt.Printf(" Token : %s\n", backupToken)
fmt.Printf(" OS : %s\n", backup.OS)
fmt.Printf(" Size : %s\n", formatBytes(backup.Size))
fmt.Printf(" MD5 Hash : %s\n", backup.MD5)
fmt.Printf(" Created : %s\n", time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05"))
func runBackupCopyToSnapshot(ctx context.Context, api backupCopyAPI, resolvedName, backupToken string, dryRun, skipConfirm bool, confirm confirmationFunc) error {
if err := validateBackupToken(backupToken); err != nil {
return err
}

if !cmd.Bool("yes") {
fmt.Printf("\n⚠️ Are you sure you want to copy this backup to a snapshot?\n")
fmt.Printf("This will create a new restorable snapshot from the backup.\n")
confirmed, err := promptConfirmation("Continue?")
if err != nil {
return err
}
if !confirmed {
fmt.Printf("Operation cancelled\n")
return nil
}
}
backupsResp, err := api.ListBackups(ctx)
if err != nil {
return fmt.Errorf("failed to list backups: %w", err)
}

fmt.Printf("\nCopying backup to snapshot for instance: %s\n", resolvedName)
backup, exists := backupsResp.Backups[backupToken]
if !exists {
return fmt.Errorf("backup with token '%s' not found", maskSensitive(backupToken))
}

if err := bwhClient.CopyBackupToSnapshot(ctx, backupToken); err != nil {
return fmt.Errorf("failed to copy backup to snapshot: %w", err)
}
fmt.Printf("Target backup for instance '%s':\n", resolvedName)
fmt.Printf(" Token : %s\n", maskSensitive(backupToken))
fmt.Printf(" OS : %s\n", backup.OS)
fmt.Printf(" Size : %s\n", formatBytes(backup.Size))
fmt.Printf(" MD5 Hash : %s\n", backup.MD5)
fmt.Printf(" Created : %s\n", time.Unix(backup.Timestamp, 0).Format("2006-01-02 15:04:05"))

fmt.Printf("✅ Backup successfully copied to snapshot\n")
fmt.Printf("💡 Use 'bwh snapshot list' to see the new snapshot\n")
if dryRun {
printDryRun("backup/copyToSnapshot", resolvedName, fmt.Sprintf("backupToken: %s", maskSensitive(backupToken)))
return nil
}

if !skipConfirm {
fmt.Printf("\n⚠️ Are you sure you want to copy this backup to a snapshot?\n")
fmt.Printf("This will create a new restorable snapshot from the backup.\n")
}
confirmed, err := confirmWrite("Continue?", skipConfirm, confirm)
if err != nil {
return err
}
if !confirmed {
return nil
},
}

fmt.Printf("\nCopying backup to snapshot for instance: %s\n", resolvedName)

if err := api.CopyBackupToSnapshot(ctx, backupToken); err != nil {
return fmt.Errorf("failed to copy backup to snapshot: %w", err)
}

fmt.Printf("✅ Backup successfully copied to snapshot\n")
fmt.Printf("💡 Use 'bwh snapshot list' to see the new snapshot\n")

return nil
}
Loading
Loading