Skip to content
Closed
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
- `--log-level <level>` - Set log level (trace, debug, info, warn, error, fatal, print)
- `--project <id-or-name>` - Scope requests to a project by ID or exact name (or set `KERNEL_PROJECT`). Project-scoped OAuth tokens cannot switch projects.

### Delete errors

Resource delete commands return a nonzero exit status and show the API error when a request
fails, including HTTP 404 for an invalid project or a missing resource. A 404 is not treated
as successful deletion. This also applies to resource lookups performed before confirmation.
Successful API responses and user-cancelled confirmations still exit successfully.

## JSON Output

Many commands support JSON output for scripting and automation. Use `--output json` or `-o json` to get machine-readable output:
Expand Down
3 changes: 0 additions & 3 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,6 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {
}

if err := c.apiKeys.Delete(ctx, in.ID); err != nil {
if util.IsNotFound(err) {
return fmt.Errorf("API key %q not found", in.ID)
}
return util.CleanedUpSdkError{Err: err}
}

Expand Down
4 changes: 3 additions & 1 deletion cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,9 @@ func TestAPIKeysDeleteReturnsNotFoundError(t *testing.T) {

err := c.Delete(context.Background(), APIKeysDeleteInput{ID: "missing_key", SkipConfirm: true})
require.Error(t, err)
assert.Contains(t, err.Error(), `API key "missing_key" not found`)
var apiErr *kernel.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusNotFound, apiErr.StatusCode)
}

func TestAPIKeysDeleteReturnsAPIError(t *testing.T) {
Expand Down
4 changes: 0 additions & 4 deletions cmd/auth_connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -739,10 +739,6 @@ func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteIn
}

if err := c.svc.Delete(ctx, in.ID); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Managed auth '%s' not found\n", in.ID)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Deleted managed auth: %s\n", in.ID)
Expand Down
5 changes: 2 additions & 3 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -747,11 +747,10 @@ func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel
}

