Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
var (
|
|
// Legacy globals kept for K8s path (globalSSH/globalPod*) and for the
|
|
// shared DB pool (globalDB). New code should use globalExecutor/globalControl.
|
|
globalSSH *ssh.Client
|
|
globalDB *pgxpool.Pool
|
|
globalPodIP string
|
|
globalPodNS string
|
|
globalPod string
|
|
|
|
globalExecutor Executor
|
|
globalControl ControlPlane
|
|
)
|
|
|
|
// resolveControl returns the effective control plane name based on config,
|
|
// defaulting to "kubectl" when SSH is configured and "local" otherwise.
|
|
func resolveControl() string {
|
|
if controlPlane != "" {
|
|
return controlPlane
|
|
}
|
|
if sshHost != "" {
|
|
return "kubectl"
|
|
}
|
|
return "local"
|
|
}
|
|
|
|
// connectAll creates the executor, control plane, and DB connection, then sets
|
|
// all globals. Called from main() and handleReconnect.
|
|
func connectAll() error {
|
|
ctrl := resolveControl()
|
|
|
|
// Start from the full loaded config so provider-specific fields
|
|
// (docker_*, cmd_*) that have no flag/env equivalent are preserved.
|
|
cfg := loadedConfig
|
|
cfg.SSHHost = sshHost
|
|
cfg.SSHUser = sshUser
|
|
cfg.SSHKey = resolveKeyPath()
|
|
cfg.DBHost = dbHost
|
|
cfg.DBPort = dbPort
|
|
cfg.DBUser = dbUser
|
|
cfg.DBPass = dbPass
|
|
cfg.DBName = dbName
|
|
cfg.DBSchema = dbSchema
|
|
cfg.Control = ctrl
|
|
cfg.ControlNamespace = controlNS
|
|
cfg.BrokerGameAddr = brokerGameAddr
|
|
cfg.BrokerAdminAddr = brokerAdminAddr
|
|
cfg.BrokerTLS = brokerTLS
|
|
cfg.BackupDir = backupDir
|
|
cfg.ServerIniDir = serverIniDir
|
|
|
|
exec, err := newExecutor(cfg.SSHHost, cfg.SSHUser, cfg.SSHKey)
|
|
if err != nil {
|
|
return fmt.Errorf("executor: %w", err)
|
|
}
|
|
// AMP mode wraps the executor to elevate WriteFile through sudo.
|
|
// Applies regardless of whether the inner executor is local or SSH.
|
|
if ctrl == "amp" {
|
|
user := cfg.AmpUser
|
|
if user == "" {
|
|
user = "amp"
|
|
}
|
|
exec = &Executor{Executor: exec, ampUser: user}
|
|
}
|
|
globalExecutor = exec
|
|
|
|
// kubectl needs DB-pod discovery (via the executor, not the DB) to learn the
|
|
// namespace before the control plane and DB connect. A discovery failure is
|
|
// fatal — without a namespace there is nothing to drive the control plane.
|
|
if ctrl == "kubectl" {
|
|
ns, pod, podIP, err := discoverDBPod(exec)
|
|
if err != nil {
|
|
exec.Close()
|
|
globalExecutor = nil
|
|
return fmt.Errorf("DB pod discovery: %w", err)
|
|
}
|
|
globalPodNS = ns
|
|
globalPod = pod
|
|
globalPodIP = podIP
|
|
// Propagate discovered namespace so kubectlControl can use it.
|
|
if cfg.ControlNamespace == "" {
|
|
cfg.ControlNamespace = ns
|
|
controlNS = ns
|
|
}
|
|
if s, ok := exec.(*sshExecutor); ok {
|
|
globalSSH = s.client
|
|
}
|
|
}
|
|
|
|
// The control plane (logs, battlegroup, server control) does not depend on
|
|
// the database. Establish it before connecting the DB so a DB outage never
|
|
// disables it — the DB can be re-established later via /api/v1/reconnect
|
|
// without losing control-plane functionality.
|
|
globalControl = newControlPlane(ctrl, cfg)
|
|
|
|
// DB connect is best-effort: on failure keep the executor + control plane
|
|
// intact and return the error so the caller can surface it (main starts the
|
|
// server anyway; the systemd watchdog or a manual reconnect retries the DB).
|
|
var pool *pgxpool.Pool
|
|
if ctrl == "kubectl" {
|
|
pool, err = connectDB(context.Background(), cfg.DBUser, cfg.DBPass)
|
|
} else {
|
|
pool, err = connectDBDirect(context.Background(), cfg)
|
|
}
|
|
if err != nil {
|
|
globalDB = nil
|
|
return fmt.Errorf("DB connect: %w", err)
|
|
}
|
|
globalDB = pool
|
|
|
|
// Best-effort: ensure the GM/Server chat persona exists for admin messaging
|
|
// (whisper, map announce). Idempotent (ON CONFLICT DO NOTHING); a failure here
|
|
// must never block startup or reconnect, so it is logged and swallowed.
|
|
if err := cmdEnsureGMIdentity(context.Background()); err != nil {
|
|
log.Printf("connectAll: ensure GM identity: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cmdConnect wraps connectAll in the legacy Msg return type.
|
|
func cmdConnect() Msg {
|
|
if err := connectAll(); err != nil {
|
|
return msgConnect{err: err}
|
|
}
|
|
return msgConnect{}
|
|
}
|
|
|
|
// connectDBDirect opens a pgxpool without SSH tunnelling, routing TCP through
|
|
// the executor's Dial (which is net.Dial for local, SSH tunnel for SSH).
|
|
func connectDBDirect(ctx context.Context, cfg appConfig) (*pgxpool.Pool, error) {
|
|
connStr := fmt.Sprintf(
|
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
|
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPass, cfg.DBName)
|
|
poolCfg, err := pgxpool.ParseConfig(connStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
poolCfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
|
_, err := conn.Exec(ctx, fmt.Sprintf(`SET search_path TO %s, public`, pgx.Identifier{cfg.DBSchema}.Sanitize()))
|
|
return err
|
|
}
|
|
if globalExecutor != nil {
|
|
addr := fmt.Sprintf("%s:%d", cfg.DBHost, cfg.DBPort)
|
|
poolCfg.ConnConfig.DialFunc = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return globalExecutor.Dial("tcp", addr)
|
|
}
|
|
}
|
|
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, err
|
|
}
|
|
dbUser = cfg.DBUser
|
|
dbPass = cfg.DBPass
|
|
return pool, nil
|
|
}
|
|
|
|
func connectDB(ctx context.Context, user, pass string) (*pgxpool.Pool, error) {
|
|
connStr := fmt.Sprintf(
|
|
"host=127.0.0.1 port=%d user=%s password=%s dbname=%s sslmode=disable",
|
|
dbPort, user, pass, dbName)
|
|
poolCfg, err := pgxpool.ParseConfig(connStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
poolCfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
|
_, err := conn.Exec(ctx, fmt.Sprintf(`SET search_path TO %s, public`, pgx.Identifier{dbSchema}.Sanitize()))
|
|
return err
|
|
}
|
|
poolCfg.ConnConfig.LookupFunc = func(_ context.Context, _ string) ([]string, error) {
|
|
return []string{globalPodIP}, nil
|
|
}
|
|
poolCfg.ConnConfig.DialFunc = func(_ context.Context, _, _ string) (net.Conn, error) {
|
|
return globalSSH.Dial("tcp", fmt.Sprintf("%s:%d", globalPodIP, dbPort))
|
|
}
|
|
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
return nil, err
|
|
}
|
|
dbUser = user
|
|
dbPass = pass
|
|
return pool, nil
|
|
}
|