Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
081897c
feat(databases): apply migrations through migration runs under a grant
tkkhq Nov 12, 2026
637e75b
test(deploy): check a grant's deploy against Hosting's grant route table
tkkhq Nov 12, 2026
5ccee16
feat(api): print the API's error code with the status
tkkhq Nov 12, 2026
449d9d7
feat(auth): read the token from a file descriptor and keep a grant ou…
tkkhq Nov 12, 2026
3217d43
Merge branch 'feat/grant-invoke' into feat/grant-migration-runs
tkkhq Nov 12, 2026
3e22f00
Merge branch 'feat/grant-migration-runs' into feat/grant-token-fd
tkkhq Nov 12, 2026
eefe987
feat(databases): record migration baselines through Hosting's baselin…
tkkhq Nov 12, 2026
8d880d5
Merge branch 'feat/grant-invoke' into feat/grant-migration-runs
tkkhq Nov 12, 2026
2dcb791
refactor(databases): leave run conflicts and name checks to Hosting
tkkhq Nov 12, 2026
237eb70
refactor(api): print the API's error code only under a delegated grant
tkkhq Nov 12, 2026
db42fe3
Merge branch 'feat/grant-invoke' into feat/grant-migration-runs
tkkhq Nov 12, 2026
88f8c24
Merge branch 'feat/grant-invoke' into feat/grant-migration-runs
tkkhq Nov 12, 2026
0880d09
style(databases): format the baseline command
tkkhq Nov 12, 2026
31a4a61
Merge branch 'feat/grant-migration-runs' into feat/grant-token-fd
tkkhq Nov 12, 2026
246fdc8
Merge branch 'feat/grant-migration-runs' into feat/grant-token-fd
tkkhq Nov 12, 2026
82acba6
test(databases): drop an unused parameter
tkkhq Nov 12, 2026
7073591
test(auth): give each descriptor test a descriptor only the CLI owns
tkkhq Nov 12, 2026
b6fe928
style(auth): format the descriptor test files
tkkhq Nov 12, 2026
1f129a5
chore: merge feat/grant-invoke
tkkhq Nov 12, 2026
9f990f3
test(deploy): match the grant route table to Hosting's current table
tkkhq Nov 12, 2026
b8f69bf
chore: merge feat/grant-migration-runs
tkkhq Nov 12, 2026
89e1528
fix(auth): read the token descriptor to its first newline and keep st…
tkkhq Nov 12, 2026
55e45ad
fix(connect): count VOLCANO_TOKEN_FD in the environment token guards
tkkhq Nov 12, 2026
3602c5d
fix(telemetry): send no event for a run on an adopted token
tkkhq Nov 12, 2026
df55b94
fix(auth): name VOLCANO_TOKEN_FD in the grant login refusal
tkkhq Nov 12, 2026
b1e2dbe
docs(auth): pass the token descriptor from a pipe
tkkhq Nov 12, 2026
6b8537d
test(deploy): copy the grant route table from Hosting's invoke branch
tkkhq Nov 12, 2026
a8d282d
chore: merge feat/grant-migration-runs
tkkhq Nov 12, 2026
e40bb09
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
8bf6de9
fix(api): keep a migration run's body out of errors when the parser r…
tkkhq Nov 13, 2026
7dd72f5
fix(databases): tell a caller to wait for a run that carries on, not …
tkkhq Nov 13, 2026
6c662fb
docs(databases): qualify the run's rehearsal and backup and document …
tkkhq Nov 13, 2026
acf06f1
chore: merge feat/grant-migration-runs
tkkhq Nov 13, 2026
61a4cfd
fix(api): print the grant error code only when a grant was sent, on a…
tkkhq Nov 13, 2026
27e4db4
test(auth): write the descriptor's token from a goroutine so a large …
tkkhq Nov 13, 2026
8126106
docs(auth): name VOLCANO_TOKEN_FD where the credential table says whe…
tkkhq Nov 13, 2026
ecf0093
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
f84cfbb
chore: merge feat/grant-migration-runs
tkkhq Nov 13, 2026
c977017
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
a475b70
chore: merge feat/grant-migration-runs
tkkhq Nov 13, 2026
93d2607
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
1de00d7
chore: merge feat/grant-migration-runs
tkkhq Nov 13, 2026
e25e902
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
16a8221
chore: merge feat/grant-migration-runs
tkkhq Nov 13, 2026
d315304
chore: merge feat/grant-invoke, drop the migration-run changes #365 s…
tkkhq Nov 15, 2026
816de9d
fix(auth): name VOLCANO_TOKEN_FD in the LavaFlow and approval sign-in…
tkkhq Nov 15, 2026
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
93 changes: 93 additions & 0 deletions cmd/volcano/grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ type grantRun struct {
// directory's link file, telemetry off and no prompts. No config loader is
// injected, so the CLI reads its configuration the way it does in the VM.
func runAsWorker(t *testing.T, handler http.HandlerFunc, args ...string) grantRun {
t.Helper()
return runAsWorkerWith(t, nil, handler, args...)
}

// runAsWorkerWith is runAsWorker with setup run in the workspace first.
func runAsWorkerWith(t *testing.T, setup func(), handler http.HandlerFunc, args ...string) grantRun {
t.Helper()
resetInstructions(t)
api.ResetLastRefusalForTest()
Expand Down Expand Up @@ -65,6 +71,9 @@ func runAsWorker(t *testing.T, handler http.HandlerFunc, args ...string) grantRu
t.Setenv("VOLCANO_PROJECT_ID", "")
t.Setenv("VOLCANO_TELEMETRY_DISABLED", "1")
t.Setenv("VOLCANO_NONINTERACTIVE", "1")
if setup != nil {
setup()
}

deps := cliruntime.Deps{HTTPClient: server.Client()}
root := rootcmd.New(deps)
Expand Down Expand Up @@ -191,3 +200,87 @@ func TestGrantInvokesWithTheGrantAndNoServiceKey(t *testing.T) {
assert.Equal(t, []string{"Bearer " + testGrant}, run.auth)
assert.JSONEq(t, `{"ok":true}`, run.stdout)
}

// Under a grant, the API's code follows the error on its own line.
func TestGrantErrorPrintsTheAPICode(t *testing.T) {
run := runAsWorker(t, func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, http.StatusTooManyRequests,
`{"error":"the delegated grant has no mutations left; ask the app for a new grant","code":"grant_budget_exhausted"}`)
}, "cloud", "functions", "list")

assert.Equal(t, exitGrantBudgetExhausted, run.code, run.stderr)
assert.Contains(t, run.stderr, "Error: failed to list functions: HTTP 429: the delegated grant has no mutations left; "+
"ask the app for a new grant\nError code: grant_budget_exhausted\n")
}

