Add the coordinator setup wizard for self-provisioned databases
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user