func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error {
// Treat not found as a success (idempotent delete)
if err := b.browsers.DeleteByID(ctx, in.Identifier); err != nil && !util.IsNotFound(err) {
if err := b.browsers.DeleteByID(ctx, in.Identifier); err != nil {
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Successfully deleted (or already absent) browser: %s\n", in.Identifier)
pterm.Success.Printf("Successfully deleted browser: %s\n", in.Identifier)
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/browsers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1009,7 +1009,7 @@ func TestBrowsersDelete_Success(t *testing.T) {
_ = b.Delete(context.Background(), BrowsersDeleteInput{Identifier: "any"})

out := outBuf.String()
assert.Contains(t, out, "Successfully deleted (or already absent) browser: any")
assert.Contains(t, out, "Successfully deleted browser: any")
}

func TestBrowsersDelete_Failure(t *testing.T) {
Expand Down
4 changes: 0 additions & 4 deletions cmd/credential_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,6 @@ func (c CredentialProvidersCmd) Delete(ctx context.Context, in CredentialProvide
}

if err := c.providers.Delete(ctx, in.ID); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Credential provider '%s' not found\n", in.ID)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Deleted credential provider: %s\n", in.ID)
Expand Down
4 changes: 0 additions & 4 deletions cmd/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,6 @@ func (c CredentialsCmd) Delete(ctx context.Context, in CredentialsDeleteInput) e
}

if err := c.credentials.Delete(ctx, in.Identifier); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Credential '%s' not found\n", in.Identifier)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Deleted credential: %s\n", in.Identifier)
Expand Down
109 changes: 109 additions & 0 deletions cmd/delete_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cmd

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/cli/pkg/util"
kernel "github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestDeleteAPIErrors(t *testing.T) {
commands := []struct {
name string
lookup bool
run func(context.Context, kernel.Client) error
}{
{"browsers", false, func(ctx context.Context, client kernel.Client) error {
return (BrowsersCmd{browsers: &client.Browsers}).Delete(ctx, BrowsersDeleteInput{Identifier: "resource"})
}},
{"profiles lookup", true, func(ctx context.Context, client kernel.Client) error {
return (ProfilesCmd{profiles: &client.Profiles}).Delete(ctx, ProfilesDeleteInput{Identifier: "resource", SkipConfirm: true})
}},
{"profiles delete", false, func(ctx context.Context, client kernel.Client) error {
return (ProfilesCmd{profiles: &client.Profiles}).Delete(ctx, ProfilesDeleteInput{Identifier: "resource", SkipConfirm: true})
}},
{"extensions", false, func(ctx context.Context, client kernel.Client) error {
return (ExtensionsCmd{extensions: &client.Extensions}).Delete(ctx, ExtensionsDeleteInput{Identifier: "resource", SkipConfirm: true})
}},
{"credentials", false, func(ctx context.Context, client kernel.Client) error {
return (CredentialsCmd{credentials: &client.Credentials}).Delete(ctx, CredentialsDeleteInput{Identifier: "resource", SkipConfirm: true})
}},
{"credential providers", false, func(ctx context.Context, client kernel.Client) error {
return (CredentialProvidersCmd{providers: &client.CredentialProviders}).Delete(ctx, CredentialProvidersDeleteInput{ID: "resource", SkipConfirm: true})
}},
{"managed auth", false, func(ctx context.Context, client kernel.Client) error {
return (AuthConnectionCmd{svc: &client.Auth.Connections}).Delete(ctx, AuthConnectionDeleteInput{ID: "resource", SkipConfirm: true})
}},
{"telemetry destinations", false, func(ctx context.Context, client kernel.Client) error {
return (TelemetryDestinationsCmd{destinations: &client.Telemetry.Destinations}).Delete(ctx, TelemetryDestinationsDeleteInput{Identifier: "resource", SkipConfirm: true})
}},
{"api keys", false, func(ctx context.Context, client kernel.Client) error {
return (APIKeysCmd{apiKeys: &client.APIKeys}).Delete(ctx, APIKeysDeleteInput{ID: "resource", SkipConfirm: true})
}},
}
responses := []struct {
status int
body, message string
}{
{404, `{"code":"project_not_found","message":"Project not found or inactive"}`, "project_not_found: Project not found or inactive"},
{404, `{"code":"not_found","message":"Resource not found"}`, "not_found: Resource not found"},
{403, `{"code":"forbidden","message":"Access denied"}`, "forbidden: Access denied"},
{409, `{"code":"conflict","message":"Resource is still in use"}`, "conflict: Resource is still in use"},
{500, `{"code":"internal_error","message":"Deletion failed"}`, "internal_error: Deletion failed"},
{204, "", ""},
}
for _, command := range commands {
for _, response := range responses {
if command.lookup && response.status == http.StatusNoContent {
continue
}
t.Run(fmt.Sprintf("%s/%d/%s", command.name, response.status, response.message), func(t *testing.T) {
buf := capturePtermOutput(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
assert.Equal(t, "selected-project", r.Header.Get("X-Kernel-Project"))
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet && !command.lookup {
assert.Equal(t, "profiles delete", command.name)
_, _ = io.WriteString(w, `{"id":"resource","name":"resource"}`)
return
}
if command.lookup {
assert.Equal(t, http.MethodGet, r.Method)
} else {
assert.Equal(t, http.MethodDelete, r.Method)
}
w.WriteHeader(response.status)
_, _ = io.WriteString(w, response.body)
}))
defer server.Close()
client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithProject("selected-project"), option.WithMaxRetries(0))
err := command.run(context.Background(), client)
if response.status == http.StatusNoContent {
require.NoError(t, err)
assert.Regexp(t, "[Dd]eleted", buf.String())
} else {
var apiErr *kernel.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, response.status, apiErr.StatusCode)
assert.Equal(t, response.message, util.CleanedUpSdkError{Err: err}.Error())
assert.Empty(t, buf.String())
}
expectedCalls := 1
if command.name == "profiles delete" {
expectedCalls = 2
}
assert.Equal(t, expectedCalls, calls)
})
}
}
}
4 changes: 0 additions & 4 deletions cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,6 @@ func (e ExtensionsCmd) Delete(ctx context.Context, in ExtensionsDeleteInput) err
}

if err := e.extensions.Delete(ctx, in.Identifier); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Extension '%s' not found\n", in.Identifier)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Deleted extension: %s\n", in.Identifier)
Expand Down
5 changes: 3 additions & 2 deletions cmd/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ func TestExtensionsDelete_NotFound(t *testing.T) {
return &kernel.Error{StatusCode: http.StatusNotFound}
}}
e := ExtensionsCmd{extensions: fake}
_ = e.Delete(context.Background(), ExtensionsDeleteInput{Identifier: "missing", SkipConfirm: true})
assert.Contains(t, buf.String(), "not found")
err := e.Delete(context.Background(), ExtensionsDeleteInput{Identifier: "missing", SkipConfirm: true})
assert.Error(t, err)
assert.Empty(t, buf.String())
}

func TestExtensionsDownload_MissingOutput(t *testing.T) {
Expand Down
9 changes: 0 additions & 9 deletions cmd/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,8 @@ func (p ProfilesCmd) Create(ctx context.Context, in ProfilesCreateInput) error {
}

func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error {
// Resolve using Get first; treat not found as success with a message
item, err := p.profiles.Get(ctx, in.Identifier)
if err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Profile '%s' not found\n", in.Identifier)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
if item == nil || item.ID == "" {
Expand All @@ -258,10 +253,6 @@ func (p ProfilesCmd) Delete(ctx context.Context, in ProfilesDeleteInput) error {
}

if err := p.profiles.Delete(ctx, in.Identifier); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("Profile '%s' not found\n", in.Identifier)
return nil
}
return util.CleanedUpSdkError{Err: err}
}
pterm.Success.Printf("Deleted profile: %s\n", in.Identifier)
Expand Down
5 changes: 3 additions & 2 deletions cmd/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,9 @@ func TestProfilesDelete_ConfirmNotFound(t *testing.T) {
return nil, &kernel.Error{StatusCode: http.StatusNotFound}
}}
p := ProfilesCmd{profiles: fake}
_ = p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "missing"})
assert.Contains(t, buf.String(), "not found")
err := p.Delete(context.Background(), ProfilesDeleteInput{Identifier: "missing"})
assert.Error(t, err)
assert.Empty(t, buf.String())
}