// Any other credential sees the error as before, without the code line.
func TestNonGrantErrorPrintsNoCodeLine(t *testing.T) {
code, stderr := runAgainst(t, "test-token", func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, http.StatusForbidden, `{"error":"feature is not available on this plan","code":"plan_required"}`)
}, "cloud", "functions", "list")

assert.Equal(t, exitPermissionDenied, code, stderr)
assert.NotContains(t, stderr, "Error code:")
}

// A 426 under a grant still names the API's code: the worker branches on the
// code, not on which renderer printed the error.
func TestGrantUpgradeRequiredPrintsTheAPICode(t *testing.T) {
run := runAsWorker(t, func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, http.StatusUpgradeRequired,
`{"error":"cli version no longer supported","code":"cli_version_unsupported"}`)
}, "cloud", "functions", "list")

assert.Equal(t, 1, run.code, run.stderr)
assert.Contains(t, run.stderr, "cli version no longer supported\nError code: cli_version_unsupported\n")
}

// The code line follows what the command sent, not what the environment
// holds: a local-mode command sends no credential, so a grant in
// VOLCANO_TOKEN does not change its errors.
func TestGrantInTheEnvironmentAloneAddsNoCodeLine(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("VOLCANO_TOKEN", testGrant)
api.ResetLastRefusalForTest()
t.Cleanup(api.ResetLastRefusalForTest)

var out bytes.Buffer
printError(&out, &api.Error{StatusCode: http.StatusNotFound, Message: "function not found", Code: "not_found"}, cliruntime.Deps{})

assert.Contains(t, out.String(), "function not found")
assert.NotContains(t, out.String(), "Error code:")
}

