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:
156
cmd/edgeguard-ctl/initdb.go
Normal file
156
cmd/edgeguard-ctl/initdb.go
Normal 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...)
|
||||||
|
}
|
||||||
@@ -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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,14 +17,15 @@ Usage:
|
|||||||
edgeguard-ctl <command> [args]
|
edgeguard-ctl <command> [args]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
version Print version
|
version Print version and exit
|
||||||
initdb Initialise PostgreSQL database and user (idempotent)
|
|
||||||
migrate up Apply pending migrations
|
migrate up Apply pending migrations
|
||||||
migrate down Roll back last migration (dev only)
|
migrate down Roll back the most recent migration (dev only)
|
||||||
cluster-join Join an existing cluster (--from URL --token TOKEN)
|
migrate check Validate embedded migrations (no DB connect)
|
||||||
cluster-leave Leave the cluster cleanly
|
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||||
promote Promote this node's PG to primary
|
initdb Create PostgreSQL role + database (idempotent)
|
||||||
dump-config Print effective config to stdout
|
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() {
|
func main() {
|
||||||
@@ -29,12 +34,20 @@ func main() {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "version":
|
|
||||||
fmt.Println(version)
|
|
||||||
case "-h", "--help", "help":
|
case "-h", "--help", "help":
|
||||||
fmt.Print(usage)
|
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:
|
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)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
cmd/edgeguard-ctl/migrate.go
Normal file
63
cmd/edgeguard-ctl/migrate.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# postinst for edgeguard-api — creates system user, filesystem layout,
|
# postinst for edgeguard-api — creates system user, filesystem layout,
|
||||||
# enables systemd units. DB init + migrations run lazily on first start
|
# initialises PostgreSQL (role + db + migrations), enables systemd
|
||||||
# of edgeguard-api.
|
# units. Each step idempotent; safe to re-run on every upgrade.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export LC_ALL=C
|
export LC_ALL=C
|
||||||
@@ -28,6 +28,29 @@ case "$1" in
|
|||||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
||||||
done
|
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 ──────────────────────────────────────────────────
|
# ── systemd ──────────────────────────────────────────────────
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now edgeguard-api.service edgeguard-scheduler.service || true
|
systemctl enable --now edgeguard-api.service edgeguard-scheduler.service || true
|
||||||
|
|||||||
Reference in New Issue
Block a user