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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ api-e2e-cloud: ## Run provisioning CLI API E2E tests against VOLCANO_API_URL and
VOLCANO_API_E2E=1 go test ./tests/e2e/api -run '^TestAPIE2E(Smoke|Cloud)' -count=1 -timeout 240m

localmode-e2e: ## Run destructive local-mode Docker smoke tests
VOLCANO_LOCALMODE_E2E=1 go test ./tests/e2e/localmode -run 'TestLocalModeE2E(Smoke|StopCleanSandboxes|DurableMessages|DurableCallbacks)' -count=1 -timeout 35m
VOLCANO_LOCALMODE_E2E=1 go test ./tests/e2e/localmode -run 'TestLocalModeE2E(Smoke|StopCleanSandboxes|DurableMessages|DurableCallbacks|DatabaseMigrations)' -count=1 -timeout 35m

lint: ## Run golangci-lint (includes gofmt, goimports, and go vet)
go tool golangci-lint run ./...
Expand Down
108 changes: 94 additions & 14 deletions docs/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,97 @@ it using its connection string.
| Delete | `volcano databases delete <name>` |
| List regions and PostgreSQL versions | `volcano cloud databases regions`, `volcano databases versions` |
| 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>` |
| Compare migration files with the history | `volcano databases migration status -d <db>` |
| Apply pending migrations | `volcano databases migration apply -d <db>` |
| Record files that already ran | `volcano databases migration baseline -d <db> --all` |
| List or show migration runs | `volcano databases migration runs list -d <db>` |
| Run migration files without tracking | `volcano databases migration up --all -d <db>` |

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.
Every migration command works in both trees: prefix it with `cloud` for a cloud
project. Local development also keeps `volcano migrations deploy --all -d <db>`,
the untracked `up`.

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.
## Migrations

Migrations are SQL files in `volcano/migrations`. With one database, put them in
`volcano/migrations` itself. With several, put each database's files in
`volcano/migrations/<database>/`, and put nothing at the top level. Names that
start with `_` or `.` are skipped, and files run in byte order of their names.
Tracked runs take only names that end in lowercase `.sql`.
Git deploy reads the directory the same way, so a layout the CLI refuses is one
a push would refuse too.

### Tracked runs

`migration apply` sends a database's files to a tracked migration run. Volcano
compares them with the database's migration history and applies only the files
the history does not record, then records them. A file runs once, however many
times you apply.

```bash
volcano cloud databases migration apply -d app
```

```text
Starting a migration run of 3 file(s) from volcano/migrations on database 'app'
Run 7f3a1c2e-5d4b-4a91-8c6f-2b1e9d0a4c73 queued
running: rehearse
running: apply
✓ Migration run 7f3a1c2e-5d4b-4a91-8c6f-2b1e9d0a4c73 succeeded: 1 applied, 2 already applied
```

Before it changes the database, a cloud run rehearses the pending files on a
short-lived branch, and takes a backup where the plan includes backups. A file
that fails in the rehearsal fails the run without touching the database. Local
development has no branches or backups, so a local run applies directly and
reports `rehearsal_skipped` and `backup_skipped` warnings.

A file may hold several statements and runs in one transaction. It cannot
`COMMIT`, `ROLLBACK`, or use savepoints. A statement that cannot run in a
transaction, such as `CREATE INDEX CONCURRENTLY`, goes alone in a file whose
first line is `-- volcano:no-transaction`. That file runs outside a
transaction, so nothing rolls it back, and a retried run runs it again from its
start: write it to be safe to repeat, such as with `IF NOT EXISTS`. A run refuses a file whose content changed after
it was applied (`checksum_mismatch`): put the recorded content back and make the
change in a new file. One database runs one migration run at a time; a second
`apply` while one is queued or running names the run to wait for.

`migration apply --no-wait` returns once the run is queued. `migration runs list`
shows a database's 50 most recent runs, including the ones Git deploys start,
and `migration runs get <run-id>` shows one run's files and why it failed.

### Adopt a database migrated without tracking

A database migrated with `migration up`, `migrations deploy`, or a
`--with-migrations` deploy from a CLI before tracked runs already reflects its
files, but its history records none of them. Record them first, or the first
run applies them again:

```bash
volcano cloud databases migration status -d app # every file is pending
volcano cloud databases migration baseline -d app --all
volcano cloud databases migration apply -d app # applies only new files
```

`baseline` records files as applied without running them. Use `-f <file>`,
repeatable, to record only some of them. It asks for confirmation; pass `--yes`
to skip it.

### Availability

Tracked runs need the `databases.migrations` capability, which the CLI reads
from the API's `GET /capabilities` before it starts one. Where the API does not
offer it,
`migration apply`, `migration baseline`, and `deploy --with-migrations` refuse
before sending anything and name the untracked command instead. `migration
status` and `migration runs` keep working, so you can still read what earlier
runs did. Local development always offers tracked runs.

### Run files without tracking

`migration up` connects through the managed database proxy and runs each file
as a single query, whether or not it ran before. The proxy refuses a query with
more than one statement, so put one SQL statement in each file.

## Regions and versions

Expand Down Expand Up @@ -241,9 +320,10 @@ volcano databases create app --type volcano-db-s --region us-east-1
# Show the connection string
volcano databases get app --show-connection-string

# Apply migration files from volcano/migrations to the "app" database
volcano migrations deploy --all -d app
# Apply pending migration files from volcano/migrations to the local "app" database
volcano databases migration apply -d app

# Apply migration files to the managed cloud database named "app"
volcano cloud databases migration up --all -d app
# Apply them to the cloud database named "app", and see what is recorded
volcano cloud databases migration apply -d app
volcano cloud databases migration status -d app
```
40 changes: 32 additions & 8 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The steps run in this order. A step with nothing to deploy is skipped.
| Step | Reads | Same as |
|---|---|---|
| variables | the env file, or the variable sections of the manifests | `variables deploy` |
| migrations | `volcano/migrations/*.sql`, only with `--with-migrations` | `migrations deploy --all` (cloud: `databases migration up --all`) |
| migrations | `volcano/migrations`, only with `--with-migrations` | `databases migration apply -d <db>` |
| functions | the standard functions in `volcano/functions` | `functions deploy --all` |
| durable | the functions the first manifest declares `kind: durable` | `durable deploy --all` |
| frontends | the frontends listed in `.volcano/source-layout.json` | `frontends deploy` |
Expand Down Expand Up @@ -87,8 +87,8 @@ its status is `provisioning`.

