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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
- `--version`, `-v` - Print the CLI version
- `--no-color` - Disable color output
- `--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.
- `--project <id-or-name>` - Scope requests to a project by ID or exact name (or set `KERNEL_PROJECT`). A non-empty flag overrides the environment variable. The selector is sent to the API as `X-Kernel-Project`, without a client-side project lookup. Project-scoped API keys and OAuth tokens cannot switch projects.

## JSON Output

Expand Down
32 changes: 6 additions & 26 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"os"
"path/filepath"
Expand Down Expand Up @@ -179,11 +177,6 @@ func runDeployGithub(cmd *cobra.Command, args []string) error {
startTime := time.Now()

// Manually POST multipart with a JSON 'source' field to match backend expectations
apiKey := os.Getenv("KERNEL_API_KEY")
if strings.TrimSpace(apiKey) == "" {
return fmt.Errorf("KERNEL_API_KEY is required for github deploy")
}
baseURL := util.GetBaseURL()
if region == "" {
region = string(kernel.DeploymentNewParamsRegionAwsUsEast1a)
}
Expand Down Expand Up @@ -230,30 +223,17 @@ func runDeployGithub(cmd *cobra.Command, args []string) error {
_, _ = part.Write(srcJSON)
_ = mw.Close()

reqHTTP, _ := http.NewRequestWithContext(cmd.Context(), http.MethodPost, strings.TrimRight(baseURL, "/")+"/deployments", &body)
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", mw.FormDataContentType())
httpResp, err := http.DefaultClient.Do(reqHTTP)
if err != nil {
return fmt.Errorf("post deployments: %w", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
b, _ := io.ReadAll(httpResp.Body)
return fmt.Errorf("deployments POST failed: %s: %s", httpResp.Status, strings.TrimSpace(string(b)))
}
var depCreated struct {
ID string `json:"id"`
}
if err := json.NewDecoder(httpResp.Body).Decode(&depCreated); err != nil {
return fmt.Errorf("decode deployment response: %w", err)
if err := client.Post(cmd.Context(), "deployments", nil, &depCreated,
option.WithRequestBody(mw.FormDataContentType(), &body),
option.WithMaxRetries(0),
); err != nil {
return fmt.Errorf("post deployments: %w", err)
}

return followDeployment(cmd.Context(), client, depCreated.ID, startTime, output,
option.WithBaseURL(baseURL),
option.WithHeader("Authorization", "Bearer "+apiKey),
option.WithMaxRetries(0),
)
return followDeployment(cmd.Context(), client, depCreated.ID, startTime, output, option.WithMaxRetries(0))
}

func runDeploy(cmd *cobra.Command, args []string) (err error) {
Expand Down
159 changes: 159 additions & 0 deletions cmd/project_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cmd

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/kernel/cli/pkg/auth"
kernel "github.com/kernel/kernel-go-sdk"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)

func projectTestCommand(t *testing.T, serverURL, project string) *cobra.Command {
t.Helper()
t.Setenv("KERNEL_BASE_URL", serverURL)
t.Setenv("KERNEL_API_KEY", "test-api-key")
cmd := &cobra.Command{Use: "request"}
cmd.SetContext(context.Background())
cmd.Flags().String("project", project, "")
cmd.Flags().String("log-level", "warn", "")
cmd.Flags().Bool("no-color", false, "")
return cmd
}

func TestProjectSelectionHeaders(t *testing.T) {
for _, tt := range []struct {
name, flag, env, want string
}{
{name: "flag ID", flag: "proj_123", want: "proj_123"},
{name: "flag exact name", flag: "Release QA", want: "Release QA"},
{name: "environment ID", env: "proj_123", want: "proj_123"},
{name: "environment exact name", env: "Release QA", want: "Release QA"},
{name: "flag wins", flag: "Release QA", env: "proj_other", want: "Release QA"},
{name: "legacy name delimiters", flag: "QA/100%", want: "QA/100%"},
{name: "no selection"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("KERNEL_PROJECT", tt.env)
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
assert.Equal(t, tt.want, r.Header.Get("X-Kernel-Project"))
assert.Empty(t, r.Header.Get("X-Kernel-Project-Id"))
assert.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{}`)
}))
defer server.Close()
cmd := projectTestCommand(t, server.URL, tt.flag)
require.NoError(t, rootCmd.PersistentPreRunE(cmd, nil))
client := getKernelClient(cmd)
_, err := client.Browsers.Get(cmd.Context(), "browser_123", kernel.BrowserGetParams{})
require.NoError(t, err)
_, err = client.Profiles.Get(cmd.Context(), "profile_123")
require.NoError(t, err)
_, err = client.Proxies.Get(cmd.Context(), "proxy_123")
require.NoError(t, err)
_, err = client.Auth.Connections.Get(cmd.Context(), "connection_123")
require.NoError(t, err)
assert.Equal(t, []string{"/browsers/browser_123", "/profiles/profile_123", "/proxies/proxy_123", "/auth/connections/connection_123"}, paths)
})
}
}

