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
38 changes: 34 additions & 4 deletions docs/git.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ into an empty repository; Volcano pushes that first commit itself.
- `root-directory` changes only the subdirectory the connected repository
builds from, and works with a project access token, so a CI job or an agent
can use it.
- `production-branch`, `auto-deploy`, `functions` and `frontend` each change
- `production-branch`, `auto-deploy`, `functions`, `migrations` and `frontend` each change
one thing about what a push deploys, and also work with a project access
token.
- What a push actually deploys is governed by the project's Git deploy settings,
Expand All @@ -47,6 +47,7 @@ into an empty repository; Volcano pushes that first commit itself.
| Change the branch that deploys | `volcano git production-branch <branch>` |
| Turn deploying on push on or off | `volcano git auto-deploy on\|off` |
| Choose whether functions deploy | `volcano git functions on\|off` |
| Choose whether a push applies database migrations | `volcano git migrations on\|off [--yes]` |
| Choose the frontend a push deploys | `volcano git frontend <name> [--root <path>]` |
| Export the project's source to an empty repository | `volcano git export [--branch <branch>]` |
| Cancel an incomplete export | `volcano git export cancel` |
Expand All @@ -61,7 +62,8 @@ volcano git status
This reports the project's own binding and every Git deploy setting: the
repository, the branch a push has to land on and whether it follows the
repository's default branch or is pinned, the subdirectory it builds from,
whether auto-deploy is on, whether functions deploy, and the frontend with the
whether auto-deploy is on, whether functions deploy, whether a push applies
database migrations, and the frontend with the
directory it builds from. It also reports the project's source of truth:
Volcano, or the repository once an export has handed it over. It ends with what
a push deploys. It does
Expand Down Expand Up @@ -279,6 +281,34 @@ Turning either setting off removes nothing: what is already deployed stays up.
The other deploy settings stay as they were, and the command says what a push
deploys from now on. Both work with a project access token.

## Applying database migrations on push

```bash
volcano git migrations on # asks for confirmation; --yes skips it
volcano git migrations off
```

While it is on, each push to the production branch applies the commit's
pending migrations, the SQL files under `volcano/migrations`, before anything
deploys. Each database gets a tracked migration run; when one fails, nothing
from the push deploys and the live version keeps serving. It is off until you
turn it on. Like every push deploy, it needs auto-deploy on; with neither
functions nor a frontend selected, a push applies its migrations and deploys no
code.

Anyone who can push to the production branch can then run any SQL as the
database owner, so protect that branch first. The confirmation names the
repository and branch; if the connection changes before you answer, nothing is
saved. Volcano applies only the files
its migration history does not list yet: if some were already applied another
way, record them first with `volcano cloud databases migration baseline`. See
[Database migrations](databases.md) for the files and runs.

Turning it off undoes nothing: applied migrations stay, and pushes stop
applying new ones. The other settings change nothing about it, and it changes
nothing about them. Where database migrations are not available, turning it on
fails and saves nothing.

## Choosing the frontend a push deploys

A connected repository deploys its functions on push. To deploy a frontend from
Expand Down Expand Up @@ -399,8 +429,8 @@ integration is not configured. Run these commands against the cloud API.

