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
5 changes: 5 additions & 0 deletions .changeset/nextgen-migrate-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zitadel/server": minor
---

Operators can run `nextgen migrate` to apply schema changes and exit without starting the HTTP server. `server` no longer migrates on start unless you pass `--migrate`; `zitadel start` and the published image still migrate.
18 changes: 10 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ For changes to the Go server, APIs, or database layer.
| Install dependencies | `corepack pnpm install --frozen-lockfile` |
| Verify my toolchain | `moon run workspace:doctor` |
| Start the server (builds console + login-ui, then Go) | `moon run workspace:server` |
| Start the server (skip UI builds) | `go run . server` |
| Start the server (skip UI builds) | `go run . server --migrate` |
| Debug and attach VSCode | `moon run workspace:server-debug` |
| Regenerate Go/OpenAPI artifacts | `moon run server:generate` |
| Verify committed generated output | `moon run server:check-generate` |
Expand All @@ -60,19 +60,21 @@ The Go binary embeds production builds of two frontend apps:
- `apps/login-ui` → `internal/staticui/login/dist/`

`moon run workspace:server` builds both apps before starting Go. If you have not
changed any frontend code, skip the UI builds with `go run . server`.
changed any frontend code, skip the UI builds with `go run . server --migrate`.

To build the UIs manually (when bypassing the wrapper):

```sh
moon run console:build login-ui:build
go run . server
go run . server --migrate
```

With no database configured, the server uses SQLite at
`<server.data_dir>/zitadel.db`. Override with `-c docs/operations/nextgen.example.yaml`,
`NEXTGEN_DATABASE_SQLITE`, or `NEXTGEN_DATABASE_POSTGRES` when you want a
path or DSN you manage.
path or DSN you manage. Schema migrations run when you pass `--migrate`;
`moon run workspace:server` adds that flag. To apply schema and exit without
serving, run `go run . migrate`.

Open http://localhost:8080/ui/console/ and http://localhost:8080/ui/login/

Expand All @@ -82,7 +84,7 @@ Use `server-debug` to build the binary with debug symbols and disabled inlining,
then attach VSCode's Go debugger by PID:

```sh
moon run workspace:server-debug -- server --user-file examples/bootstrap-users/demo-admin.json
moon run workspace:server-debug -- server --migrate --user-file examples/bootstrap-users/demo-admin.json
```

The task prints the `go build` invocation and the PID of the running process. The
Expand All @@ -91,7 +93,7 @@ three `-X` values are stamped from the current HEAD, and `<pkg>` below stands fo

```
[server-debug] build: go build -gcflags 'all=-N -l' -ldflags '-X <pkg>.version=debug+<short-sha> -X <pkg>.commit=<sha> -X <pkg>.date=<commit-date>' -o dist/server/nextgen-debug .
[server-debug] run: ./dist/server/nextgen-debug server --user-file examples/bootstrap-users/demo-admin.json
[server-debug] run: ./dist/server/nextgen-debug server --migrate --user-file examples/bootstrap-users/demo-admin.json

[server-debug] PID 98765 — VSCode: Run ▸ Start Debugging ▸ "Attach to Process"
```
Expand Down Expand Up @@ -143,7 +145,7 @@ the two SPAs bundled directly into the server binary.
| Open the component workbench (Storybook) | `moon run storybook:dev` → http://localhost:6006 |
| Start the console dev server | `moon run console:dev` → http://localhost:5174 |
| Start the login-UI dev server | `moon run login-ui:dev` → http://localhost:5175 |
| Build both apps and test with the Go server | `moon run console:build login-ui:build` then `go run . server` |
| Build both apps and test with the Go server | `moon run console:build login-ui:build` then `go run . server --migrate` |
| Run lint, type checks, and tests | `moon ci :lint :typecheck :build :test` |

Run `corepack pnpm install --frozen-lockfile` first.
Expand All @@ -165,7 +167,7 @@ schema and default login/register flow definition.
**1. Start the API-only backend:**