// The app's worker applies migrations with `databases migration apply`, which
// starts a server-side run. Under a grant it uses only routes a grant may call
// and never reads the database, whose answer holds the connection string.
func TestGrantMigrationApplyUsesOnlyARun(t *testing.T) {
runsPath := "/projects/" + mainTestProjectAlpha + "/databases/app/migration-runs"
const runID = "44444444-4444-4444-8444-444444444444"
run := runAsWorkerWith(t, func() {
require.NoError(t, os.MkdirAll(filepath.Join("volcano", "migrations"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join("volcano", "migrations", "001_init.sql"), []byte("CREATE TABLE t();"), 0o644))
}, func(w http.ResponseWriter, r *http.Request) {
state := `{"id":"` + runID + `","status":"succeeded","phase":"apply","source":"api","warnings":[],` +
`"database":{"id":"66666666-6666-4666-8666-666666666666","name":"app"},` +
`"files":[{"name":"001_init.sql","outcome":"applied"}],` +
`"created_at":"2026-11-11T00:00:00Z","updated_at":"2026-11-11T00:00:00Z"}`
switch {
case r.Method == http.MethodGet && r.URL.Path == "/capabilities":
respondExitTestJSON(w, http.StatusOK, `{"data":[{"id":"databases.migrations","status":"available"}]}`)
case r.Method == http.MethodPost && r.URL.Path == runsPath:
respondExitTestJSON(w, http.StatusAccepted, state)
case r.Method == http.MethodGet && r.URL.Path == runsPath+"/"+runID:
respondExitTestJSON(w, http.StatusOK, state)
default:
respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`)
}
}, "cloud", "databases", "migration", "apply", "-d", "app")

assert.Equal(t, 0, run.code, run.stderr)
assert.Contains(t, run.requests, "POST "+runsPath)
for i, request := range run.requests {
assert.Contains(t, []string{"GET /capabilities", "POST " + runsPath, "GET " + runsPath + "/" + runID}, request)
assert.Equal(t, "Bearer "+testGrant, run.auth[i], request)
}
}
67 changes: 67 additions & 0 deletions cmd/volcano/grant_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//go:build unix

package main

import (
"net/http"
"os"
"strconv"
"syscall"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

cliconfig "github.com/Kong/volcano-cli/internal/config"
)

// The worker can pass the grant on a descriptor instead of VOLCANO_TOKEN, so
// it is never in the CLI's environment. main adopts it before the command runs.
func TestGrantFromATokenDescriptor(t *testing.T) {
run := runAsWorkerWith(t, func() { adoptGrantFromADescriptor(t) }, func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, http.StatusOK, `{"data":[],"has_more":false,"page":1,"limit":100,"total":0}`)
}, "cloud", "functions", "list")

assert.Equal(t, 0, run.code, run.stderr)
assert.Equal(t, []string{"Bearer " + testGrant}, run.auth)
assert.Empty(t, os.Getenv("VOLCANO_TOKEN"))
}

// A refusal that says which variable to unset names VOLCANO_TOKEN_FD when the
// token came from it: unsetting VOLCANO_TOKEN would change nothing.
func TestGrantFromATokenDescriptorIsNamedInSignInHints(t *testing.T) {
for _, args := range [][]string{
{"cloud", "lavaflow", "disable"},
{"cloud", "durable", "approvals", "approve", "11111111-1111-4111-8111-111111111111", "--yes"},
} {
t.Run(args[1]+" "+args[2], func(t *testing.T) {
run := runAsWorkerWith(t, func() { adoptGrantFromADescriptor(t) }, func(w http.ResponseWriter, _ *http.Request) {
respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`)
}, args...)

assert.Equal(t, exitPermissionDenied, run.code, run.stderr)
assert.Contains(t, run.stderr, cliconfig.TokenFDEnv)
assert.NotContains(t, run.stderr, "VOLCANO_TOKEN ")
})
}
}

// adoptGrantFromADescriptor puts the grant on a pipe, names it in
// VOLCANO_TOKEN_FD and adopts it, as main does at startup.
func adoptGrantFromADescriptor(t *testing.T) {
t.Helper()
cliconfig.ResetAdoptedTokenForTest()
t.Cleanup(cliconfig.ResetAdoptedTokenForTest)
r, w, err := os.Pipe()
require.NoError(t, err)
_, err = w.WriteString(testGrant + "\n")
require.NoError(t, err)
require.NoError(t, w.Close())
// A duplicate no *os.File owns, so only AdoptToken closes it.
fd, err := syscall.Dup(int(r.Fd()))
require.NoError(t, err)
require.NoError(t, r.Close())
t.Setenv("VOLCANO_TOKEN", "")
t.Setenv(cliconfig.TokenFDEnv, strconv.Itoa(fd))
require.NoError(t, cliconfig.AdoptToken())
}
21 changes: 21 additions & 0 deletions cmd/volcano/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const (
)