| Flag | Description |
|---|---|
| `--with-migrations` | Apply every migration in `volcano/migrations`. |
| `-d, --database` | Database to migrate. Local mode defaults to `app`. In the cloud, the default is the database the manifests' `databases:` sections declare, if they declare exactly one. |
| `--with-migrations` | Apply `volcano/migrations` in tracked migration runs. |
| `-d, --database` | Database to migrate. With a directory per database in `volcano/migrations`, the default is every database that has one. Otherwise local mode defaults to `app`, and the cloud defaults to the database the manifests' `databases:` sections declare, if they declare exactly one. |
| `--env-file` | Env file to deploy. Default: `volcano/volcano.env` or `./volcano.env`. |
| `--no-env-file` | Deploy no env file, even when `volcano/volcano.env` exists. Use it when a manifest's `variables` section holds the project's variables. |
| `-c, --config` | Manifest to apply. Repeat it to apply several, in order. Passing any `-c` replaces default lookup: `./volcano-config.yaml` first, then `volcano/volcano-config.yaml` only when the root file is absent. |
Expand Down Expand Up @@ -144,11 +144,35 @@ each manifest that has them. The config step applies the full manifests later.

## Migrations

Migrations run only with `--with-migrations`, and without tracking: every file
runs again on each run. Write migrations that are safe to re-run, for example
with `create table if not exists`, and put one SQL statement in each file. When
a later step fails, run the command again without `--with-migrations` and
`-d`, since the migrations already ran.
Migrations run only with `--with-migrations`. The step starts a tracked
migration run for each database, the same run
[`databases migration apply`](databases.md#tracked-runs) starts, and waits for
it to end, even with `--no-wait`. The database's migration history records each
file it applies, so a file runs once: running the deploy again after a later
step failed applies nothing new.

```text
Starting a migration run of 3 file(s) from volcano/migrations on database 'app'
Run 7f3a1c2e-5d4b-4a91-8c6f-2b1e9d0a4c73 queued
running: apply
✓ Migration run 7f3a1c2e-5d4b-4a91-8c6f-2b1e9d0a4c73 succeeded: 1 applied, 2 already applied
```

A run that fails stops the deploy before the functions step. With one database,
put its files in `volcano/migrations`; with several, put each database's files
in `volcano/migrations/<database>/`, and the step migrates each of them.

A database that an earlier CLI migrated with `--with-migrations` already
reflects its files, but its history records none of them. Record them once
before the next deploy, or the run applies them again:

```bash
volcano cloud databases migration baseline -d app --all
```

Tracked runs need the `databases.migrations` capability. Where the API does not
offer it, `--with-migrations` refuses before anything changes and names
`databases migration up --all`, which runs every file again without tracking.

## Frontends

Expand Down
28 changes: 28 additions & 0 deletions internal/api/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package api

import (
"context"

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

// CapabilityAvailable reports whether the API lists capability id as
// available. The list is how a client learns what an environment offers, so a
// capability it leaves out, or reports with any status but available, is
// unavailable.
func (c *Client) CapabilityAvailable(ctx context.Context, id string) (bool, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ P3: CapabilityAvailable duplicates the existing ListCapabilities call

internal/api/lavaflow.go:23 already has Client.ListCapabilities, which calls the same ListCapabilitiesWithResponse, and sharing.CheckAvailable looks up its capability on top of it. The new CapabilityAvailable makes a second wrapper for the same route. Building it on ListCapabilities, or doing the lookup in database.Service.RequireMigrationRuns the way sharing does, keeps one place that reads /capabilities.

Found by claude-opus-5-5

resp, err := c.client.ListCapabilitiesWithResponse(ctx)
if err != nil {
return false, err
}
list, err := apiResult[apiclient.CapabilityList](resp.StatusCode(), resp.Body, resp.JSON200)
if err != nil {
return false, err
}
for _, capability := range list.Data {
if capability.Id == id {
return capability.Status == apiclient.CapabilityStatusAvailable, nil
}
}
return false, nil
}
121 changes: 121 additions & 0 deletions internal/api/database_migration_runs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package api

import (
"context"
"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"

// MigrationRunInProgressError is a migration request refused because the
// database already has a queued or running run, which RunID names.
type MigrationRunInProgressError struct {
Err *Error
RunID uuid.UUID
}

func (e *MigrationRunInProgressError) Error() string { return e.Err.Error() }

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

// GetDatabaseMigrations returns the files a database's migration history
// records and the run that is queued or running, if any.
func (c *Client) GetDatabaseMigrations(ctx context.Context, projectID uuid.UUID, databaseName string) (*apiclient.DatabaseMigrations, error) {
resp, err := c.client.GetDatabaseMigrationsWithResponse(ctx, projectID, strings.TrimSpace(databaseName))
if err != nil {
return nil, err
}
return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON404, resp.JSON409, resp.JSON503)
}

// 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 each file's outcome.
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)
}

// ListDatabaseMigrationRuns returns a database's 50 most recent migration
// runs, newest first, without their files.
func (c *Client) ListDatabaseMigrationRuns(ctx context.Context, projectID uuid.UUID, databaseName string) (*apiclient.DatabaseMigrationRunList, error) {
resp, err := c.client.ListDatabaseMigrationRunsWithResponse(ctx, projectID, strings.TrimSpace(databaseName))
if err != nil {
return nil, err
}
// A run's error can quote data, as in GetDatabaseMigrationRun.
return apiResult(resp.StatusCode(), errorFieldsOnly(resp.Body), resp.JSON200, resp.JSON404)
}
Loading
Loading