func TestDeployGithubProjectScope(t *testing.T) {
for _, tt := range []struct {
name, flag, env, want string
oauth bool
status int
}{
{name: "flag ID", flag: "proj_123", want: "proj_123"},
{name: "flag exact name", flag: "Release QA", want: "Release QA"},
{name: "environment ID", env: "proj_123", want: "proj_123"},
{name: "environment exact name", env: "Release QA", want: "Release QA"},
{name: "flag wins", flag: "Release QA", env: "proj_other", want: "Release QA"},
{name: "no selection"},
{name: "project OAuth", flag: "Release QA", want: "Release QA", oauth: true},
{name: "server error without retry", flag: "proj_123", want: "proj_123", status: http.StatusInternalServerError},
{name: "invalid ID", flag: "proj_missing", want: "proj_missing", status: http.StatusNotFound},
{name: "invalid name", env: "missing project", want: "missing project", status: http.StatusNotFound},
{name: "credential scope mismatch", flag: "Other Project", want: "Other Project", oauth: true, status: http.StatusForbidden},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("KERNEL_PROJECT", tt.env)
wantAuth := "Bearer test-api-key"
if tt.oauth {
wantAuth = "Bearer test-oauth-token"
}
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
assert.Equal(t, tt.want, r.Header.Get("X-Kernel-Project"))
assert.Empty(t, r.Header.Get("X-Kernel-Project-Id"))
assert.Equal(t, wantAuth, r.Header.Get("Authorization"))
assert.Equal(t, metadata.Version, r.Header.Get("X-Kernel-Cli-Version"))
switch r.URL.Path {
case "/deployments":
assert.Equal(t, http.MethodPost, r.Method)
if err := r.ParseMultipartForm(1 << 20); !assert.NoError(t, err) {
w.WriteHeader(http.StatusBadRequest)
return
}
defer func() { assert.NoError(t, r.MultipartForm.RemoveAll()) }()
assert.Equal(t, "aws.us-east-1a", r.FormValue("region"))
var source map[string]string
assert.NoError(t, json.Unmarshal([]byte(r.FormValue("source")), &source))
assert.Equal(t, map[string]string{"type": "github", "url": "https://github.com/example/app", "ref": "main", "entrypoint": "app.ts"}, source)
w.Header().Set("Content-Type", "application/json")
if tt.status != 0 {
w.WriteHeader(tt.status)
_, _ = io.WriteString(w, `{"code":"project_error","message":"project selector rejected"}`)
return
}
_, _ = io.WriteString(w, `{"id":"deployment_123"}`)
case "/deployments/deployment_123/events":
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"event\":\"deployment_state\",\"deployment\":{\"status\":\"running\"}}\n\n")
default:
t.Errorf("unexpected request: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cmd := projectTestCommand(t, server.URL, tt.flag)
if tt.oauth {
t.Setenv("KERNEL_API_KEY", "")
keyring.MockInit()
t.Cleanup(keyring.MockInit)
require.NoError(t, auth.SaveTokens(&auth.TokenStorage{
AccessToken: "test-oauth-token", ExpiresAt: time.Now().Add(time.Hour),
AccessScope: "project", ProjectID: "proj_123",
}))
}
cmd.Flags().String("url", "https://github.com/example/app", "")
cmd.Flags().String("ref", "main", "")
cmd.Flags().String("entrypoint", "app.ts", "")
cmd.Flags().String("output", "json", "")
require.NoError(t, rootCmd.PersistentPreRunE(cmd, nil))
err := runDeployGithub(cmd, nil)
if tt.status != 0 {
var apiErr *kernel.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, tt.status, apiErr.StatusCode)
assert.Contains(t, err.Error(), "project selector rejected")
assert.Equal(t, []string{"POST /deployments"}, paths)
} else {
require.NoError(t, err)
assert.Equal(t, []string{"POST /deployments", "GET /deployments/deployment_123/events"}, paths)
}
})
}
}
Loading