```sh
NEXTGEN_SERVER_CONSOLE_ENABLED=false NEXTGEN_SERVER_LOGIN_ENABLED=false go run . server
NEXTGEN_SERVER_CONSOLE_ENABLED=false NEXTGEN_SERVER_LOGIN_ENABLED=false go run . server --migrate
```

This skips the embedded UI dist checks, so it works before you have built
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ USER 65532:65532
VOLUME ["/var/lib/zitadel/nextgen-data"]
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/nextgen"]
# Root cobra command is already `server` (see main.go); no extra argv.
# Default argv applies schema migrations then serves. Override with
# `migrate` (apply and exit) or drop `--migrate` once a migrate job has run.
CMD ["--migrate"]
11 changes: 9 additions & 2 deletions apps/cli/src/lib/local-server/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export async function startBinaryRuntime(spec: BinaryRunSpec): Promise<BinaryRun
await mkdir(dirname(spec.logPath), { recursive: true, mode: 0o700 });
const log = await open(spec.logPath, "a", 0o600);
try {
const child = spawn(command.command, command.args, {
const args = withMigrateFlag(command.args);
const child = spawn(command.command, args, {
detached: true,
env: {
...process.env,
Expand All @@ -84,7 +85,7 @@ export async function startBinaryRuntime(spec: BinaryRunSpec): Promise<BinaryRun
schema_version: 1,
backend: "binary",
pid: child.pid,
command: [command.command, ...command.args].join(" "),
command: [command.command, ...args].join(" "),
log_path: spec.logPath,
server_package: command.serverPackage,
server_version: command.serverVersion,
Expand Down Expand Up @@ -269,3 +270,9 @@ function errorMessage(error: unknown): string {
function delay(ms: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}

const MIGRATE_FLAG = "--migrate";

function withMigrateFlag(args: string[]): string[] {
return args.includes(MIGRATE_FLAG) ? args : [...args, MIGRATE_FLAG];
}
7 changes: 5 additions & 2 deletions apps/cli/tests/unit/lib/local-server/binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,22 @@ describe("local server binary helpers", () => {
vi.stubEnv("ZITADEL_SERVER_BINARY", "/tmp/fake-nextgen-server");
const dir = await mkdtemp(join(tmpdir(), "zitadel-binary-test-"));

await startBinaryRuntime({
const runtime = await startBinaryRuntime({
cliVersion: "0.0.0-test",
dataDir: join(dir, "data"),
logPath: join(dir, "logs", "server.log"),
port: 8091,
serverUrl: "http://localhost:8091",
});

const [, , options] = vi.mocked(spawn).mock.calls[0] as unknown as [
const [command, args, options] = vi.mocked(spawn).mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
expect(command).toBe("/tmp/fake-nextgen-server");
expect(args).toEqual(["--migrate"]);
expect(runtime.command).toBe("/tmp/fake-nextgen-server --migrate");
expect(options.env.NEXTGEN_SERVER_ADDRESS).toBe(":8091");
expect(options.env.NEXTGEN_SERVER_PUBLIC_BASE).toBe("http://localhost:8091");
});
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/tests/unit/lib/local-server/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ describe("local server Docker helpers", () => {
expect(dockerfile).toContain(`ENV NEXTGEN_SERVER_DATA_DIR=${CONTAINER_DATA_DIR}`);
// The declared USER is what makes the default location unwritable.
expect(dockerfile).toContain("USER 65532:65532");
// Bare `docker run` must still migrate; the image CMD is how smoke-container
// and `zitadel start --runtime docker` keep applying schema after --migrate
// defaulted off on the binary.
expect(dockerfile).toContain('CMD ["--migrate"]');
});

it("builds the single-container run command without an explicit encryption key", () => {
Expand Down
56 changes: 56 additions & 0 deletions cmd/server/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package server

import (
"context"
"fmt"
"log/slog"

"github.com/spf13/cobra"

"github.com/zitadel/nextgen/internal/instrumentation/zlog"
"github.com/zitadel/nextgen/internal/instrumentation/zotel"
)

func newMigrateCommand(configPath *string) *cobra.Command {
return &cobra.Command{
Use: "migrate",
Short: "Apply database migrations and exit",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig(*configPath)
if err != nil {
return err
}
return migrateDatabase(cmd.Context(), cfg)
},
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
Copilot marked this conversation as resolved.
}

func migrateDatabase(ctx context.Context, cfg Config) error {
metrics, err := zotel.NewOtelMetrics(ctx, zotel.MetricsConfig{
ServiceName: cfg.Instrumentation.ServiceName,
TraceIdFraction: cfg.Instrumentation.Trace.Fraction,
TraceExporter: cfg.Instrumentation.Trace.Exporter,
MetricExporter: cfg.Instrumentation.Metric.Exporter,
LogExporter: cfg.Instrumentation.Log.Exporter,
})
if err != nil {
return fmt.Errorf("failed to create otel metrics: %w", err)
}
defer func() {
_ = metrics.Shutdown(context.WithoutCancel(ctx))
}()
setUpLogging(cfg.Instrumentation.Log, metrics.LoggerProvider())
ctx = zlog.WithLoggingContext(ctx, zlog.WithStream(slog.Default(), zlog.StreamRuntime))

pool, err := startDatabase(ctx, cfg, true)
if err != nil {
return err
}
zlog.Info(ctx, "database migrations applied")
if err := pool.Close(ctx); err != nil {
zlog.WithError(ctx, err).Debug("closing database pool after migrate")
}
return nil
}
95 changes: 95 additions & 0 deletions cmd/server/migrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package server

import (
"bytes"
"database/sql"
"os"
"path/filepath"
"testing"

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

_ "modernc.org/sqlite"
)

func TestCommandHelpListsMigrate(t *testing.T) {
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--help"})
require.NoError(t, cmd.Execute())

var migrateCmd, serverCmd bool
for _, c := range cmd.Commands() {
switch c.Name() {
case "migrate":
migrateCmd = true
assert.Equal(t, "Apply database migrations and exit", c.Short)
case "server":
serverCmd = true
require.NotNil(t, c.Flags().Lookup("migrate"), "expected --migrate on server")
}
}
assert.True(t, migrateCmd, "expected a command named migrate")
assert.True(t, serverCmd, "expected a command named server")
require.NotNil(t, cmd.Flags().Lookup("migrate"), "expected --migrate on root")
}

func TestMigrateCommandAppliesSchemaIdempotently(t *testing.T) {
dataDir, configPath := tempServerConfig(t)

for range 2 {
cmd := NewCommand()
cmd.SetArgs([]string{"migrate", "--config", configPath})
executed, err := cmd.ExecuteC()
require.NoError(t, err)
assert.Equal(t, "migrate", executed.Name())
}

assert.True(t, sqliteHasGooseTable(t, defaultSQLitePath(dataDir)))
}

func TestStartDatabaseSkipsMigrationsUnlessRequested(t *testing.T) {
dataDir, configPath := tempServerConfig(t)
cfg, err := loadConfig(configPath)
require.NoError(t, err)

ctx := t.Context()
skipped, err := startDatabase(ctx, cfg, false)
require.NoError(t, err)
require.NoError(t, skipped.Close(ctx))

dbPath := defaultSQLitePath(dataDir)
assert.False(t, sqliteHasGooseTable(t, dbPath), "goose table should be absent without --migrate")

applied, err := startDatabase(ctx, cfg, true)
require.NoError(t, err)
require.NoError(t, applied.Close(ctx))
assert.True(t, sqliteHasGooseTable(t, dbPath))
}

func tempServerConfig(t *testing.T) (dataDir, configPath string) {
t.Helper()
dataDir = t.TempDir()
t.Setenv("NEXTGEN_SERVER_DATA_DIR", dataDir)
configPath = filepath.Join(t.TempDir(), "nextgen.yaml")
require.NoError(t, os.WriteFile(configPath, nil, 0o600))
return dataDir, configPath
}

func sqliteHasGooseTable(t *testing.T, dbPath string) bool {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
defer db.Close()

var name string
err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'goose_db_version'`).Scan(&name)
if err == sql.ErrNoRows {
return false
}
require.NoError(t, err)
return name == "goose_db_version"
}
62 changes: 45 additions & 17 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,46 @@ import (
func NewCommand() *cobra.Command {
var configPath string
var userFiles []string
var applyMigrations bool

cmd := &cobra.Command{
Use: "server",
Short: "Run the server",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig(configPath)
if err != nil {
return err
}
return run(cmd.Context(), cfg, userFiles)
},
runServer := func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig(configPath)
if err != nil {
return err
}
return run(cmd.Context(), cfg, userFiles, applyMigrations)
}

cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to YAML configuration file")
cmd.Flags().StringArrayVar(&userFiles, "user-file", nil, "Bootstrap user JSON file (repeatable)")
root := &cobra.Command{
Use: "nextgen",
Short: "Run the server",
SilenceUsage: true,
SilenceErrors: true,
RunE: runServer,
}
Comment thread
Copilot marked this conversation as resolved.
root.PersistentFlags().StringVarP(&configPath, "config", "c", "", "Path to YAML configuration file")
addServerFlags(root, &applyMigrations, &userFiles)

return cmd
serverCmd := &cobra.Command{
Use: "server",
Short: "Run the server",
SilenceUsage: true,
SilenceErrors: true,
RunE: runServer,
}
Comment thread
Copilot marked this conversation as resolved.
addServerFlags(serverCmd, &applyMigrations, &userFiles)

root.AddCommand(serverCmd)
root.AddCommand(newMigrateCommand(&configPath))
return root
}

func addServerFlags(cmd *cobra.Command, applyMigrations *bool, userFiles *[]string) {
cmd.Flags().BoolVar(applyMigrations, "migrate", false, "Apply database migrations before serving")
cmd.Flags().StringArrayVar(userFiles, "user-file", nil, "Bootstrap user JSON file (repeatable)")
}

func run(ctx context.Context, cfg Config, userFiles []string) error {
func run(ctx context.Context, cfg Config, userFiles []string, applyMigrations bool) error {
var err error
sfs := &ShutdownFuncs{}
defer func() {
Expand Down Expand Up @@ -99,7 +119,7 @@ func run(ctx context.Context, cfg Config, userFiles []string) error {

setUpLogging(cfg.Instrumentation.Log, metrics.LoggerProvider())

pool, err := startDatabase(ctx, cfg)
pool, err := startDatabase(ctx, cfg, applyMigrations)
if err != nil {
return err
}
Expand Down Expand Up @@ -606,16 +626,24 @@ func buildHTTPMux(cfg ServerConfig, reqIdGen middleware.RequestIDGenerator, apiH

// ----------------------------- STORAGE --------------------------------------

func startDatabase(ctx context.Context, cfg Config) (database.Pool, error) {
func connectDatabase(ctx context.Context, cfg Config) (database.Pool, error) {
dialect, err := buildDatabaseDialect(cfg)
if err != nil {
return nil, err
}
pool, err := database.Connect(ctx, dialect)
return database.Connect(ctx, dialect)
}

func startDatabase(ctx context.Context, cfg Config, applyMigrations bool) (database.Pool, error) {
pool, err := connectDatabase(ctx, cfg)
if err != nil {
return nil, err
}
if !applyMigrations {
return pool, nil
}
if err := pool.Migrate(ctx); err != nil {
_ = pool.Close(ctx)
return nil, err
}
return pool, nil
Expand Down
Loading
Loading