Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
85 changes: 84 additions & 1 deletion cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/pterm/pterm"
"github.com/samber/lo"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)

var appCmd = &cobra.Command{
Expand All @@ -25,7 +26,14 @@ var appListCmd = &cobra.Command{
RunE: runAppList,
}

// --- app history subcommand (scaffold)
var appDeleteCmd = &cobra.Command{
Use: "delete <app_name>",
Short: "Delete an app and all its deployments",
Long: "Deletes all deployments for an application. Use --version to scope deletion to a specific version.",
Args: cobra.ExactArgs(1),
RunE: runAppDelete,
}

var appHistoryCmd = &cobra.Command{
Use: "history <app_name>",
Short: "Show deployment history for an application",
Expand All @@ -36,8 +44,13 @@ var appHistoryCmd = &cobra.Command{
func init() {
// register subcommands under app
appCmd.AddCommand(appListCmd)
appCmd.AddCommand(appDeleteCmd)
appCmd.AddCommand(appHistoryCmd)

// Flags for delete
appDeleteCmd.Flags().String("version", "", "Only delete deployments for this version (default: all versions)")
appDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

// Add optional filters for list
appListCmd.Flags().String("name", "", "Filter by application name")
appListCmd.Flags().String("version", "", "Filter by version label")
Expand Down Expand Up @@ -206,6 +219,76 @@ func runAppList(cmd *cobra.Command, args []string) error {
return nil
}

func runAppDelete(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
appName := args[0]
version, _ := cmd.Flags().GetString("version")
skipConfirm, _ := cmd.Flags().GetBool("yes")

params := kernel.DeploymentListParams{
AppName: kernel.Opt(appName),
Limit: kernel.Opt(int64(100)),
Offset: kernel.Opt(int64(0)),
}
if version != "" {
params.AppVersion = kernel.Opt(version)
}

initial, err := client.Deployments.List(cmd.Context(), params)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this initial list fetch is only used as an existence check and then thrown away — the deletion loop re-lists from offset 0 anyway. could skip this entirely and just check deleted == 0 after the loop to print "no deployments found"

if err != nil {
return util.CleanedUpSdkError{Err: err}
}
if initial == nil || len(initial.Items) == 0 {
pterm.Info.Printf("No deployments found for app '%s'\n", appName)
return nil
}

if !skipConfirm {
scope := "all versions"
if version != "" {
scope = fmt.Sprintf("version '%s'", version)
}
msg := fmt.Sprintf("Delete all deployments for app '%s' (%s)? This cannot be undone.", appName, scope)
pterm.DefaultInteractiveConfirm.DefaultText = msg
ok, _ := pterm.DefaultInteractiveConfirm.Show()
if !ok {
pterm.Info.Println("Deletion cancelled")
return nil
}
}

spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Deleting deployments for app '%s'...", appName))
deleted := 0

for {
page, err := client.Deployments.List(cmd.Context(), params)
if err != nil {
spinner.Fail("Failed to list deployments")
return util.CleanedUpSdkError{Err: err}
}
items := page.Items
if len(items) == 0 {
break
}

g, gctx := errgroup.WithContext(cmd.Context())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: no concurrency limit on the errgroup — this fires up to 100 parallel deletes per page. g.SetLimit(10) or similar would be friendlier to the API

for _, dep := range items {
g.Go(func() error {
return client.Deployments.Delete(gctx, dep.ID)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch delete missing not-found handling causes unnecessary failures

Medium Severity

The batch delete goroutines in runAppDelete propagate not-found errors as failures, while every other delete path in the codebase — including runDeployDelete in the same diff — treats not-found as success (idempotent delete). If a deployment is removed between the List call and the Delete call (e.g., by a concurrent user or auto-cleanup), the not-found error from one goroutine causes g.Wait() to fail, cancels the gctx context (aborting other in-flight deletes), and aborts the entire operation unnecessarily.

Fix in Cursor Fix in Web

}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded parallel deletes may overwhelm the API

Medium Severity

The errgroup created for each page of deployments has no concurrency limit, so up to 100 Delete calls fire simultaneously per batch. This can overwhelm the API with concurrent requests, triggering rate limiting or transient failures that cause the entire batch to abort. Adding a concurrency limit via g.SetLimit() would make this much safer.

Fix in Cursor Fix in Web

if err := g.Wait(); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if one delete in the batch fails, g.Wait() returns early but some deletes may have already succeeded. might be worth printing the deleted count in the error path too, so the user knows partial progress happened

spinner.Fail("Failed to delete deployments")
return util.CleanedUpSdkError{Err: err}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch delete error discards partial progress count

Medium Severity

When g.Wait() returns an error during the batch deletion loop, the function reports "Failed to delete deployments" and returns — but the deleted count from prior successful iterations is silently discarded. For a destructive, irreversible operation, the user has no way to know whether 0 or hundreds of deployments were already removed, making recovery difficult.

Fix in Cursor Fix in Web

deleted += len(items)
spinner.UpdateText(fmt.Sprintf("Deleted %d deployment(s) so far...", deleted))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletion loop may infinite-loop on async deletes

High Severity

The deletion loop always re-lists deployments at Offset 0. The PR description states that Delete "stops a running deployment and marks it for deletion" rather than deleting immediately. If marked-for-deletion deployments still appear in list results, the loop will keep fetching the same items and re-attempting deletion indefinitely. The offset is never advanced, so there's no forward progress when items linger in the list after a delete call.

Additional Locations (1)

Fix in Cursor Fix in Web


spinner.Success(fmt.Sprintf("Deleted %d deployment(s) for app '%s'", deleted, appName))
return nil
}

func runAppHistory(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
appName := args[0]
Expand Down
38 changes: 38 additions & 0 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import (
"github.com/spf13/cobra"
)

var deployDeleteCmd = &cobra.Command{
Use: "delete <deployment_id>",
Short: "Delete a deployment",
Long: "Stops a running deployment and marks it for deletion. If already stopped or failed, deletes immediately.",
Args: cobra.ExactArgs(1),
RunE: runDeployDelete,
}

var deployLogsCmd = &cobra.Command{
Use: "logs <deployment_id>",
Short: "Stream logs for a deployment",
Expand Down Expand Up @@ -65,6 +73,9 @@ func init() {
deployLogsCmd.Flags().BoolP("with-timestamps", "t", false, "Include timestamps in each log line")
deployCmd.AddCommand(deployLogsCmd)

deployDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
deployCmd.AddCommand(deployDeleteCmd)

deployHistoryCmd.Flags().Int("limit", 20, "Max deployments to return (default 20)")
deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)")
deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)")
Expand Down Expand Up @@ -306,6 +317,33 @@ func quoteIfNeeded(s string) string {
return s
}

func runDeployDelete(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
deploymentID := args[0]
skipConfirm, _ := cmd.Flags().GetBool("yes")

if !skipConfirm {
msg := fmt.Sprintf("Are you sure you want to delete deployment '%s'? This cannot be undone.", deploymentID)
pterm.DefaultInteractiveConfirm.DefaultText = msg
ok, _ := pterm.DefaultInteractiveConfirm.Show()
if !ok {
pterm.Info.Println("Deletion cancelled")
return nil
}
}

if err := client.Deployments.Delete(cmd.Context(), deploymentID); err != nil {
if util.IsNotFound(err) {
pterm.Warning.Printf("Deployment '%s' not found\n", deploymentID)
return nil
}
return util.CleanedUpSdkError{Err: err}
}

pterm.Success.Printf("Deleted deployment %s\n", deploymentID)
return nil
}

func runDeployLogs(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)

Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/joho/godotenv v1.5.1
github.com/kernel/kernel-go-sdk v0.35.0
github.com/kernel/kernel-go-sdk v0.36.2-0.20260221214548-0bf19a19dfd7
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
github.com/samber/lo v1.51.0
Expand All @@ -19,6 +19,7 @@ require (
github.com/zalando/go-keyring v0.2.6
golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.19.0
)

require (
Expand Down Expand Up @@ -53,7 +54,6 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kernel/kernel-go-sdk v0.35.0 h1:zQcDPxq7N1njnNVoFmxvi3XMKoqemOVlnkVYuYPqAE0=
github.com/kernel/kernel-go-sdk v0.35.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/kernel/kernel-go-sdk v0.36.2-0.20260221214548-0bf19a19dfd7 h1:0evHNgffqBgtPMqbYopZ0Y7RYTvCvIrt3zfmKBpOmaY=
github.com/kernel/kernel-go-sdk v0.36.2-0.20260221214548-0bf19a19dfd7/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
Expand Down