feat(ctl): edgeguard-ctl migrate + initdb wired into postinst

migrate up|down|check|dump (1:1 nmg-ctl-Pattern, ruft internal/database
Migrate/MigrateDown/ValidateMigrations/CopyEmbeddedMigrationsTo).
initdb prüft pg_roles/pg_database und legt Role + DB idempotent via
sudo -u postgres psql an, mit Identifier-Whitelist gegen Injection.
postinst wirt die drei Schritte vor systemd-enable: migrate check
(Pre-Flight ohne DB), initdb, migrate up (als edgeguard-User via
Socket-Peer-Auth). cluster-join/promote/dump-config bleiben explizit
Phase-3-Stubs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-09 08:18:55 +02:00
parent b307a7b1f7
commit 106ef95f6d
4 changed files with 268 additions and 13 deletions

156
cmd/edgeguard-ctl/initdb.go Normal file
View File

@@ -0,0 +1,156 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// cmdInitDB creates the PostgreSQL role and database for edgeguard if
// they don't already exist. Idempotent — safe to re-run from postinst
// on every package upgrade.
//
// Authentication: the local postgres super-user via unix-socket peer
// auth. edgeguard-ctl initdb is invoked from postinst (running as
// root), so it shells out via `sudo -u postgres psql -tA -c ...`.
//
// The created role has LOGIN but no password — production access is
// peer-auth from the system user `edgeguard` over the unix socket
// (architecture.md §6).
//
// Flags:
//
// --db <name> database name (default: edgeguard)
// --role <name> role name (default: edgeguard)
func cmdInitDB(args []string) int {
dbName := "edgeguard"
roleName := "edgeguard"
for i := 0; i < len(args); i++ {
switch args[i] {
case "--db":
if i+1 < len(args) {
dbName = args[i+1]
i++
}
case "--role":
if i+1 < len(args) {
roleName = args[i+1]
i++
}
case "-h", "--help":
fmt.Println("Usage: edgeguard-ctl initdb [--db NAME] [--role NAME]")
return 0
}
}
if !looksLikeIdentifier(dbName) || !looksLikeIdentifier(roleName) {
fmt.Fprintln(os.Stderr, "initdb: --db and --role must match [a-z_][a-z0-9_]{0,62}")
return 2
}
roleExists, err := psqlBoolQuery(fmt.Sprintf(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '%s')", roleName))
if err != nil {
fmt.Fprintln(os.Stderr, "initdb: check role:", err)
return 1
}
if roleExists {
fmt.Printf("role %q already exists — leaving it\n", roleName)
} else {
if err := psqlExec(fmt.Sprintf("CREATE ROLE %s LOGIN", roleName)); err != nil {
fmt.Fprintln(os.Stderr, "initdb: create role:", err)
return 1
}
fmt.Printf("created role %q\n", roleName)
}
dbExists, err := psqlBoolQuery(fmt.Sprintf(
"SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = '%s')", dbName))
if err != nil {
fmt.Fprintln(os.Stderr, "initdb: check database:", err)
return 1
}
if dbExists {
fmt.Printf("database %q already exists — leaving it\n", dbName)
} else {
if err := psqlExec(fmt.Sprintf("CREATE DATABASE %s OWNER %s", dbName, roleName)); err != nil {
fmt.Fprintln(os.Stderr, "initdb: create database:", err)
return 1
}
fmt.Printf("created database %q owned by %q\n", dbName, roleName)
}
return 0
}
// looksLikeIdentifier guards against shell- and SQL-injection in the
// role/db flags. Only allow the standard PG identifier shape; we
// interpolate into psql -c so a quoted user-supplied string is
// hostile.
func looksLikeIdentifier(s string) bool {
if s == "" || len(s) > 63 {
return false
}
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) {
return false
}
for _, r := range s[1:] {
ok := r == '_' ||
(r >= 'a' && r <= 'z') ||
(r >= '0' && r <= '9')
if !ok {
return false
}
}
return true
}
// psqlBoolQuery runs a one-shot SELECT EXISTS query as the postgres
// super-user and returns true/false. Tuples-only + unaligned mode
// (-tA) so we get just the literal "t" or "f".
func psqlBoolQuery(sql string) (bool, error) {
out, err := psqlRun([]string{"-tA", "-c", sql})
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) == "t", nil
}
// psqlExec runs a CREATE ROLE / CREATE DATABASE statement as the
// postgres super-user. ON_ERROR_STOP makes psql return non-zero on
// any SQL error.
func psqlExec(sql string) error {
if _, err := psqlRun([]string{"-v", "ON_ERROR_STOP=1", "-c", sql}); err != nil {
return err
}
return nil
}
// psqlRun shells out to `sudo -u postgres psql` with the given args.
// When edgeguard-ctl initdb is already running as the postgres user
// (e.g. an admin re-running it manually), skip sudo so it works
// without sudoers configuration.
func psqlRun(args []string) ([]byte, error) {
cmd := buildPsqlCmd(args)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("%w: %s", err, msg)
}
return nil, err
}
return out, nil
}
func buildPsqlCmd(args []string) *exec.Cmd {
if u := os.Getenv("USER"); u == "postgres" {
return exec.Command("psql", args...)
}
full := append([]string{"-n", "-u", "postgres", "psql"}, args...)
return exec.Command("sudo", full...)
}

