diff --git a/PLAN.md b/PLAN.md index 630511c..d438255 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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). diff --git a/coordinator/Makefile b/coordinator/Makefile index a652979..38b728f 100644 --- a/coordinator/Makefile +++ b/coordinator/Makefile @@ -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).' diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 45cc4fb..dbfc41b 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -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 { diff --git a/coordinator/cmd/coordinator/setup_cmd.go b/coordinator/cmd/coordinator/setup_cmd.go new file mode 100644 index 0000000..d67e117 --- /dev/null +++ b/coordinator/cmd/coordinator/setup_cmd.go @@ -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 +} + diff --git a/coordinator/internal/setup/setup.go b/coordinator/internal/setup/setup.go new file mode 100644 index 0000000..b7bef55 --- /dev/null +++ b/coordinator/internal/setup/setup.go @@ -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 +} diff --git a/coordinator/internal/setup/setup_integration_test.go b/coordinator/internal/setup/setup_integration_test.go new file mode 100644 index 0000000..9ff0064 --- /dev/null +++ b/coordinator/internal/setup/setup_integration_test.go @@ -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) + } +} diff --git a/coordinator/internal/setup/setup_test.go b/coordinator/internal/setup/setup_test.go new file mode 100644 index 0000000..b62475e --- /dev/null +++ b/coordinator/internal/setup/setup_test.go @@ -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) + } + } +} diff --git a/mkdocs/index.md b/mkdocs/index.md index bca66b6..8522faa 100644 --- a/mkdocs/index.md +++ b/mkdocs/index.md @@ -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: