Skip to content

Commit 2c121e3

Browse files
authored
Preserve project scope when deploying from GitHub (#245)
## Summary `deploy github` bypassed the authenticated SDK client for its initial multipart POST. It dropped `--project` / `KERNEL_PROJECT`, so an organization-scoped credential could create a deployment in the default project before trying to follow it in the selected project. - Send the existing multipart body through the authenticated client's POST method, preserving project selection, authentication, base URL, and CLI version headers for both creation and event streaming. - Allow the same OAuth credentials as other deployment commands. Keep creation and event streaming retries disabled. - Document project-selector precedence and credential restrictions. The global selector already uses `X-Kernel-Project` for IDs and exact names; no client-side name resolution or SDK upgrade is needed. ## Validation - Local HTTP fixtures cover IDs and names via flag/environment, precedence, absent selection, legacy name delimiters, SDK subservices, project-scoped OAuth credentials, 403/404 propagation, and no retry after a creation 500. - Confirmed the project-header regression tests fail against `main` and pass with this fix. - `make test` (including `go vet ./...`) and `make build`: pass. - `go test -race ./cmd -run 'Test(ProjectSelectionHeaders|DeployGithubProjectScope)$' -count=1`: pass. - `golangci-lint run --new-from-merge-base=main`: no new issues. Unfiltered lint remains non-green due to existing repository findings. - No live Kernel API requests; project resolution and credential authorization are server-side contracts, not reimplemented by the fixtures. Co-authored-by: rgarcia <72655+rgarcia@users.noreply.github.com>
1 parent a4b3d10 commit 2c121e3

3 files changed

Lines changed: 166 additions & 27 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com).
103103
- `--version`, `-v` - Print the CLI version
104104
- `--no-color` - Disable color output
105105
- `--log-level <level>` - Set log level (trace, debug, info, warn, error, fatal, print)
106-
- `--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.
106+
- `--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.
107107

108108
## JSON Output
109109

‎cmd/deploy.go‎

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8-
"io"
98
"mime/multipart"
10-
"net/http"
119
"net/textproto"
1210
"os"
1311
"path/filepath"
@@ -179,11 +177,6 @@ func runDeployGithub(cmd *cobra.Command, args []string) error {
179177
startTime := time.Now()
180178

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

233-
reqHTTP, _ := http.NewRequestWithContext(cmd.Context(), http.MethodPost, strings.TrimRight(baseURL, "/")+"/deployments", &body)
234-
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
235-
reqHTTP.Header.Set("Content-Type", mw.FormDataContentType())
236-
httpResp, err := http.DefaultClient.Do(reqHTTP)
237-
if err != nil {
238-
return fmt.Errorf("post deployments: %w", err)
239-
}
240-
defer httpResp.Body.Close()
241-
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
242-
b, _ := io.ReadAll(httpResp.Body)
243-
return fmt.Errorf("deployments POST failed: %s: %s", httpResp.Status, strings.TrimSpace(string(b)))
244-
}
245226
var depCreated struct {
246227
ID string `json:"id"`
247228
}
248-
if err := json.NewDecoder(httpResp.Body).Decode(&depCreated); err != nil {
249-
return fmt.Errorf("decode deployment response: %w", err)
229+
if err := client.Post(cmd.Context(), "deployments", nil, &depCreated,
230+
option.WithRequestBody(mw.FormDataContentType(), &body),
231+
option.WithMaxRetries(0),
232+
); err != nil {
233+
return fmt.Errorf("post deployments: %w", err)
250234
}
251235

252-
return followDeployment(cmd.Context(), client, depCreated.ID, startTime, output,
253-
option.WithBaseURL(baseURL),
254-
option.WithHeader("Authorization", "Bearer "+apiKey),
255-
option.WithMaxRetries(0),
256-
)
236+
return followDeployment(cmd.Context(), client, depCreated.ID, startTime, output, option.WithMaxRetries(0))
257237
}
258238

259239
func runDeploy(cmd *cobra.Command, args []string) (err error) {

‎cmd/project_scope_test.go‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/kernel/cli/pkg/auth"
13+
kernel "github.com/kernel/kernel-go-sdk"
14+
"github.com/spf13/cobra"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
"github.com/zalando/go-keyring"
18+
)
19+
20+
func projectTestCommand(t *testing.T, serverURL, project string) *cobra.Command {
21+
t.Helper()
22+
t.Setenv("KERNEL_BASE_URL", serverURL)
23+
t.Setenv("KERNEL_API_KEY", "test-api-key")
24+
cmd := &cobra.Command{Use: "request"}
25+
cmd.SetContext(context.Background())
26+
cmd.Flags().String("project", project, "")
27+
cmd.Flags().String("log-level", "warn", "")
28+
cmd.Flags().Bool("no-color", false, "")
29+
return cmd
30+
}
31+
32+
func TestProjectSelectionHeaders(t *testing.T) {
33+
for _, tt := range []struct {
34+
name, flag, env, want string
35+
}{
36+
{name: "flag ID", flag: "proj_123", want: "proj_123"},
37+
{name: "flag exact name", flag: "Release QA", want: "Release QA"},
38+
{name: "environment ID", env: "proj_123", want: "proj_123"},
39+
{name: "environment exact name", env: "Release QA", want: "Release QA"},
40+
{name: "flag wins", flag: "Release QA", env: "proj_other", want: "Release QA"},
41+
{name: "legacy name delimiters", flag: "QA/100%", want: "QA/100%"},
42+
{name: "no selection"},
43+
} {
44+
t.Run(tt.name, func(t *testing.T) {
45+
t.Setenv("KERNEL_PROJECT", tt.env)
46+
var paths []string
47+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
paths = append(paths, r.URL.Path)
49+
assert.Equal(t, tt.want, r.Header.Get("X-Kernel-Project"))
50+
assert.Empty(t, r.Header.Get("X-Kernel-Project-Id"))
51+
assert.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
52+
w.Header().Set("Content-Type", "application/json")
53+
_, _ = io.WriteString(w, `{}`)
54+
}))
55+
defer server.Close()
56+
cmd := projectTestCommand(t, server.URL, tt.flag)
57+
require.NoError(t, rootCmd.PersistentPreRunE(cmd, nil))
58+
client := getKernelClient(cmd)
59+
_, err := client.Browsers.Get(cmd.Context(), "browser_123", kernel.BrowserGetParams{})
60+
require.NoError(t, err)
61+
_, err = client.Profiles.Get(cmd.Context(), "profile_123")
62+
require.NoError(t, err)
63+
_, err = client.Proxies.Get(cmd.Context(), "proxy_123")
64+
require.NoError(t, err)
65+
_, err = client.Auth.Connections.Get(cmd.Context(), "connection_123")
66+
require.NoError(t, err)
67+
assert.Equal(t, []string{"/browsers/browser_123", "/profiles/profile_123", "/proxies/proxy_123", "/auth/connections/connection_123"}, paths)
68+
})
69+
}
70+
}
71+
72+
func TestDeployGithubProjectScope(t *testing.T) {
73+
for _, tt := range []struct {
74+
name, flag, env, want string
75+
oauth bool
76+
status int
77+
}{
78+
{name: "flag ID", flag: "proj_123", want: "proj_123"},
79+
{name: "flag exact name", flag: "Release QA", want: "Release QA"},
80+
{name: "environment ID", env: "proj_123", want: "proj_123"},
81+
{name: "environment exact name", env: "Release QA", want: "Release QA"},
82+
{name: "flag wins", flag: "Release QA", env: "proj_other", want: "Release QA"},
83+
{name: "no selection"},
84+
{name: "project OAuth", flag: "Release QA", want: "Release QA", oauth: true},
85+
{name: "server error without retry", flag: "proj_123", want: "proj_123", status: http.StatusInternalServerError},
86+
{name: "invalid ID", flag: "proj_missing", want: "proj_missing", status: http.StatusNotFound},
87+
{name: "invalid name", env: "missing project", want: "missing project", status: http.StatusNotFound},
88+
{name: "credential scope mismatch", flag: "Other Project", want: "Other Project", oauth: true, status: http.StatusForbidden},
89+
} {
90+
t.Run(tt.name, func(t *testing.T) {
91+
t.Setenv("KERNEL_PROJECT", tt.env)
92+
wantAuth := "Bearer test-api-key"
93+
if tt.oauth {
94+
wantAuth = "Bearer test-oauth-token"
95+
}
96+
var paths []string
97+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98+
paths = append(paths, r.Method+" "+r.URL.Path)
99+
assert.Equal(t, tt.want, r.Header.Get("X-Kernel-Project"))
100+
assert.Empty(t, r.Header.Get("X-Kernel-Project-Id"))
101+
assert.Equal(t, wantAuth, r.Header.Get("Authorization"))
102+
assert.Equal(t, metadata.Version, r.Header.Get("X-Kernel-Cli-Version"))
103+
switch r.URL.Path {
104+
case "/deployments":
105+
assert.Equal(t, http.MethodPost, r.Method)
106+
if err := r.ParseMultipartForm(1 << 20); !assert.NoError(t, err) {
107+
w.WriteHeader(http.StatusBadRequest)
108+
return
109+
}
110+
defer func() { assert.NoError(t, r.MultipartForm.RemoveAll()) }()
111+
assert.Equal(t, "aws.us-east-1a", r.FormValue("region"))
112+
var source map[string]string
113+
assert.NoError(t, json.Unmarshal([]byte(r.FormValue("source")), &source))
114+
assert.Equal(t, map[string]string{"type": "github", "url": "https://github.com/example/app", "ref": "main", "entrypoint": "app.ts"}, source)
115+
w.Header().Set("Content-Type", "application/json")
116+
if tt.status != 0 {
117+
w.WriteHeader(tt.status)
118+
_, _ = io.WriteString(w, `{"code":"project_error","message":"project selector rejected"}`)
119+
return
120+
}
121+
_, _ = io.WriteString(w, `{"id":"deployment_123"}`)
122+
case "/deployments/deployment_123/events":
123+
w.Header().Set("Content-Type", "text/event-stream")
124+
_, _ = io.WriteString(w, "data: {\"event\":\"deployment_state\",\"deployment\":{\"status\":\"running\"}}\n\n")
125+
default:
126+
t.Errorf("unexpected request: %s", r.URL.Path)
127+
w.WriteHeader(http.StatusNotFound)
128+
}
129+
}))
130+
defer server.Close()
131+
cmd := projectTestCommand(t, server.URL, tt.flag)
132+
if tt.oauth {
133+
t.Setenv("KERNEL_API_KEY", "")
134+
keyring.MockInit()
135+
t.Cleanup(keyring.MockInit)
136+
require.NoError(t, auth.SaveTokens(&auth.TokenStorage{
137+
AccessToken: "test-oauth-token", ExpiresAt: time.Now().Add(time.Hour),
138+
AccessScope: "project", ProjectID: "proj_123",
139+
}))
140+
}
141+
cmd.Flags().String("url", "https://github.com/example/app", "")
142+
cmd.Flags().String("ref", "main", "")
143+
cmd.Flags().String("entrypoint", "app.ts", "")
144+
cmd.Flags().String("output", "json", "")
145+
require.NoError(t, rootCmd.PersistentPreRunE(cmd, nil))
146+
err := runDeployGithub(cmd, nil)
147+
if tt.status != 0 {
148+
var apiErr *kernel.Error
149+
require.ErrorAs(t, err, &apiErr)
150+
assert.Equal(t, tt.status, apiErr.StatusCode)
151+
assert.Contains(t, err.Error(), "project selector rejected")
152+
assert.Equal(t, []string{"POST /deployments"}, paths)
153+
} else {
154+
require.NoError(t, err)
155+
assert.Equal(t, []string{"POST /deployments", "GET /deployments/deployment_123/events"}, paths)
156+
}
157+
})
158+
}
159+
}

0 commit comments

Comments
 (0)