View File

@@ -1,3 +1,7 @@
// Command edgeguard-ctl is the admin CLI for setup, migrations and
// (later) cluster ops. v1 wires migrate + initdb so postinst can
// initialise a fresh node; cluster-* and promote remain stubs until
// Phase 3.
package main
import (
@@ -13,14 +17,15 @@ Usage:
edgeguard-ctl <command> [args]
Commands:
version Print version
initdb Initialise PostgreSQL database and user (idempotent)
version Print version and exit
migrate up Apply pending migrations
migrate down Roll back last migration (dev only)
cluster-join Join an existing cluster (--from URL --token TOKEN)
cluster-leave Leave the cluster cleanly
promote Promote this node's PG to primary
dump-config Print effective config to stdout
migrate down Roll back the most recent migration (dev only)
migrate check Validate embedded migrations (no DB connect)
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
initdb Create PostgreSQL role + database (idempotent)
cluster-join Join an existing cluster (Phase 3, not yet implemented)
promote Promote this node's PG to primary (Phase 3, not yet implemented)
dump-config Print effective config (Phase 3, not yet implemented)
`
func main() {
@@ -29,12 +34,20 @@ func main() {
os.Exit(2)
}
switch os.Args[1] {
case "version":
fmt.Println(version)
case "-h", "--help", "help":
fmt.Print(usage)
case "version", "--version":
fmt.Println(version)
case "migrate":
os.Exit(cmdMigrate(os.Args[2:]))
case "initdb":
os.Exit(cmdInitDB(os.Args[2:]))
case "cluster-join", "cluster-leave", "promote", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1)
default:
fmt.Fprintf(os.Stderr, "edgeguard-ctl: command %q not yet implemented\n", os.Args[1])
fmt.Fprintf(os.Stderr, "edgeguard-ctl: unknown command %q\n", os.Args[1])
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
}

View File

@@ -0,0 +1,63 @@
package main
import (
"context"
"fmt"
"os"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// cmdMigrate fans out to the subcommand handlers. `check` is offline
// (filename validation against the embedded FS) and is the safe
// pre-flight call from postinst — it catches duplicate version
// prefixes before the DB ever gets touched.
func cmdMigrate(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Usage: edgeguard-ctl migrate up|down|check|dump [dir]")
return 2
}
if args[0] == "check" {
if err := database.ValidateMigrations(); err != nil {
fmt.Fprintln(os.Stderr, "migrate check:", err)
return 1
}
fmt.Println("embedded migrations OK")
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
switch args[0] {
case "up":
if err := database.Migrate(ctx, ""); err != nil {
fmt.Fprintln(os.Stderr, "migrate up:", err)
return 1
}
fmt.Println("migrations applied")
return 0
case "down":
if err := database.MigrateDown(ctx, ""); err != nil {
fmt.Fprintln(os.Stderr, "migrate down:", err)
return 1
}
fmt.Println("one migration rolled back")
return 0
case "dump":
dst := "./migrations"
if len(args) >= 2 {
dst = args[1]
}
if err := database.CopyEmbeddedMigrationsTo(dst); err != nil {
fmt.Fprintln(os.Stderr, "migrate dump:", err)
return 1
}
fmt.Println("embedded migrations written to", dst)
return 0
default:
fmt.Fprintln(os.Stderr, "Usage: edgeguard-ctl migrate up|down|check|dump [dir]")
return 2
}
}

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# postinst for edgeguard-api — creates system user, filesystem layout,
# enables systemd units. DB init + migrations run lazily on first start
# of edgeguard-api.
# initialises PostgreSQL (role + db + migrations), enables systemd
# units. Each step idempotent; safe to re-run on every upgrade.
set -e
export LC_ALL=C
@@ -28,6 +28,29 @@ case "$1" in
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
done
# ── Pre-flight: validate embedded migration set ──────────────
# Catches duplicate version prefixes BEFORE we touch the DB,
# so a broken upgrade can't half-apply migrations and leave
# the cluster wedged (mail-gateway 2026-05-08 incident).
if ! /usr/bin/edgeguard-ctl migrate check; then
echo "postinst: embedded migrations failed validation — aborting" >&2
exit 1
fi
# ── PostgreSQL: ensure role + database exist ─────────────────
# Requires postgresql-16 (or -17) running locally — guaranteed
# by Depends. Idempotent — re-runs on upgrade are no-ops.
if ! /usr/bin/edgeguard-ctl initdb; then
echo "postinst: edgeguard-ctl initdb failed — aborting" >&2
exit 1
fi
# ── Apply pending schema migrations ──────────────────────────
if ! sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl migrate up; then
echo "postinst: edgeguard-ctl migrate up failed — aborting" >&2
exit 1
fi
# ── systemd ──────────────────────────────────────────────────
systemctl daemon-reload
systemctl enable --now edgeguard-api.service edgeguard-scheduler.service || true