`volcano git disconnect` reads only the project's own binding, which does not
touch GitHub, so it reports that the project has nothing connected instead.
`volcano git root-directory`, `production-branch`, `auto-deploy`, `functions`
and `frontend` report that the integration is not configured, since no
`volcano git root-directory`, `production-branch`, `auto-deploy`, `functions`,
`migrations` and `frontend` report that the integration is not configured, since no
repository can be connected there for them to change. `volcano git export`
reports that source export is not available: local mode serves functions from a
directory on your machine, so Volcano stores no source to export.
4 changes: 3 additions & 1 deletion internal/cmd/git/auto_deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ project needs a connected repository first: see '%s'.`,
}
return runDeploySetting(cmd.Context(), deps, cmd.OutOrStdout(), deploySetting{
subcommand: "auto-deploy",
change: func(ctx context.Context, service gitconnect.Service) (*apiclient.ProjectGitDeploySettings, error) {
change: func(
ctx context.Context, service gitconnect.Service, _ *apiclient.ProjectGitConnection,
) (*apiclient.ProjectGitDeploySettings, error) {
return service.SetAutoDeploy(ctx, on)
},
render: output.GitAutoDeploySet,
Expand Down
20 changes: 17 additions & 3 deletions internal/cmd/git/deploy_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ type deploySetting struct {
// subcommand is the command's own name, whose next step the output leaves
// out.
subcommand string
change func(context.Context, gitconnect.Service) (*apiclient.ProjectGitDeploySettings, error)
render func(io.Writer, output.GitBinding)
// change saves the setting. It is given the binding the command read and
// showed, for a change that must not land on any other.
change func(
context.Context, gitconnect.Service, *apiclient.ProjectGitConnection,
) (*apiclient.ProjectGitDeploySettings, error)
render func(io.Writer, output.GitBinding)
// confirm, when set, is asked once the repository is known and before
// anything is saved. Declining saves nothing and is not an error.
confirm func(*apiclient.ProjectGitConnection) (bool, error)
}

// runDeploySetting changes one deploy setting on a project with a repository
Expand All @@ -36,7 +43,14 @@ func runDeploySetting(ctx context.Context, deps cliruntime.Deps, out io.Writer,
return err
}

settings, err := setting.change(ctx, service)
if setting.confirm != nil {
confirmed, err := setting.confirm(connection)

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: The migrations confirmation is not re-checked against the binding after the prompt

The prompt names the production branch and repository from the binding read before asking, then setting.change saves without checking that binding again. If the connection is replaced or its production branch changes while the prompt is open, the user's "yes" turns on SQL-on-push for a branch or repository they never confirmed. The rendered output then shows the stale binding. git disconnect passes the binding it showed to service.Disconnect, which re-checks it with confirmUnchanged for this reason. Re-checking the binding after the prompt (for example with confirmUnchanged, or comparing it with sameBinding) would close the window. The race is narrow, but this is the confirmation for a security-sensitive setting.

Found by claude-opus-5-5

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 P2: Revalidate the confirmed Git binding before enabling migrations

The enabling path needs to verify that the current binding still matches the connection shown in the confirmation. SetApplyMigrations rereads only deploy settings, so a concurrent repository or production-branch update leaves approval tied to stale connection details. Pass the expected connection into the service and reuse confirmUnchanged before saving, as Connect and Disconnect already do.

Found by gpt-6.1-sol

if err != nil || !confirmed {
return err
}
Comment on lines +47 to +50
}

settings, err := setting.change(ctx, service, connection)
if err != nil {
return guide(deps, err)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/git/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ func frontendArguments(args []string, root string, rootSet, none bool) (name, fr
func runFrontend(ctx context.Context, opts frontendOptions) error {
return runDeploySetting(ctx, opts.deps, opts.out, deploySetting{
subcommand: "frontend",
change: func(ctx context.Context, service gitconnect.Service) (*apiclient.ProjectGitDeploySettings, error) {
change: func(
ctx context.Context, service gitconnect.Service, _ *apiclient.ProjectGitConnection,
) (*apiclient.ProjectGitDeploySettings, error) {
return service.SetFrontend(ctx, opts.name, opts.root)
},
render: output.GitFrontendSet,
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/git/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ project needs a connected repository first: see '%s'.`,
}
return runDeploySetting(cmd.Context(), deps, cmd.OutOrStdout(), deploySetting{
subcommand: "functions",
change: func(ctx context.Context, service gitconnect.Service) (*apiclient.ProjectGitDeploySettings, error) {
change: func(
ctx context.Context, service gitconnect.Service, _ *apiclient.ProjectGitConnection,
) (*apiclient.ProjectGitDeploySettings, error) {
return service.SetDeployFunctions(ctx, on)
},
render: output.GitFunctionsSet,
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ an empty repository once; Volcano pushes that first commit itself.`,
cmd.AddCommand(newProductionBranch(deps))
cmd.AddCommand(newAutoDeploy(deps))
cmd.AddCommand(newFunctions(deps))
cmd.AddCommand(newMigrations(deps))
cmd.AddCommand(newFrontend(deps))
cmd.AddCommand(newExport(deps))
cmd.AddCommand(newDisconnect(deps))
Expand Down
8 changes: 7 additions & 1 deletion internal/cmd/git/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ type gitAPI struct {

autoDeploy bool
deployFunctions bool
applyMigrations bool
frontend string
frontendRoot string
// providerStatus overrides the response on the routes that reach the git
Expand Down Expand Up @@ -549,7 +550,8 @@ func (a *gitAPI) serveDeploySettings(w http.ResponseWriter) {
}

// serveUpdateDeploySettings replaces every deploy setting with what it was
// sent, as the platform does: a field left out is cleared, not kept.
// sent, as the platform does: a field left out is cleared, not kept, except
// apply_migrations, which keeps its saved value when left out.
func (a *gitAPI) serveUpdateDeploySettings(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
Expand All @@ -563,13 +565,17 @@ func (a *gitAPI) serveUpdateDeploySettings(w http.ResponseWriter, r *http.Reques
a.deployFunctions, _ = a.settingsBody["deploy_functions"].(bool)
a.frontend, _ = a.settingsBody["frontend_name"].(string)
a.frontendRoot, _ = a.settingsBody["frontend_app_root"].(string)
if apply, ok := a.settingsBody["apply_migrations"].(bool); ok {
a.applyMigrations = apply
}
writeGitJSON(a.t, w, http.StatusOK, a.deploySettings())
}

func (a *gitAPI) deploySettings() map[string]any {
settings := map[string]any{
"auto_deploy_enabled": a.autoDeploy,
"deploy_functions": a.deployFunctions,
"apply_migrations": a.applyMigrations,
"updated_at": "2026-08-18T00:00:00Z",
}
if a.frontend != "" {
Expand Down
79 changes: 79 additions & 0 deletions internal/cmd/git/migrations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package git

import (
"context"
"fmt"

"github.com/spf13/cobra"

"github.com/Kong/volcano-cli/internal/apiclient"
"github.com/Kong/volcano-cli/internal/gitconnect"
"github.com/Kong/volcano-cli/internal/output"
cliruntime "github.com/Kong/volcano-cli/internal/runtime"
)

func newMigrations(deps cliruntime.Deps) *cobra.Command {
var yes bool
// A push applies migrations to the cloud project's databases, so the
// baseline is the cloud one: the top-level databases commands run against
// the local server.
baseline := cliruntime.CommandPath(deps, "cloud databases migration baseline")
cmd := &cobra.Command{
Use: "migrations on|off",
Short: "Choose whether a push to the connected repository applies its database migrations",
Long: fmt.Sprintf(`Choose whether a push to the connected repository's production branch applies
the commit's pending migrations, the SQL files under volcano/migrations, before
it deploys anything. Each database gets a tracked migration run; when one fails,
nothing from the push deploys and the live version keeps serving.

Anyone who can push to the production branch can then run any SQL as the
database owner, so protect that branch first. Turning it on asks for
confirmation; use --yes to skip the prompt.

Volcano applies only the files its migration history does not list yet. If
some files were already applied another way, record them first:
%s

Turning it off undoes nothing: applied migrations stay, and pushes stop
applying new ones. The other deploy settings stay as they were; see
'%s' for all of them.

This works with a project access token, so CI or an agent can set it. The
project needs a connected repository first: see '%s'.`,
baseline,
cliruntime.CommandPath(deps, "git status"),
cliruntime.CommandPath(deps, "git connect")),
Example: " volcano git migrations on\n volcano git migrations on --yes\n volcano git migrations off",
Args: cobra.ArbitraryArgs,
ValidArgs: []string{"on", "off"},
RunE: func(cmd *cobra.Command, args []string) error {
on, err := onOffArgument(args, "migrations")
if err != nil {
return err
}
setting := deploySetting{
subcommand: "migrations",
change: func(
ctx context.Context, service gitconnect.Service, shown *apiclient.ProjectGitConnection,
) (*apiclient.ProjectGitDeploySettings, error) {
return service.SetApplyMigrations(ctx, on, shown)
},
render: output.GitMigrationsSet,
}
if on {
setting.confirm = func(connection *apiclient.ProjectGitConnection) (bool, error) {
return ask(cmd.InOrStdin(), cmd.OutOrStdout(), yes,
fmt.Sprintf("Each push to %[1]s of %[2]s will apply the SQL files under volcano/migrations "+
"to the project's databases as their owner, before it deploys. Anyone who can push "+
"to %[1]s can then run any SQL on them, so protect that branch first.\n"+
"Files already applied another way need a baseline first: %[3]s",
connection.ProductionBranch, connection.RepoFullName, baseline),
"Apply migrations on push?")
}
}
return runDeploySetting(cmd.Context(), deps, cmd.OutOrStdout(), setting)
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt when turning migrations on")
return cmd
}
Loading
Loading