func main() {
// First, before anything reads the configuration: take the token from
// VOLCANO_TOKEN_FD, or a grant out of VOLCANO_TOKEN, into memory.
if err := config.AdoptToken(); err != nil {
fmt.Fprintln(os.Stderr, theme.Error("Error:", theme.On(os.Stderr)), err)
os.Exit(exitNotAuthenticated)
}
// One recorder per process: it reads the telemetry setting once, and both
// the agent headers and the usage event follow that read. Agent headers
// count as usage data: none before the first-run notice.
Expand Down Expand Up @@ -212,6 +218,7 @@ func printDeprecationError(w io.Writer, err error, deps cliruntime.Deps) {
if latest := api.LastInstructions().LatestVersion; latest != "" {
fmt.Fprintf(w, "Upgrade to %s: %s\n", latest, cliruntime.CommandPath(deps, "upgrade"))
}
printGrantErrorCode(w, err)
}

// printError renders a generic command error, appending a reauth hint when
Expand All @@ -222,6 +229,20 @@ func printError(w io.Writer, err error, deps cliruntime.Deps) {
fmt.Fprintf(w, "Run `%s` to re-authenticate.\n", cliruntime.CommandPath(deps, "login"))
}
printProjectTokenMismatchHint(w, err, deps)
printGrantErrorCode(w, err)
}

// printGrantErrorCode prints the API's error code on its own line when the
// command sent a delegated grant, so the app's worker can branch on the code,
// such as grant_budget_exhausted, rather than the message. Every other
// credential sees its errors exactly as before, including a local-mode
// command, which sends no credential even with a grant in the environment.
func printGrantErrorCode(w io.Writer, err error) {
code := api.Code(err)
if code == "" || !api.GrantSent() {
return
}
fmt.Fprintf(w, "Error code: %s\n", code)
}

// loadConfig loads the configuration through the runtime's loader, so a
Expand Down
25 changes: 22 additions & 3 deletions internal/api/refusal.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"

"github.com/google/uuid"

Expand Down Expand Up @@ -45,12 +46,26 @@ func LastRefusal() Refusal {
return lastRefusal
}

// ResetLastRefusalForTest clears the process-global refusal state. Test-only:
// each CLI invocation is a fresh process.
// grantSent records whether any request in this process carried a delegated
// grant.
var grantSent atomic.Bool

// GrantSent reports whether any API request in this process carried a
// delegated grant (pg-). It reads the requests that went out rather than the
// configuration: a local-mode command sends no credential even when a grant
// is in the environment.
func GrantSent() bool {
return grantSent.Load()
}

// ResetLastRefusalForTest clears the process-global request state: the last
// refusal and whether a grant was sent. Test-only: each CLI invocation is a
// fresh process.
func ResetLastRefusalForTest() {
refusalMu.Lock()
lastRefusal = Refusal{}
refusalMu.Unlock()
grantSent.Store(false)
}

