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)
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
version Print version and exit
migrate up Apply pending migrations
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
}
}