func TestProfilesDelete_SkipConfirm(t *testing.T) {
Expand Down
10 changes: 1 addition & 9 deletions cmd/proxies/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ func (p ProxyCmd) Delete(ctx context.Context, in ProxyDeleteInput) error {
// Try to get the proxy details for better confirmation message
proxy, err := p.proxies.Get(ctx, in.ID)
if err != nil {
// If we can't get the proxy, just use the ID
if !util.IsNotFound(err) {
return util.CleanedUpSdkError{Err: err}
}
proxy = nil
return util.CleanedUpSdkError{Err: err}
}

var confirmMsg string
Expand All @@ -48,10 +44,6 @@ func (p ProxyCmd) Delete(ctx context.Context, in ProxyDeleteInput) error {

err := p.proxies.Delete(ctx, in.ID)
if err != nil {
if util.IsNotFound(err) {
pterm.Warning.Printf("Proxy '%s' not found\n", in.ID)
return nil
}
return util.CleanedUpSdkError{Err: err}
}

Expand Down
48 changes: 45 additions & 3 deletions cmd/proxies/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ package proxies
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/cli/pkg/interactive"
"github.com/kernel/cli/pkg/util"
"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestProxyDelete_SkipConfirm_Success(t *testing.T) {
Expand Down Expand Up @@ -49,10 +55,46 @@ func TestProxyDelete_SkipConfirm_NotFound(t *testing.T) {
SkipConfirm: true,
})

assert.NoError(t, err) // Not found returns nil
output := buf.String()
assert.Error(t, err)
assert.NotContains(t, buf.String(), "Successfully deleted")
}

assert.Contains(t, output, "Proxy 'not-found' not found")
func TestProxyDeleteAPIErrors(t *testing.T) {
for _, skip := range []bool{false, true} {
for _, response := range []struct {
status int
code string
}{
{404, "project_not_found"}, {404, "not_found"}, {403, "forbidden"}, {409, "conflict"}, {500, "internal_error"},
} {
t.Run(fmt.Sprintf("skip=%t/%s", skip, response.code), func(t *testing.T) {
buf := captureOutput(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
assert.Equal(t, "selected-project", r.Header.Get("X-Kernel-Project"))
if skip {
assert.Equal(t, http.MethodDelete, r.Method)
} else {
assert.Equal(t, http.MethodGet, r.Method)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.status)
_, _ = io.WriteString(w, fmt.Sprintf(`{"code":%q,"message":"API error details"}`, response.code))
}))
defer server.Close()
client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithProject("selected-project"), option.WithMaxRetries(0))
p := ProxyCmd{proxies: &client.Proxies, prompter: interactive.NewPrompterWithTerminal(true)}
err := p.Delete(context.Background(), ProxyDeleteInput{ID: "resource", SkipConfirm: skip})
var apiErr *kernel.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, response.status, apiErr.StatusCode)
assert.Equal(t, response.code+": API error details", util.CleanedUpSdkError{Err: err}.Error())
assert.NotContains(t, buf.String(), "Successfully deleted")
assert.Equal(t, 1, calls)
})
}
}
}

func TestProxyDelete_SkipConfirm_APIError(t *testing.T) {
Expand Down
4 changes: 0 additions & 4 deletions cmd/telemetry_destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,6 @@ func (c TelemetryDestinationsCmd) Delete(ctx context.Context, in TelemetryDestin
}

if err := c.destinations.Delete(ctx, in.Identifier); err != nil {
if util.IsNotFound(err) {
pterm.Info.Printf("OTLP destination '%s' not found\n", in.Identifier)
return nil
}
// A 409 here means the destination is still referenced; the API's own
// message names what still holds it, so it is surfaced as-is.
return util.CleanedUpSdkError{Err: err}
Expand Down
4 changes: 2 additions & 2 deletions cmd/telemetry_destinations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ func TestTelemetryDestinationsDelete_NotFound(t *testing.T) {
return &kernel.Error{StatusCode: http.StatusNotFound}
}}
c := TelemetryDestinationsCmd{destinations: fake}
require.NoError(t, c.Delete(context.Background(), TelemetryDestinationsDeleteInput{Identifier: "nope", SkipConfirm: true}))
assert.Contains(t, buf.String(), "not found")
require.Error(t, c.Delete(context.Background(), TelemetryDestinationsDeleteInput{Identifier: "nope", SkipConfirm: true}))
assert.Empty(t, buf.String())
}

func TestTelemetryDestinationsDelete_NonInteractiveWithoutYes(t *testing.T) {
Expand Down
Loading