Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
3217d43
Merge branch 'feat/grant-invoke' into feat/grant-migration-runs
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
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
82acba6
test(databases): drop an unused parameter
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
6b8537d
test(deploy): copy the grant route table from Hosting's invoke branch
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
ecf0093
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
c977017
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
93d2607
chore: merge feat/grant-invoke
tkkhq Nov 13, 2026
e25e902
chore: merge feat/grant-invoke
tkkhq Nov 13, 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
73 changes: 73 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,67 @@ func TestGrantInvokesWithTheGrantAndNoServiceKey(t *testing.T) {
assert.Equal(t, []string{"Bearer " + testGrant}, run.auth)
assert.JSONEq(t, `{"ok":true}`, run.stdout)
}

// Migrations under a grant go through a server-side run. The CLI never reads
// the database, whose answer holds the connection string.
func TestGrantMigrationUpUsesAMigrationRun(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.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", "up", "--all", "-d", "app")

assert.Equal(t, 0, run.code, run.stderr)
assert.Equal(t, []string{"POST " + runsPath}, run.requests)
assert.Contains(t, run.stdout, "001_init.sql: applied")
for _, request := range run.requests {
assert.NotEqual(t, "GET /projects/"+mainTestProjectAlpha+"/databases/app", request)
}
}

// Baseline under a grant goes through Hosting's baseline route, with the files
// the caller names, and exits like other commands when Hosting refuses it.
func TestGrantBaselineUsesTheBaselineRoute(t *testing.T) {
baselinePath := "/projects/" + mainTestProjectAlpha + "/databases/app/migrations/baseline"
writeMigration := 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))
}
for _, tc := range []struct {
name string
status int
body string
code int
}{
{"recorded", http.StatusOK, `{"recorded":["001_init.sql"]}`, 0},
{"refused", http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`, exitPermissionDenied},
{"budget", http.StatusTooManyRequests, `{"error":"the delegated grant has no mutations left; ask the app for a new grant","code":"grant_budget_exhausted"}`, exitGrantBudgetExhausted},
} {
t.Run(tc.name, func(t *testing.T) {
run := runAsWorkerWith(t, writeMigration, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == baselinePath {
respondExitTestJSON(w, tc.status, tc.body)
return
}
respondExitTestJSON(w, http.StatusForbidden, `{"error":"delegated grants cannot be used on this endpoint","code":"grant_route_not_allowed"}`)
}, "cloud", "databases", "migration", "baseline", "-d", "app", "-f", "001_init", "--yes")

assert.Equal(t, tc.code, run.code, run.stderr)
assert.Equal(t, []string{"POST " + baselinePath}, run.requests)
})
}
}
31 changes: 31 additions & 0 deletions docs/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,44 @@ it using its connection string.
| Change size (cloud) | `volcano cloud databases resize <name> --type <size>` |
| Apply local migrations | `volcano migrations deploy --all -d <db>` |
| Apply cloud migrations | `volcano cloud databases migration up --all -d <db>` |
| Record cloud migrations as applied without running them | `volcano cloud databases migration baseline -d <db> -f <file>` |

Local migrations run from `volcano/migrations` against a local database. Cloud
migrations use the cloud database command and connect through the managed
database proxy; the local `migrations deploy` command does not have a cloud
variant. [`volcano deploy --with-migrations`](deploy.md) runs the same
migrations as part of a whole-project deploy.

With a [delegated grant](authentication.md#delegated-grants) (`pg-`), `volcano cloud
databases migration up` sends the files to a server-side migration run instead
of connecting to the database. The run skips files the database's migration
history already records. Before it applies the rest, it rehearses them on a
short-lived branch of the database and takes a backup, where the platform and
your plan offer branches and backups. When the run skips either step, the
command prints a `Warning:` line saying so, and the files are applied without
it. Where migration runs are not available, the command fails and applies
nothing.

Files applied by an earlier `up` that connected to the database directly are
not in that history, so a run tries them again. When a run fails because an
object already exists, the error names the file. If the database already holds
everything that file creates, record the file as applied without running it,
then run `up` again:

```bash
# Record named files; -f takes a name or a path and can repeat
volcano cloud databases migration baseline -d <db> -f 001_create_users -f 002_add_posts

# Record every file in volcano/migrations
volcano cloud databases migration baseline -d <db> --all
```

Record only files the database already reflects: a recorded file never runs.
A file the history already records with the same content is skipped; one
recorded with different content is refused, and nothing is recorded. The
command asks for confirmation first; `--yes` skips the prompt, and `--json`
prints the result as JSON.

Each file runs as a single query, and the database proxy refuses a query with
more than one statement, so put one SQL statement in each file. Files run in
name order.
Expand Down
107 changes: 107 additions & 0 deletions internal/api/database_migration_runs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package api

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/google/uuid"

"github.com/Kong/volcano-cli/internal/apiclient"
)

// MigrationRunInProgressCode is the 409 the API answers when the database
// already has a queued or running migration run.
const MigrationRunInProgressCode = "migration_run_in_progress"

// FeatureUnavailableCode is the 404 the API answers when migration runs are
// not turned on where it runs.
const FeatureUnavailableCode = "feature_unavailable"

// MigrationRunInProgressError is a run the API would not start because RunID
// is already queued or running on the database.
type MigrationRunInProgressError struct {
Err *Error
RunID uuid.UUID
}

func (e *MigrationRunInProgressError) Error() string {
return fmt.Sprintf("%s (run %s)", e.Err.Error(), e.RunID)
}

func (e *MigrationRunInProgressError) Unwrap() error { return e.Err }

// CreateDatabaseMigrationRun starts a server-side migration run of files on
// databaseName. The server compares them with the database's history and
// applies the pending ones; nothing here connects to the database.
// idempotencyKey makes a retried request return the run the first one started,
// which the API answers with 200 instead of 202.
func (c *Client) CreateDatabaseMigrationRun(
ctx context.Context, projectID uuid.UUID, databaseName, idempotencyKey string, files []apiclient.DatabaseMigrationFile,
) (*apiclient.DatabaseMigrationRun, error) {
params := &apiclient.CreateDatabaseMigrationRunParams{}
if key := strings.TrimSpace(idempotencyKey); key != "" {
params.IdempotencyKey = &key
}
resp, err := c.client.CreateDatabaseMigrationRunWithResponse(ctx, projectID, strings.TrimSpace(databaseName), params,
apiclient.CreateDatabaseMigrationRunJSONRequestBody{Files: files})
if err != nil {
return nil, err
}
switch {
case resp.JSON202 != nil:
return resp.JSON202, nil
case resp.JSON200 != nil:
return resp.JSON200, nil
case resp.JSON409 != nil:
return nil, migrationConflictError(resp.JSON409)
}
// A run the parser did not accept quotes database errors, which can quote
// data, so only an error body's own fields reach the error.
return nil, apiErrorFromGeneratedErrors(resp.StatusCode(), errorFieldsOnly(resp.Body), resp.JSON400, resp.JSON404, resp.JSON413)
}

// BaselineDatabaseMigrations records files in databaseName's migration
// history as applied, without running them. The server skips files it already
// records with the same checksum, and refuses one recorded with another.
func (c *Client) BaselineDatabaseMigrations(
ctx context.Context, projectID uuid.UUID, databaseName string, files []apiclient.DatabaseMigrationFile,
) (*apiclient.DatabaseMigrationBaseline, error) {
resp, err := c.client.BaselineDatabaseMigrationsWithResponse(ctx, projectID, strings.TrimSpace(databaseName),
apiclient.BaselineDatabaseMigrationsJSONRequestBody{Files: files})
if err != nil {
return nil, err
}
if resp.JSON200 != nil {
return resp.JSON200, nil
}
if resp.JSON409 != nil {
return nil, migrationConflictError(resp.JSON409)
}
return nil, apiErrorFromGeneratedErrors(resp.StatusCode(), resp.Body, resp.JSON400, resp.JSON404, resp.JSON413, resp.JSON503)
}

// migrationConflictError is the error for a migration 409, naming the run in
// progress when that is the reason.
func migrationConflictError(conflict *apiclient.DatabaseMigrationConflictError) error {
apiErr := &Error{StatusCode: http.StatusConflict, Message: strings.TrimSpace(conflict.Error), fromAPI: true}
if conflict.Code != nil {
apiErr.Code = *conflict.Code
}
if apiErr.Code == MigrationRunInProgressCode && conflict.RunId != nil {
return &MigrationRunInProgressError{Err: apiErr, RunID: *conflict.RunId}
}
return apiErr
}

// GetDatabaseMigrationRun returns one migration run with its files.
func (c *Client) GetDatabaseMigrationRun(
ctx context.Context, projectID uuid.UUID, databaseName string, runID uuid.UUID,
) (*apiclient.DatabaseMigrationRun, error) {
resp, err := c.client.GetDatabaseMigrationRunWithResponse(ctx, projectID, strings.TrimSpace(databaseName), runID)
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), errorFieldsOnly(resp.Body), resp.JSON200, resp.JSON404)
}
54 changes: 54 additions & 0 deletions internal/api/secret_redaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,57 @@ func TestRedactedErrorsStillCarryTheStatus(t *testing.T) {
assert.Equal(t, http.StatusOK, apiErr.StatusCode)
assert.Contains(t, err.Error(), "200", "the status should still be reported")
}

// A migration run quotes the database's errors, which can quote data. A run
// the parser did not accept, such as one whose Content-Type was stripped on
// the way, must not become an error message, which usage telemetry sends.
func TestMigrationRunNeverPutsTheRunInAnError(t *testing.T) {
const quoted = "Key (email)=(alice@example.com) already exists"
body := `{"id":"11111111-1111-4111-8111-111111111111","status":"failed","phase":"apply",` +
`"files":[{"name":"001.sql","outcome":"failed","error":{"message":"` + quoted + `"}}]}`
for _, status := range []int{http.StatusAccepted, http.StatusOK} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
client, err := NewClient(server.URL, "pg-grant", WithHTTPClient(server.Client()))
require.NoError(t, err)

_, err = client.CreateDatabaseMigrationRun(context.Background(), redactionTestProjectID(t), "app", "key", nil)
require.Error(t, err, "an unparseable response must not read as success")
assert.NotContains(t, err.Error(), "alice")
assert.Equal(t, status, Status(err))

_, err = client.GetDatabaseMigrationRun(context.Background(), redactionTestProjectID(t), "app",
uuid.MustParse("11111111-1111-4111-8111-111111111111"))
require.Error(t, err)
assert.NotContains(t, err.Error(), "alice")
})
}
}

// Dropping the run must not cost a refusal its message or code, even one whose
// status the API does not declare for these routes.
func TestMigrationRunKeepsTheRefusalMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":"the delegated grant has no mutations left","code":"grant_budget_exhausted"}`))
}))
t.Cleanup(server.Close)
client, err := NewClient(server.URL, "pg-grant", WithHTTPClient(server.Client()))
require.NoError(t, err)

_, err = client.CreateDatabaseMigrationRun(context.Background(), redactionTestProjectID(t), "app", "key", nil)
require.Error(t, err)
assert.Equal(t, http.StatusTooManyRequests, Status(err))
assert.Equal(t, "grant_budget_exhausted", Code(err))
assert.Contains(t, err.Error(), "the delegated grant has no mutations left")

_, err = client.GetDatabaseMigrationRun(context.Background(), redactionTestProjectID(t), "app", uuid.New())
require.Error(t, err)
assert.Equal(t, "grant_budget_exhausted", Code(err))
}
Loading
Loading