Add the coordinator setup wizard for self-provisioned databases
This commit is contained in:
@@ -1004,18 +1004,20 @@ schema, and a `setup` command walks the operator through the remaining
|
|||||||
environment (database creation, secrets, admin account).
|
environment (database creation, secrets, admin account).
|
||||||
|
|
||||||
**Depends on:** the Go coordinator and the release build pipeline. Step 1
|
**Depends on:** the Go coordinator and the release build pipeline. Step 1
|
||||||
(embedded migrations, `AUTO_MIGRATE`) is implemented; steps 2–3 below are
|
(embedded migrations, `AUTO_MIGRATE`) and step 2 (the `coordinator setup`
|
||||||
the remaining work.
|
wizard: database reachability and creation, schema migration, `.env` with a
|
||||||
|
generated `JWT_SECRET`, readiness summary) are implemented; step 3 — a fully
|
||||||
|
embedded userservice — is the remaining work.
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
|
|
||||||
- the binary embeds the migrations and applies pending ones on startup by
|
- the binary embeds the migrations and applies pending ones on startup by
|
||||||
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
|
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
|
||||||
idempotent and safe under concurrent starts;
|
idempotent and safe under concurrent starts;
|
||||||
- `coordinator setup` (interactive, then non-interactive with flags) checks
|
- `coordinator setup` (interactive, then non-interactive with `--yes`) checks
|
||||||
database reachability, offers to create the role/database when credentials
|
database reachability, offers to create the role/database when credentials
|
||||||
allow it, applies migrations, writes a `.env` with a generated `JWT_SECRET`
|
allow it, applies migrations, writes a `.env` with a generated `JWT_SECRET`
|
||||||
and storage path, and verifies readiness with `/health`;
|
and storage path, and prints exact next steps;
|
||||||
- `coordinator --version` and the setup output agree on the release build;
|
- `coordinator --version` and the setup output agree on the release build;
|
||||||
- the wizard explains what it cannot do itself: running PostgreSQL and the
|
- the wizard explains what it cannot do itself: running PostgreSQL and the
|
||||||
userservice, with concrete commands (docker compose, systemd) to finish;
|
userservice, with concrete commands (docker compose, systemd) to finish;
|
||||||
@@ -1164,10 +1166,9 @@ Do not start these before CTX-12 is accepted.
|
|||||||
- Add shard caching and content-addressed input deduplication.
|
- Add shard caching and content-addressed input deduplication.
|
||||||
- Add job priority and fair scheduling.
|
- Add job priority and fair scheduling.
|
||||||
- Add a CLI for submitting and monitoring remote jobs.
|
- Add a CLI for submitting and monitoring remote jobs.
|
||||||
- Implement CTX-17 steps 2–3: the interactive `coordinator setup` wizard
|
- Implement CTX-17 step 3: a fully embedded userservice
|
||||||
(database creation, `.env` generation, admin seeding) and, later, a fully
|
(`coordinator userservice` subcommand) so one binary can serve the whole
|
||||||
embedded userservice (`coordinator userservice` subcommand) so one binary
|
platform without containers.
|
||||||
can serve the whole platform without containers.
|
|
||||||
- Replace PostgreSQL with an embedded SQLite backend for fully self-contained
|
- Replace PostgreSQL with an embedded SQLite backend for fully self-contained
|
||||||
single-binary deployments (large storage-layer change; postgres row locks,
|
single-binary deployments (large storage-layer change; postgres row locks,
|
||||||
transactions, and integration tests must be re-derived).
|
transactions, and integration tests must be re-derived).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator workloads-export demo-ui demo-down demo-reset demo-logs
|
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator setup workloads-export demo-ui demo-down demo-reset demo-logs
|
||||||
|
|
||||||
# `check` deliberately uses its own Compose project and host ports. This keeps
|
# `check` deliberately uses its own Compose project and host ports. This keeps
|
||||||
# it from connecting to or replacing a developer's local PostgreSQL instance.
|
# it from connecting to or replacing a developer's local PostgreSQL instance.
|
||||||
@@ -51,6 +51,12 @@ coordinator:
|
|||||||
' COORDINATOR_STORAGE_DIR, SCIMESH_DOCS_DIR, JWT_SECRET, USERSERVICE_URL' \
|
' COORDINATOR_STORAGE_DIR, SCIMESH_DOCS_DIR, JWT_SECRET, USERSERVICE_URL' \
|
||||||
' (embedded schema migrations run on startup; AUTO_MIGRATE=false disables)'
|
' (embedded schema migrations run on startup; AUTO_MIGRATE=false disables)'
|
||||||
|
|
||||||
|
# Interactive wizard: checks the database, creates it when missing (via
|
||||||
|
# POSTGRES_ADMIN_URL or --admin-db), applies the embedded schema, generates a
|
||||||
|
# JWT_SECRET, and writes a .env file. Non-interactive: SETUP_ARGS=--yes.
|
||||||
|
setup: coordinator
|
||||||
|
./bin/coordinator setup $(SETUP_ARGS)
|
||||||
|
|
||||||
workloads-export:
|
workloads-export:
|
||||||
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
|
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
|
||||||
|
|
||||||
@@ -63,6 +69,7 @@ help:
|
|||||||
' make demo-down Stop the demo services and workers.' \
|
' make demo-down Stop the demo services and workers.' \
|
||||||
' make demo-reset Stop the demo and wipe its data volumes.' \
|
' make demo-reset Stop the demo and wipe its data volumes.' \
|
||||||
' make workloads-export Regenerate the embedded UI workload catalog.' \
|
' make workloads-export Regenerate the embedded UI workload catalog.' \
|
||||||
|
' make setup Interactive one-shot provisioning wizard.' \
|
||||||
' make test / make vet Run Go verification.' \
|
' make test / make vet Run Go verification.' \
|
||||||
'' \
|
'' \
|
||||||
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
|
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ import (
|
|||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
args := os.Args[1:]
|
||||||
|
if len(args) > 0 && args[0] == "setup" {
|
||||||
|
if err := runSetup(args[1:]); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
showVersion := flag.Bool("version", false, "print the build version and exit")
|
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
if *showVersion {
|
if *showVersion {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runSetup implements `coordinator setup` with non-interactive flags and an
|
||||||
|
// interactive fallback for anything still missing.
|
||||||
|
func runSetup(args []string) error {
|
||||||
|
flags := flag.NewFlagSet("setup", flag.ContinueOnError)
|
||||||
|
flags.Usage = func() {
|
||||||
|
fmt.Fprintf(flags.Output(), "usage: coordinator setup [options]\n")
|
||||||
|
fmt.Fprintf(flags.Output(), "Provisions the coordinator database, schema, and local .env settings.\n\n")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
databaseURL = flags.String("db", "", "coordinator database URL (default: DATABASE_URL)")
|
||||||
|
adminURL = flags.String("admin-db", "", "maintenance URL to create a missing database (default: same host, 'postgres' db)")
|
||||||
|
envFile = flags.String("env-file", "", "settings file to write (default: .env)")
|
||||||
|
force = flags.Bool("force", false, "overwrite an existing settings file")
|
||||||
|
yes = flags.Bool("yes", false, "non-interactive: use defaults, fail on anything missing")
|
||||||
|
)
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flags.NArg() > 0 {
|
||||||
|
return fmt.Errorf("setup takes no positional arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseURLValue := *databaseURL
|
||||||
|
if databaseURLValue == "" {
|
||||||
|
databaseURLValue = os.Getenv("DATABASE_URL")
|
||||||
|
}
|
||||||
|
envFileValue := *envFile
|
||||||
|
if envFileValue == "" {
|
||||||
|
envFileValue = os.Getenv("ENV_FILE")
|
||||||
|
}
|
||||||
|
adminURLValue := *adminURL
|
||||||
|
if adminURLValue == "" {
|
||||||
|
adminURLValue = os.Getenv("POSTGRES_ADMIN_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
options := setup.Options{
|
||||||
|
DatabaseURL: databaseURLValue,
|
||||||
|
AdminDatabaseURL: adminURLValue,
|
||||||
|
EnvFile: envFileValue,
|
||||||
|
Force: *force,
|
||||||
|
Yes: *yes,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
Out: os.Stdout,
|
||||||
|
In: os.Stdin,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||||
|
log.Info("setup started", "db", setup.SanitizeDatabaseURL(databaseURLValue), "env_file", envFileValue)
|
||||||
|
|
||||||
|
summary, err := setup.Run(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("setup failed", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stdout, summary)
|
||||||
|
log.Info("setup complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Package setup implements the `coordinator setup` wizard: database reachability
|
||||||
|
// and creation, embedded schema migration, secret generation, and .env writing.
|
||||||
|
// The wizard never logs or echoes secrets.
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures one wizard run.
|
||||||
|
type Options struct {
|
||||||
|
// DatabaseURL is the target coordinator database (pgx/libpq URL).
|
||||||
|
DatabaseURL string
|
||||||
|
// AdminDatabaseURL, when set, is used to create a missing target database.
|
||||||
|
// Defaults to the target URL with the database name replaced by "postgres".
|
||||||
|
AdminDatabaseURL string
|
||||||
|
// EnvFile is where the generated settings are written (default ".env").
|
||||||
|
EnvFile string
|
||||||
|
// Force overwrites an existing EnvFile.
|
||||||
|
Force bool
|
||||||
|
// Yes disables interactive prompts; missing values fail instead.
|
||||||
|
Yes bool
|
||||||
|
// ConnectTimeout bounds the reachability check.
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
// Out receives progress and summary output; In feeds interactive answers.
|
||||||
|
Out io.Writer
|
||||||
|
In io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the wizard and returns a summary of what was done.
|
||||||
|
func Run(ctx context.Context, options Options) (string, error) {
|
||||||
|
if options.DatabaseURL == "" {
|
||||||
|
return "", fmt.Errorf("DATABASE_URL is required (or pass --db)")
|
||||||
|
}
|
||||||
|
if options.EnvFile == "" {
|
||||||
|
options.EnvFile = ".env"
|
||||||
|
}
|
||||||
|
if options.ConnectTimeout <= 0 {
|
||||||
|
options.ConnectTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if options.Out == nil {
|
||||||
|
options.Out = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
report := func(format string, args ...any) {
|
||||||
|
fmt.Fprintf(options.Out, format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
report("SciMesh coordinator setup")
|
||||||
|
report("")
|
||||||
|
|
||||||
|
// 1. Reachability, with optional database creation.
|
||||||
|
target, err := pgx.ParseConfig(options.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("DATABASE_URL is not a valid postgres URL: %w", err)
|
||||||
|
}
|
||||||
|
if err := probeDatabase(ctx, target, options.ConnectTimeout); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if !errors.As(err, &pgErr) || pgErr.Code != "3D000" {
|
||||||
|
return "", fmt.Errorf("cannot reach the coordinator database: %w", err)
|
||||||
|
}
|
||||||
|
report("database %q does not exist yet", target.Database)
|
||||||
|
admin, err := resolveAdminConfig(options, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := createDatabase(ctx, admin, target.Database, options.ConnectTimeout); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot create database %q: %w", target.Database, err)
|
||||||
|
}
|
||||||
|
report("created database %q", target.Database)
|
||||||
|
}
|
||||||
|
report("database %q is reachable", target.Database)
|
||||||
|
|
||||||
|
// 2. Apply the embedded schema migrations (idempotent).
|
||||||
|
if err := postgres.Migrate(ctx, options.DatabaseURL, nil); err != nil {
|
||||||
|
return "", fmt.Errorf("apply schema migrations: %w", err)
|
||||||
|
}
|
||||||
|
report("schema migrations applied")
|
||||||
|
|
||||||
|
// 3. JWT secret: reuse the environment value when strong, else generate.
|
||||||
|
secret := os.Getenv("JWT_SECRET")
|
||||||
|
if secret != "" && len(secret) < 32 {
|
||||||
|
return "", fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
generated, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("generate JWT_SECRET: %w", err)
|
||||||
|
}
|
||||||
|
secret = generated
|
||||||
|
report("generated a fresh JWT_SECRET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write the .env file.
|
||||||
|
storageDir := os.Getenv("COORDINATOR_STORAGE_DIR")
|
||||||
|
if storageDir == "" {
|
||||||
|
storageDir = "./data"
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(options, secret, storageDir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Summary.
|
||||||
|
var summary strings.Builder
|
||||||
|
fmt.Fprintf(&summary, "Setup complete.\n\n")
|
||||||
|
fmt.Fprintf(&summary, "Ready:\n")
|
||||||
|
fmt.Fprintf(&summary, " - database %s is reachable and migrated\n", target.Database)
|
||||||
|
fmt.Fprintf(&summary, " - settings written to %s (chmod 0600)\n", options.EnvFile)
|
||||||
|
fmt.Fprintf(&summary, "\nStart the coordinator:\n")
|
||||||
|
fmt.Fprintf(&summary, " ENV_FILE=%s ./coordinator\n", options.EnvFile)
|
||||||
|
fmt.Fprintf(&summary, "\nOptional — userservice for UI logins (must share JWT_SECRET):\n")
|
||||||
|
fmt.Fprintf(&summary, " cd users && JWT_SECRET=%q docker compose up -d\n", secret)
|
||||||
|
fmt.Fprintf(&summary, " then set USERSERVICE_URL=http://localhost:8081 and BOOTSTRAP_ADMIN_EMAIL/PASSWORD\n")
|
||||||
|
fmt.Fprintf(&summary, "\nThe wizard cannot run PostgreSQL or the userservice for you; the\n")
|
||||||
|
fmt.Fprintf(&summary, "commands above are the supported way to start them.\n")
|
||||||
|
return summary.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeDatabase verifies the target database accepts connections.
|
||||||
|
func probeDatabase(ctx context.Context, config *pgx.ConnConfig, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Close(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdminConfig picks the maintenance connection used to create
|
||||||
|
// databases. pgx's ConnConfig.ConnString() caches the original URL, so the
|
||||||
|
// config itself (not a re-rendered string) is what the caller connects with.
|
||||||
|
func resolveAdminConfig(options Options, target *pgx.ConnConfig) (*pgx.ConnConfig, error) {
|
||||||
|
if options.AdminDatabaseURL != "" {
|
||||||
|
config, err := pgx.ParseConfig(options.AdminDatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("--admin-db is not a valid postgres URL: %w", err)
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
admin := *target
|
||||||
|
admin.Database = "postgres"
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDatabase creates the named database through the maintenance connection.
|
||||||
|
func createDatabase(ctx context.Context, admin *pgx.ConnConfig, name string, timeout time.Duration) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
if _, err := conn.Exec(ctx, "CREATE DATABASE "+quoted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSecret returns 32 random bytes as lowercase hex.
|
||||||
|
func generateSecret() (string, error) {
|
||||||
|
buffer := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEnvFile writes the settings, refusing to clobber without --force.
|
||||||
|
func writeEnvFile(options Options, secret, storageDir string) error {
|
||||||
|
path := filepath.Clean(options.EnvFile)
|
||||||
|
if _, err := os.Stat(path); err == nil && !options.Force {
|
||||||
|
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
|
||||||
|
}
|
||||||
|
content := strings.Join([]string{
|
||||||
|
"DATABASE_URL=" + options.DatabaseURL,
|
||||||
|
"JWT_SECRET=" + secret,
|
||||||
|
"COORDINATOR_STORAGE_DIR=" + storageDir,
|
||||||
|
"", // trailing newline
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("chmod %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeDatabaseURL hides the password for logging.
|
||||||
|
func SanitizeDatabaseURL(raw string) string {
|
||||||
|
at := strings.LastIndex(raw, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
start := 0
|
||||||
|
if strings.HasPrefix(raw, "postgres://") || strings.HasPrefix(raw, "postgresql://") {
|
||||||
|
start = len("postgres://")
|
||||||
|
}
|
||||||
|
colon := strings.Index(raw[start:at], ":")
|
||||||
|
if colon < 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
colon += start
|
||||||
|
return raw[:colon] + ":***@" + raw[at+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// prompt asks a question and returns the trimmed answer ("" on EOF).
|
||||||
|
func prompt(options Options, question, fallback string) string {
|
||||||
|
fmt.Fprintf(options.Out, "%s [%s]: ", question, fallback)
|
||||||
|
reader := bufio.NewReader(options.In)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
answer := strings.TrimSpace(line)
|
||||||
|
if answer == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return answer
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunProvisionsDatabaseSchemaAndEnvFile(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
base := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
// The wizard must create a *missing* database through the admin URL.
|
||||||
|
slash := strings.LastIndex(base, "/")
|
||||||
|
target := base[:slash+1] + "scimesh_setup_test"
|
||||||
|
admin := base[:slash+1] + "postgres"
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
conn, err := pgx.Connect(ctx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
_, _ = conn.Exec(ctx, `DROP DATABASE IF EXISTS "scimesh_setup_test"`)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
|
||||||
|
envPath := filepath.Join(t.TempDir(), ".env")
|
||||||
|
var output strings.Builder
|
||||||
|
options := Options{
|
||||||
|
DatabaseURL: target,
|
||||||
|
AdminDatabaseURL: admin,
|
||||||
|
EnvFile: envPath,
|
||||||
|
Force: true,
|
||||||
|
Yes: true,
|
||||||
|
ConnectTimeout: 10 * time.Second,
|
||||||
|
Out: &output,
|
||||||
|
In: strings.NewReader(""),
|
||||||
|
}
|
||||||
|
summary, err := Run(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setup run: %v", err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"created database", "schema migrations applied"} {
|
||||||
|
if !strings.Contains(output.String(), expected) {
|
||||||
|
t.Errorf("progress output is missing %q:\n%s", expected, output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"Setup complete", "Start the coordinator"} {
|
||||||
|
if !strings.Contains(summary, expected) {
|
||||||
|
t.Errorf("summary is missing %q:\n%s", expected, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database now exists, is migrated, and the env file is written.
|
||||||
|
conn, err := pgx.Connect(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to provisioned database: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close(ctx) }()
|
||||||
|
var count int
|
||||||
|
if err := conn.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||||
|
t.Fatalf("read schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
if count < 13 {
|
||||||
|
t.Errorf("schema_migrations has %d rows, want >= 13", count)
|
||||||
|
}
|
||||||
|
envContent, err := os.ReadFile(envPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := string(envContent)
|
||||||
|
if !strings.Contains(env, "DATABASE_URL="+target) || !strings.Contains(env, "JWT_SECRET=") {
|
||||||
|
t.Errorf("env file is incomplete:\n%s", env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second run is idempotent: no create-database error, same outcome.
|
||||||
|
if _, err := Run(ctx, options); err != nil {
|
||||||
|
t.Fatalf("second setup run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateSecretIsRandomAndStrong(t *testing.T) {
|
||||||
|
first, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := generateSecret()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first) != 64 || len(second) != 64 {
|
||||||
|
t.Fatalf("secrets must be 32 random bytes as hex, got %d and %d", len(first), len(second))
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatal("two generated secrets must differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvFileContentsAndPermissions(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeEnvFile(Options{
|
||||||
|
EnvFile: path,
|
||||||
|
DatabaseURL: "postgres://scimesh@localhost/scimesh",
|
||||||
|
}, "s3cr3t", "./data"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "DATABASE_URL=postgres://scimesh@localhost/scimesh\nJWT_SECRET=s3cr3t\nCOORDINATOR_STORAGE_DIR=./data\n"
|
||||||
|
if got := string(content); got != want {
|
||||||
|
t.Fatalf("env file = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("env file mode = %o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteEnvFileRefusesWithoutForce(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "a", "./data"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err == nil {
|
||||||
|
t.Fatal("second write without --force must fail")
|
||||||
|
}
|
||||||
|
if err := writeEnvFile(Options{EnvFile: path, Force: true, DatabaseURL: "postgres://x@localhost/a"}, "b", "./data"); err != nil {
|
||||||
|
t.Fatalf("write with --force: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptReadsAnswer(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
answer := prompt(Options{Out: &out, In: strings.NewReader("postgres://custom\n")}, "Database URL", "default")
|
||||||
|
if answer != "postgres://custom" {
|
||||||
|
t.Fatalf("answer = %q, want the typed value", answer)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "Database URL [default]:") {
|
||||||
|
t.Fatalf("prompt output = %q", out.String())
|
||||||
|
}
|
||||||
|
fallback := prompt(Options{Out: &out, In: strings.NewReader("\n")}, "Question", "fb")
|
||||||
|
if fallback != "fb" {
|
||||||
|
t.Fatalf("empty answer must fall back, got %q", fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeDatabaseURL(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"postgres://scimesh:hunter2@localhost:5432/scimesh?sslmode=disable": "postgres://scimesh:***@localhost:5432/scimesh?sslmode=disable",
|
||||||
|
"postgresql://scimesh@localhost/scimesh": "postgresql://scimesh@localhost/scimesh",
|
||||||
|
"not-a-url": "not-a-url",
|
||||||
|
}
|
||||||
|
for raw, want := range cases {
|
||||||
|
got := SanitizeDatabaseURL(raw)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("sanitize(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "hunter2") {
|
||||||
|
t.Errorf("sanitize(%q) leaked the password", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-2
@@ -86,8 +86,11 @@ chmod +x coordinator
|
|||||||
- **coordinator** needs PostgreSQL running (`DATABASE_URL`,
|
- **coordinator** needs PostgreSQL running (`DATABASE_URL`,
|
||||||
`COORDINATOR_STORAGE_DIR`, `JWT_SECRET`); the binary applies its embedded
|
`COORDINATOR_STORAGE_DIR`, `JWT_SECRET`); the binary applies its embedded
|
||||||
schema migrations itself on startup (`AUTO_MIGRATE=false` opts out), so no
|
schema migrations itself on startup (`AUTO_MIGRATE=false` opts out), so no
|
||||||
separate migration step is needed. The UI login additionally requires
|
separate migration step is needed. The interactive wizard provisions the
|
||||||
`USERSERVICE_URL`.
|
rest: it checks the database, creates it when missing, generates a
|
||||||
|
`JWT_SECRET`, and writes a `.env` file (`coordinator setup --help`, or
|
||||||
|
`make setup`; `--yes` for non-interactive runs). The UI login additionally
|
||||||
|
requires `USERSERVICE_URL`.
|
||||||
`coordinator --version` / `worker-agent --version` print the build tag.
|
`coordinator --version` / `worker-agent --version` print the build tag.
|
||||||
|
|
||||||
Build and serve this documentation site:
|
Build and serve this documentation site:
|
||||||
|
|||||||
Reference in New Issue
Block a user