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).
|
||||
|
||||
**Depends on:** the Go coordinator and the release build pipeline. Step 1
|
||||
(embedded migrations, `AUTO_MIGRATE`) is implemented; steps 2–3 below are
|
||||
the remaining work.
|
||||
(embedded migrations, `AUTO_MIGRATE`) and step 2 (the `coordinator setup`
|
||||
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:**
|
||||
|
||||
- the binary embeds the migrations and applies pending ones on startup by
|
||||
default (`AUTO_MIGRATE=false` opts out for managed databases); applying is
|
||||
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
|
||||
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;
|
||||
- the wizard explains what it cannot do itself: running PostgreSQL and the
|
||||
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 job priority and fair scheduling.
|
||||
- Add a CLI for submitting and monitoring remote jobs.
|
||||
- Implement CTX-17 steps 2–3: the interactive `coordinator setup` wizard
|
||||
(database creation, `.env` generation, admin seeding) and, later, a fully
|
||||
embedded userservice (`coordinator userservice` subcommand) so one binary
|
||||
can serve the whole platform without containers.
|
||||
- Implement CTX-17 step 3: a fully embedded userservice
|
||||
(`coordinator userservice` subcommand) so one binary can serve the whole
|
||||
platform without containers.
|
||||
- Replace PostgreSQL with an embedded SQLite backend for fully self-contained
|
||||
single-binary deployments (large storage-layer change; postgres row locks,
|
||||
transactions, and integration tests must be re-derived).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.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
|
||||
# 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' \
|
||||
' (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:
|
||||
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-reset Stop the demo and wipe its data volumes.' \
|
||||
' make workloads-export Regenerate the embedded UI workload catalog.' \
|
||||
' make setup Interactive one-shot provisioning wizard.' \
|
||||
' make test / make vet Run Go verification.' \
|
||||
'' \
|
||||
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
|
||||
|
||||
@@ -24,6 +24,13 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
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")
|
||||
flag.Parse()
|
||||
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_STORAGE_DIR`, `JWT_SECRET`); the binary applies its embedded
|
||||
schema migrations itself on startup (`AUTO_MIGRATE=false` opts out), so no
|
||||
separate migration step is needed. The UI login additionally requires
|
||||
`USERSERVICE_URL`.
|
||||
separate migration step is needed. The interactive wizard provisions the
|
||||
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.
|
||||
|
||||
Build and serve this documentation site:
|
||||
|
||||
Reference in New Issue
Block a user