func recordRefusal(req *http.Request) {
Expand Down Expand Up @@ -89,12 +104,16 @@ func bearerToken(authorization string) string {
}

// refusalDoer records the request behind every 403 the API returns, so a
// caller explaining one can describe the request instead of guessing at it.
// caller explaining one can describe the request instead of guessing at it,
// and whether any request carried a delegated grant (GrantSent).
type refusalDoer struct {
next apiclient.HttpRequestDoer
}

func (d refusalDoer) Do(req *http.Request) (*http.Response, error) {
if config.IsDelegatedGrant(bearerToken(req.Header.Get("Authorization"))) {
grantSent.Store(true)
}
resp, err := d.next.Do(req)
if resp != nil && resp.StatusCode == http.StatusForbidden {
recordRefusal(req)
Expand Down
13 changes: 6 additions & 7 deletions internal/cmd/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,11 @@ func run(ctx context.Context, opts options) error {
return fmt.Errorf("--template: %w (supported: nextjs, javascript, python, ruby)", err)
}

// Every command after sign-in takes VOLCANO_TOKEN over the saved login, so
// a new login would be saved and then not used. Checked before the
// dashboard's device code is spent.
if strings.TrimSpace(os.Getenv("VOLCANO_TOKEN")) != "" &&
(strings.TrimSpace(opts.deviceCode) != "" || opts.signup) {
return errors.New("VOLCANO_TOKEN is set and would be used instead of the new login. " +
// Every command after sign-in takes VOLCANO_TOKEN or VOLCANO_TOKEN_FD over
// the saved login, so a new login would be saved and then not used.
// Checked before the dashboard's device code is spent.
if config.EnvironmentTokenSet() && (strings.TrimSpace(opts.deviceCode) != "" || opts.signup) {
return errors.New("VOLCANO_TOKEN or VOLCANO_TOKEN_FD is set and would be used instead of the new login. " +
"Unset it, or run connect without --device-code and --signup to use it")
}
// VOLCANO_PROJECT_ID is deprecated, and would override the link connect
Expand Down Expand Up @@ -398,7 +397,7 @@ func ensureSession(ctx context.Context, opts options, sessions clisession.Factor
output.Success(opts.log, "Signed in")
return nil
}
if api.Status(err) != http.StatusUnauthorized || strings.TrimSpace(os.Getenv("VOLCANO_TOKEN")) != "" {
if api.Status(err) != http.StatusUnauthorized || cfg.TokenFromEnv() {
return fmt.Errorf("failed to check the saved login: %w", err)
}
output.Note(opts.log, "The saved login is no longer valid. Signing in again.")
Expand Down
40 changes: 26 additions & 14 deletions internal/cmd/connect/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1317,22 +1317,34 @@ func TestConnectSavesTheDeviceLoginWhenItsCheckFails(t *testing.T) {
}

func TestConnectRefusesALoginThatVolcanoTokenWouldOverride(t *testing.T) {
setConnectTestHome(t)
t.Setenv("VOLCANO_TOKEN", "pk-from-env")
fake := &fakeAPI{t: t}
server := httptest.NewServer(fake)
defer server.Close()
for name, token := range map[string]string{
"account token in VOLCANO_TOKEN": "pk-from-env",
// AdoptToken takes a grant out of the environment into memory, as it
// does a token from VOLCANO_TOKEN_FD.
"adopted grant": cliconfig.DelegatedGrantPrefix + "from-env",
} {
t.Run(name, func(t *testing.T) {
setConnectTestHome(t)
t.Setenv("VOLCANO_TOKEN", token)
cliconfig.ResetAdoptedTokenForTest()
t.Cleanup(cliconfig.ResetAdoptedTokenForTest)
require.NoError(t, cliconfig.AdoptToken())
fake := &fakeAPI{t: t}
server := httptest.NewServer(fake)
defer server.Close()

for _, flags := range [][]string{{"--device-code", connectDeviceCode}, {"--signup", "--email", "a@example.com"}} {
_, err := runConnect(t, server, t.TempDir(), flags...)
require.ErrorContains(t, err, "VOLCANO_TOKEN is set", flags)
for _, flags := range [][]string{{"--device-code", connectDeviceCode}, {"--signup", "--email", "a@example.com"}} {
_, err := runConnect(t, server, t.TempDir(), flags...)
require.ErrorContains(t, err, "VOLCANO_TOKEN or VOLCANO_TOKEN_FD is set", flags)
}
assert.Zero(t, fake.polls, "the device code is not spent")
assert.Zero(t, fake.creates)
assert.False(t, fake.deviceAuthorize)
cfg, err := cliconfig.Load()
require.NoError(t, err)
assert.Equal(t, "pk-connect", cfg.UserToken, "the saved login is unchanged")
})
}
assert.Zero(t, fake.polls, "the device code is not spent")
assert.Zero(t, fake.creates)
assert.False(t, fake.deviceAuthorize)
cfg, err := cliconfig.Load()
require.NoError(t, err)
assert.Equal(t, "pk-connect", cfg.UserToken, "the saved login is unchanged")
}

// A private relay git email is not offered. Connect stops before it signs in
Expand Down
13 changes: 11 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,21 @@ func (c *Config) Token() string {
// Untrimmed, that reaches the Authorization header and net/http refuses to
// send the request at all, reporting an invalid header field rather than
// anything the user can act on.
if token := strings.TrimSpace(os.Getenv(envToken)); !c.IgnoreEnv && token != "" {
if token := environmentToken(); !c.IgnoreEnv && token != "" {
return token
}
return c.UserToken
}

// environmentToken is the token from the environment: the one AdoptToken took in, or
// else VOLCANO_TOKEN.
func environmentToken() string {
if adopted := adoptedToken.Load(); adopted != nil {
return *adopted
}
return strings.TrimSpace(os.Getenv(envToken))
}

// TokenEnv is the environment variable whose token Token returns ahead of the
// saved one.
const TokenEnv = envToken
Expand All @@ -374,7 +383,7 @@ const TokenEnv = envToken
// rather than the saved one. Signing in saves a token, which Token then still
// passes over.
func (c *Config) TokenFromEnv() bool {
return !c.IgnoreEnv && strings.TrimSpace(os.Getenv(envToken)) != ""
return !c.IgnoreEnv && environmentToken() != ""
}

// FunctionInvokeToken returns the token used for runtime function invocation.
Expand Down
Loading
Loading