docs(reference): import Dune: Awakening server-manager references
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>
This commit is contained in:
255
docs/reference-repos/icehunter/cmd/dune-admin/amp_api.go
Normal file
255
docs/reference-repos/icehunter/cmd/dune-admin/amp_api.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultAmpAPIPort is the AMP instance ADS Web API port, reachable from inside
|
||||
// the AMP container at http://127.0.0.1:8081/API/.
|
||||
const defaultAmpAPIPort = 8081
|
||||
|
||||
// ampAPIClient talks to a CubeCoders AMP instance's Web API. Under the AMP
|
||||
// control plane, gameplay/server settings are owned by AMP: it regenerates
|
||||
// UserEngine.ini / UserGame.ini from its own config (GenericModule.kvp →
|
||||
// App.AppSettings) on every start, so a direct INI edit gets clobbered. Writing
|
||||
// through the AMP API persists cleanly and survives restarts.
|
||||
//
|
||||
// Requests are issued by building a curl command, wrapping it for in-container
|
||||
// execution via wrap (ampControl.wrapInContainer), and running it through the
|
||||
// host Executor. The AMP ADS port is not exposed on the host, but the executor
|
||||
// already execs into the container for logs and rabbitmqctl, so the same path
|
||||
// reaches the loopback API with no extra port plumbing.
|
||||
type ampAPIClient struct {
|
||||
exec Executor
|
||||
wrap func(string) string // wraps an in-container shell command
|
||||
user string
|
||||
pass string
|
||||
port int
|
||||
sessionID string // cached after the first successful login
|
||||
}
|
||||
|
||||
func newAMPAPIClient(exec Executor, wrap func(string) string, user, pass string, port int) *ampAPIClient {
|
||||
return &APIClient{exec: exec, wrap: wrap, user: user, pass: pass, port: port}
|
||||
}
|
||||
|
||||
func (c *ampAPIClient) apiPort() int {
|
||||
if c.port == 0 {
|
||||
return defaultAmpAPIPort
|
||||
}
|
||||
return c.port
|
||||
}
|
||||
|
||||
func (c *ampAPIClient) endpoint(path string) string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d/API/%s", c.apiPort(), path)
|
||||
}
|
||||
|
||||
// buildCurl returns an in-container shell command that POSTs payload as JSON to
|
||||
// the named AMP API endpoint. The JSON body is base64-piped to curl so
|
||||
// operator-supplied values (passwords, server names) never touch the shell
|
||||
// command line — eliminating both quoting bugs and shell-injection risk.
|
||||
func (c *ampAPIClient) buildCurl(path string, payload any) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal %s payload: %w", path, err)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(body)
|
||||
return fmt.Sprintf(
|
||||
"echo %s | base64 -d | curl -s -m 20 -X POST "+
|
||||
"-H 'Content-Type: application/json' -H 'Accept: application/json' "+
|
||||
"--data-binary @- %s",
|
||||
b64, c.endpoint(path)), nil
|
||||
}
|
||||
|
||||
// post runs an AMP API call and returns the trimmed response body. Executor
|
||||
// failures are wrapped and surface curl's stderr for diagnosis.
|
||||
func (c *ampAPIClient) post(path string, payload any) (string, error) {
|
||||
cmd, err := c.buildCurl(path, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := c.exec.Exec(c.wrap(cmd))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("amp api %s: %w (output: %s)", path, err, strings.TrimSpace(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// login authenticates against Core/Login and caches the session ID. AMP returns
|
||||
// a LoginResult; success is gated on both the success flag and a non-empty
|
||||
// sessionID.
|
||||
func (c *ampAPIClient) login() (string, error) {
|
||||
resp, err := c.post("Core/Login", map[string]any{
|
||||
"username": c.user,
|
||||
"password": c.pass,
|
||||
"token": "",
|
||||
"rememberMe": false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
ResultReason string `json:"resultReason"`
|
||||
SessionID string `json:"sessionID"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractJSONObject(resp)), &result); err != nil {
|
||||
return "", fmt.Errorf("amp api Core/Login: decode response: %w (output: %s)", err, resp)
|
||||
}
|
||||
if !result.Success || result.SessionID == "" {
|
||||
reason := result.ResultReason
|
||||
if reason == "" {
|
||||
reason = "login failed"
|
||||
}
|
||||
return "", fmt.Errorf("amp api login rejected: %s", reason)
|
||||
}
|
||||
c.sessionID = result.SessionID
|
||||
return c.sessionID, nil
|
||||
}
|
||||
|
||||
// ensureSession returns the cached session ID, logging in on first use.
|
||||
func (c *ampAPIClient) ensureSession() (string, error) {
|
||||
if c.sessionID != "" {
|
||||
return c.sessionID, nil
|
||||
}
|
||||
return c.login()
|
||||
}
|
||||
|
||||
// isSessionError reports whether an AMP API error looks like a session
|
||||
// rejection (expired, invalid, or unknown session ID). Used to trigger a
|
||||
// one-shot re-login rather than surfacing a confusing auth error to the
|
||||
// operator — AMP sessions can expire if the server is idle for a long time.
|
||||
func isSessionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "session")
|
||||
}
|
||||
|
||||
// setConfig writes a single AMP config node (e.g.
|
||||
// "Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier").
|
||||
// AMP persists it to GenericModule.kvp and regenerates the game INIs on the
|
||||
// next start.
|
||||
//
|
||||
// If AMP rejects the call with a session error (expired or invalid session),
|
||||
// setConfig clears the cached session ID, re-logs in once, and retries the
|
||||
// write. This handles the case where the in-process session goes stale between
|
||||
// a successful login and a subsequent SetConfig within the same batch.
|
||||
func (c *ampAPIClient) setConfig(node, value string) error {
|
||||
sid, err := c.ensureSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.post("Core/SetConfig", map[string]any{
|
||||
"node": node,
|
||||
"value": value,
|
||||
"SESSIONID": sid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseActionResult(node, resp); err != nil {
|
||||
if !isSessionError(err) {
|
||||
return err
|
||||
}
|
||||
// Session expired — force re-login and retry once.
|
||||
c.sessionID = ""
|
||||
sid, err = c.login()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err = c.post("Core/SetConfig", map[string]any{
|
||||
"node": node,
|
||||
"value": value,
|
||||
"SESSIONID": sid,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseActionResult(node, resp)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getConfig reads a single AMP config node's current value.
|
||||
func (c *ampAPIClient) getConfig(node string) (string, error) {
|
||||
sid, err := c.ensureSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.post("Core/GetConfig", map[string]any{
|
||||
"node": node,
|
||||
"SESSIONID": sid,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result struct {
|
||||
CurrentValue json.RawMessage `json:"CurrentValue"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractJSONObject(resp)), &result); err != nil {
|
||||
return "", fmt.Errorf("amp api GetConfig %s: decode response: %w (output: %s)", node, err, resp)
|
||||
}
|
||||
return jsonScalarToString(result.CurrentValue), nil
|
||||
}
|
||||
|
||||
// parseActionResult interprets an AMP SetConfig response, which is either an
|
||||
// ActionResult object ({"Status":bool,"Reason":string}) or — on some AMP
|
||||
// versions — a bare JSON bool. A missing Status is treated as success (older
|
||||
// builds return {} when the write succeeds).
|
||||
func parseActionResult(node, resp string) error {
|
||||
trimmed := strings.TrimSpace(resp)
|
||||
switch trimmed {
|
||||
case "true":
|
||||
return nil
|
||||
case "false":
|
||||
return fmt.Errorf("amp api SetConfig %s: rejected", node)
|
||||
}
|
||||
var result struct {
|
||||
Status *bool `json:"Status"`
|
||||
Reason string `json:"Reason"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractJSONObject(trimmed)), &result); err != nil {
|
||||
return fmt.Errorf("amp api SetConfig %s: decode response: %w (output: %s)", node, err, trimmed)
|
||||
}
|
||||
if result.Status != nil && !*result.Status {
|
||||
reason := result.Reason
|
||||
if reason == "" {
|
||||
reason = "rejected"
|
||||
}
|
||||
return fmt.Errorf("amp api SetConfig %s: %s", node, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractJSONObject returns the substring spanning the first '{' to the last
|
||||
// '}', so a stray sudo banner or curl notice ahead of the JSON body doesn't
|
||||
// break decoding. Returns s unchanged when no object braces are present (the
|
||||
// caller's decode then fails with a clear error).
|
||||
func extractJSONObject(s string) string {
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end < start {
|
||||
return s
|
||||
}
|
||||
return s[start : end+1]
|
||||
}
|
||||
|
||||
// jsonScalarToString renders a JSON scalar (string/number/bool/null) as a plain
|
||||
// string: quoted strings are unquoted; numbers and bools are returned verbatim;
|
||||
// null/empty become "".
|
||||
func jsonScalarToString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if len(s) >= 2 && s[0] == '"' {
|
||||
var unquoted string
|
||||
if err := json.Unmarshal(raw, &unquoted); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewControlPlane_AMPWiresAPICredentials verifies the factory threads the
|
||||
// AMP Web API credentials from config into the ampControl so the settings-write
|
||||
// path can authenticate.
|
||||
func TestNewControlPlane_AMPWiresAPICredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
cp := newControlPlane("amp", appConfig{
|
||||
AmpInstance: "DuneTest01",
|
||||
AmpAPIUser: "admin",
|
||||
AmpAPIPass: "test123!",
|
||||
AmpAPIPort: 9090,
|
||||
})
|
||||
amp, ok := cp.(*ampControl)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ampControl, got %T", cp)
|
||||
}
|
||||
if amp.apiUser != "admin" || amp.apiPass != "test123!" || amp.apiPort != 9090 {
|
||||
t.Errorf("api creds = (%q,%q,%d), want (admin, test123!, 9090)", amp.apiUser, amp.apiPass, amp.apiPort)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskSecrets_MasksAmpAPIPass ensures the AMP API password is never exposed
|
||||
// through the /api/v1/config GET endpoint.
|
||||
func TestMaskSecrets_MasksAmpAPIPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := appConfig{AmpAPIPass: "secret"}
|
||||
maskSecrets(&cfg)
|
||||
if cfg.AmpAPIPass != masked {
|
||||
t.Errorf("AmpAPIPass = %q, want masked", cfg.AmpAPIPass)
|
||||
}
|
||||
// An empty password stays empty (not masked) so the UI shows "unset".
|
||||
empty := appConfig{}
|
||||
maskSecrets(&empty)
|
||||
if empty.AmpAPIPass != "" {
|
||||
t.Errorf("empty AmpAPIPass = %q, want empty", empty.AmpAPIPass)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveMaskedSecrets_RestoresAmpAPIPass verifies that when the client
|
||||
// posts back the masked placeholder, the stored AMP API password is restored
|
||||
// (here from the in-memory loadedConfig fallback when the file is unreadable).
|
||||
func TestPreserveMaskedSecrets_RestoresAmpAPIPass(t *testing.T) {
|
||||
orig := loadedConfig
|
||||
t.Cleanup(func() { loadedConfig = orig })
|
||||
loadedConfig = appConfig{AmpAPIPass: "stored-amp-pass"}
|
||||
|
||||
cfg := appConfig{AmpAPIPass: masked}
|
||||
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) { return nil, errors.New("no file") }, "ignored")
|
||||
if cfg.AmpAPIPass != "stored-amp-pass" {
|
||||
t.Errorf("AmpAPIPass = %q, want restored stored-amp-pass", cfg.AmpAPIPass)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreserveMaskedSecrets_KeepsExplicitAmpAPIPass verifies an explicitly-set
|
||||
// (non-masked) password is written through unchanged.
|
||||
func TestPreserveMaskedSecrets_KeepsExplicitAmpAPIPass(t *testing.T) {
|
||||
orig := loadedConfig
|
||||
t.Cleanup(func() { loadedConfig = orig })
|
||||
loadedConfig = appConfig{AmpAPIPass: "stored"}
|
||||
|
||||
cfg := appConfig{AmpAPIPass: "new-pass"}
|
||||
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) { return nil, errors.New("no file") }, "ignored")
|
||||
if cfg.AmpAPIPass != "new-pass" {
|
||||
t.Errorf("AmpAPIPass = %q, want new-pass (explicit value preserved)", cfg.AmpAPIPass)
|
||||
}
|
||||
}
|
||||
379
docs/reference-repos/icehunter/cmd/dune-admin/amp_api_test.go
Normal file
379
docs/reference-repos/icehunter/cmd/dune-admin/amp_api_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// identityWrap is a no-op container wrapper so tests inspect the exact curl
|
||||
// command the AMP API client builds, without the sudo/exec envelope.
|
||||
func identityWrap(s string) string { return s }
|
||||
|
||||
// decodePipedPayload extracts the base64 blob from an `echo <b64> | base64 -d |
|
||||
// curl ...` command and unmarshals the decoded JSON into out. The API client
|
||||
// base64-pipes request bodies so operator-supplied values (passwords, names)
|
||||
// never need shell escaping; tests assert on the decoded payload rather than on
|
||||
// brittle string formatting.
|
||||
func decodePipedPayload(t *testing.T, cmd string, out any) {
|
||||
t.Helper()
|
||||
// The payload rides as `echo <b64> | base64 -d | curl …`. Locate that segment
|
||||
// whether the command is bare (identity wrap) or wrapped for in-container
|
||||
// exec (`sudo … sh -c 'echo <b64> | …'`). The base64 token has no spaces or
|
||||
// quotes, so the field after "echo " is the payload in both forms.
|
||||
const marker = "echo "
|
||||
i := strings.Index(cmd, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("command has no `echo <payload>` segment: %q", cmd)
|
||||
}
|
||||
b64 := strings.Fields(cmd[i+len(marker):])[0]
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("payload is not valid base64 (%v) in cmd: %q", err, cmd)
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
t.Fatalf("decoded payload is not valid JSON (%v): %s", err, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAMPAPILogin_BuildsRequestAndReturnsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotCmd string
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
gotCmd = cmd
|
||||
return `{"success":true,"resultReason":"","sessionID":"abc-123"}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "s3cr3t!", 0)
|
||||
|
||||
sid, err := c.login()
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if sid != "abc-123" {
|
||||
t.Errorf("sessionID = %q, want abc-123", sid)
|
||||
}
|
||||
|
||||
// Endpoint + default port 8081 when port is 0.
|
||||
if !strings.Contains(gotCmd, "http://127.0.0.1:8081/API/Core/Login") {
|
||||
t.Errorf("missing Login endpoint with default port in cmd: %q", gotCmd)
|
||||
}
|
||||
// JSON is base64-piped, not inlined, and posted as the request body.
|
||||
for _, want := range []string{"base64 -d", "--data-binary @-", "-H 'Content-Type: application/json'", "-H 'Accept: application/json'"} {
|
||||
if !strings.Contains(gotCmd, want) {
|
||||
t.Errorf("cmd missing %q: %q", want, gotCmd)
|
||||
}
|
||||
}
|
||||
// Operator credentials, including the special-char password, ride in the
|
||||
// decoded payload — never on the shell command line.
|
||||
if strings.Contains(gotCmd, "s3cr3t!") {
|
||||
t.Errorf("password leaked onto the command line: %q", gotCmd)
|
||||
}
|
||||
var payload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Token string `json:"token"`
|
||||
RememberMe bool `json:"rememberMe"`
|
||||
}
|
||||
decodePipedPayload(t, gotCmd, &payload)
|
||||
if payload.Username != "admin" || payload.Password != "s3cr3t!" {
|
||||
t.Errorf("login payload creds = %+v, want admin/s3cr3t!", payload)
|
||||
}
|
||||
if payload.Token != "" || payload.RememberMe {
|
||||
t.Errorf("login payload token/rememberMe = %q/%v, want empty/false", payload.Token, payload.RememberMe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPILogin_HonoursConfiguredPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotCmd string
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
gotCmd = cmd
|
||||
return `{"success":true,"sessionID":"x"}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "u", "p", 9999)
|
||||
if _, err := c.login(); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotCmd, "http://127.0.0.1:9999/API/Core/Login") {
|
||||
t.Errorf("expected configured port 9999 in endpoint: %q", gotCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPILogin_FailedAuthIsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fnExecutor{fn: func(string) (string, error) {
|
||||
return `{"success":false,"resultReason":"Invalid username or password.","sessionID":""}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "wrong", 8081)
|
||||
_, err := c.login()
|
||||
if err == nil {
|
||||
t.Fatal("expected error on failed auth")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Invalid username or password") {
|
||||
t.Errorf("error should surface the AMP reason, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPILogin_ExecErrorIsWrapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fnExecutor{fn: func(string) (string, error) {
|
||||
return "curl: (7) Failed to connect", errors.New("exit status 7")
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
if _, err := c.login(); err == nil {
|
||||
t.Fatal("expected error when exec fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPILogin_GarbageResponseIsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fnExecutor{fn: func(string) (string, error) { return "not json at all", nil }}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
if _, err := c.login(); err == nil {
|
||||
t.Fatal("expected error on non-JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
// ── setConfig ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAMPAPISetConfig_LogsInThenSetsAndReusesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
var loginCalls, setCalls int
|
||||
var setCmd string
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "Core/Login"):
|
||||
loginCalls++
|
||||
return `{"success":true,"sessionID":"sess-9"}`, nil
|
||||
case strings.Contains(cmd, "Core/SetConfig"):
|
||||
setCalls++
|
||||
setCmd = cmd
|
||||
return `{"Status":true,"Reason":""}`, nil
|
||||
default:
|
||||
t.Fatalf("unexpected endpoint in cmd: %q", cmd)
|
||||
return "", nil
|
||||
}
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
|
||||
node := "Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier"
|
||||
if err := c.setConfig(node, "3.0"); err != nil {
|
||||
t.Fatalf("first setConfig: %v", err)
|
||||
}
|
||||
if err := c.setConfig("Meta.GenericModule.WorldTitle", "My Sietch's Server"); err != nil {
|
||||
t.Fatalf("second setConfig: %v", err)
|
||||
}
|
||||
|
||||
if loginCalls != 1 {
|
||||
t.Errorf("login called %d times, want 1 (session must be cached)", loginCalls)
|
||||
}
|
||||
if setCalls != 2 {
|
||||
t.Errorf("setConfig issued %d POSTs, want 2", setCalls)
|
||||
}
|
||||
if !strings.Contains(setCmd, "/API/Core/SetConfig") {
|
||||
t.Errorf("missing SetConfig endpoint: %q", setCmd)
|
||||
}
|
||||
var payload struct {
|
||||
Node string `json:"node"`
|
||||
Value string `json:"value"`
|
||||
SessionID string `json:"SESSIONID"`
|
||||
}
|
||||
decodePipedPayload(t, setCmd, &payload)
|
||||
if payload.Node != "Meta.GenericModule.WorldTitle" {
|
||||
t.Errorf("node = %q, want Meta.GenericModule.WorldTitle", payload.Node)
|
||||
}
|
||||
if payload.Value != "My Sietch's Server" {
|
||||
t.Errorf("value = %q, want the quote-containing title verbatim", payload.Value)
|
||||
}
|
||||
if payload.SessionID != "sess-9" {
|
||||
t.Errorf("SESSIONID = %q, want sess-9", payload.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPISetConfig_StatusFalseIsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
return `{"Status":false,"Reason":"No such node."}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
err := c.setConfig("Meta.GenericModule.Nope", "1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Status is false")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "No such node") {
|
||||
t.Errorf("error should surface AMP reason, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPISetConfig_AcceptsBareBoolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Some AMP versions return a bare `true` from SetConfig rather than an
|
||||
// ActionResult object.
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
return `true`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
if err := c.setConfig("Meta.GenericModule.X", "1"); err != nil {
|
||||
t.Errorf("bare true should be success, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMPAPISetConfig_LoginFailureAborts(t *testing.T) {
|
||||
t.Parallel()
|
||||
setReached := false
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":false,"resultReason":"locked"}`, nil
|
||||
}
|
||||
setReached = true
|
||||
return `{"Status":true}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
if err := c.setConfig("Meta.GenericModule.X", "1"); err == nil {
|
||||
t.Fatal("expected error when login fails")
|
||||
}
|
||||
if setReached {
|
||||
t.Error("setConfig must not POST when login fails")
|
||||
}
|
||||
}
|
||||
|
||||
// ── getConfig ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAMPAPIGetConfig_ReturnsCurrentValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
resp string
|
||||
want string
|
||||
}{
|
||||
{"string value", `{"CurrentValue":"3.000000","Node":"x"}`, "3.000000"},
|
||||
{"numeric value", `{"CurrentValue":42}`, "42"},
|
||||
{"bool value", `{"CurrentValue":true}`, "true"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var getCmd string
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
getCmd = cmd
|
||||
return tt.resp, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
got, err := c.getConfig("Meta.GenericModule.X")
|
||||
if err != nil {
|
||||
t.Fatalf("getConfig: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("CurrentValue = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !strings.Contains(getCmd, "/API/Core/GetConfig") {
|
||||
t.Errorf("missing GetConfig endpoint: %q", getCmd)
|
||||
}
|
||||
var payload struct {
|
||||
Node string `json:"node"`
|
||||
SessionID string `json:"SESSIONID"`
|
||||
}
|
||||
decodePipedPayload(t, getCmd, &payload)
|
||||
if payload.Node != "Meta.GenericModule.X" || payload.SessionID != "s" {
|
||||
t.Errorf("getConfig payload = %+v, want node X + session s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── setConfig session-expiry retry ───────────────────────────────────────────
|
||||
|
||||
// TestAMPAPISetConfig_RetriesOnSessionExpiry verifies that when SetConfig
|
||||
// returns a session-expired rejection, the client clears its session, re-logs
|
||||
// in, and retries the call — succeeding on the second attempt.
|
||||
func TestAMPAPISetConfig_RetriesOnSessionExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
firstSet := true
|
||||
var loginCalls int
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "Core/Login"):
|
||||
loginCalls++
|
||||
return `{"success":true,"sessionID":"fresh-sess"}`, nil
|
||||
case strings.Contains(cmd, "Core/SetConfig"):
|
||||
if firstSet {
|
||||
firstSet = false
|
||||
return `{"Status":false,"Reason":"Session has expired."}`, nil
|
||||
}
|
||||
return `{"Status":true}`, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
if err := c.setConfig("Meta.GenericModule.X", "1"); err != nil {
|
||||
t.Errorf("expected retry to succeed on session expiry, got: %v", err)
|
||||
}
|
||||
if loginCalls != 2 {
|
||||
t.Errorf("login called %d times, want 2 (initial + re-login on expiry)", loginCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMPAPISetConfig_DoesNotRetryNonSessionError verifies that a SetConfig
|
||||
// rejection unrelated to session expiry is returned immediately without retry.
|
||||
func TestAMPAPISetConfig_DoesNotRetryNonSessionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
setCalls := 0
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
setCalls++
|
||||
return `{"Status":false,"Reason":"No such node."}`, nil
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
err := c.setConfig("Meta.GenericModule.Bogus", "1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no-such-node rejection")
|
||||
}
|
||||
if setCalls != 1 {
|
||||
t.Errorf("non-session error must not trigger retry: SetConfig called %d times, want 1", setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAMPAPISetConfig_ReloginFailurePropagates verifies that when re-login
|
||||
// fails after a session expiry, the login error is returned rather than a
|
||||
// silent success.
|
||||
func TestAMPAPISetConfig_ReloginFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
var loginCalls int
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "Core/Login"):
|
||||
loginCalls++
|
||||
if loginCalls > 1 {
|
||||
return `{"success":false,"resultReason":"account locked"}`, nil
|
||||
}
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
case strings.Contains(cmd, "Core/SetConfig"):
|
||||
return `{"Status":false,"Reason":"Session has expired."}`, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}}
|
||||
c := newAMPAPIClient(exec, identityWrap, "admin", "pw", 8081)
|
||||
err := c.setConfig("Meta.GenericModule.X", "1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when re-login fails after session expiry")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "account locked") {
|
||||
t.Errorf("error should surface re-login reason, got: %v", err)
|
||||
}
|
||||
}
|
||||
195
docs/reference-repos/icehunter/cmd/dune-admin/amp_detect.go
Normal file
195
docs/reference-repos/icehunter/cmd/dune-admin/amp_detect.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ampInstance describes a single AMP-managed game-server instance discovered
|
||||
// via `ampinstmgr -l`. Used by the setup wizard to pre-fill prompts.
|
||||
type ampInstance struct {
|
||||
Name string // "DuneTest01"
|
||||
Module string // "GenericModule", "DuneAwakening", etc.
|
||||
Running bool
|
||||
InContainer bool
|
||||
DataPath string // "/home/amp/.ampdata/instances/DuneTest01"
|
||||
}
|
||||
|
||||
// candidate AMP user accounts checked in order. First one that exists wins.
|
||||
// Sites that use a custom AMP user will still get a manual fallback.
|
||||
var ampUserCandidates = []string{"amp", "ampuser"}
|
||||
|
||||
// detectAmpInstances runs `sudo -u <amp_user> ampinstmgr -l`, parses the
|
||||
// output, and returns the discovered instances along with the AMP user it
|
||||
// found. Filters out the ADS module (that's AMP itself, not a game).
|
||||
//
|
||||
// Returns an empty slice (not an error) when ampinstmgr is not on PATH or
|
||||
// the probe times out — the caller is expected to fall back to manual
|
||||
// prompts in that case. Genuine parse errors are returned as errors so the
|
||||
// operator sees them.
|
||||
func detectAmpInstances() (instances []ampInstance, ampUser string, err error) {
|
||||
if _, lookErr := exec.LookPath("ampinstmgr"); lookErr != nil {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
for _, candidate := range ampUserCandidates {
|
||||
if _, lookupErr := user.Lookup(candidate); lookupErr == nil {
|
||||
ampUser = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if ampUser == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-n", "-u", ampUser, "ampinstmgr", "-l")
|
||||
out, runErr := cmd.CombinedOutput()
|
||||
if runErr != nil {
|
||||
// Non-fatal: probably needs interactive sudo or ampinstmgr crashed.
|
||||
// Caller falls back to manual prompts.
|
||||
return nil, ampUser, nil
|
||||
}
|
||||
|
||||
instances = parseAmpInstmgrOutput(out)
|
||||
return instances, ampUser, nil
|
||||
}
|
||||
|
||||
// parseAmpInstmgrOutput parses the human-formatted output of `ampinstmgr -l`.
|
||||
// Pure function — exported via package-internal callers and unit-tested with
|
||||
// a golden fixture so changes to the output format are caught early.
|
||||
//
|
||||
// Output blocks look like:
|
||||
//
|
||||
// Instance Name │ DuneTest01
|
||||
// Module │ GenericModule
|
||||
// Running │ Yes
|
||||
// Runs in Container │ Yes
|
||||
// Data Path │ /home/amp/.ampdata/instances/DuneTest01
|
||||
//
|
||||
// The separator is the Unicode box-drawing character │ (U+2502) which AMP
|
||||
// emits regardless of locale. We also accept "|" as a fallback in case a
|
||||
// future ampinstmgr release drops Unicode in batch mode.
|
||||
//
|
||||
// ADS instances (AMP itself) are filtered out — the wizard targets game
|
||||
// servers.
|
||||
func parseAmpInstmgrOutput(out []byte) []ampInstance {
|
||||
var instances []ampInstance
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(out)))
|
||||
scanner.Buffer(make([]byte, 0, 1024), 1024*1024)
|
||||
|
||||
current := ampInstance{}
|
||||
flush := func() {
|
||||
if current.Name != "" && !strings.EqualFold(current.Module, "ADS") {
|
||||
instances = append(instances, current)
|
||||
}
|
||||
current = ampInstance{}
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
raw := scanner.Text()
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
key, val, ok := splitAmpKV(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "Instance Name":
|
||||
current.Name = val
|
||||
case "Module":
|
||||
current.Module = val
|
||||
case "Running":
|
||||
current.Running = strings.EqualFold(val, "Yes")
|
||||
case "Runs in Container":
|
||||
current.InContainer = strings.EqualFold(val, "Yes")
|
||||
case "Data Path":
|
||||
current.DataPath = val
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return instances
|
||||
}
|
||||
|
||||
// splitAmpKV splits a "Key │ Value" line on the Unicode box character (or
|
||||
// ASCII pipe as a fallback). Returns key, value, and whether the split
|
||||
// succeeded.
|
||||
func splitAmpKV(line string) (string, string, bool) {
|
||||
for _, sep := range []string{"│", "|"} {
|
||||
if i := strings.Index(line, sep); i >= 0 {
|
||||
key := strings.TrimSpace(line[:i])
|
||||
val := strings.TrimSpace(line[i+len(sep):])
|
||||
return key, val, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// probeGameRoot inspects a running container to discover the game install
|
||||
// path under /AMP/. Most AMP modules put the game at /AMP/<game-name>/ but
|
||||
// the exact <game-name> depends on the module (e.g. "duneawakening" for the
|
||||
// official CubeCoders Dune Awakening module). Rather than hardcoding that
|
||||
// suffix in the wizard, we list /AMP/ inside the container with -F to mark
|
||||
// directories with a trailing slash, then pick the first directory entry.
|
||||
// Returns "" + nil when the probe cannot answer authoritatively (container
|
||||
// not running, sudo prompts, non-standard layout) — caller falls back to
|
||||
// the historical default.
|
||||
func probeGameRoot(ctx context.Context, ampUser, container string) (string, error) {
|
||||
if ampUser == "" || container == "" {
|
||||
return "", errors.New("ampUser and container are required")
|
||||
}
|
||||
// Use `sudo -n -i -u <ampUser>` so sudo enters amp's login shell and
|
||||
// chdirs to amp's home before exec'ing — otherwise the calling user's
|
||||
// cwd typically isn't readable by amp ("cannot chdir to /home/X: …").
|
||||
// -F appends "/" to directory entries; -1 forces one-per-line output.
|
||||
// Use Output() (not CombinedOutput) so any residual stderr doesn't
|
||||
// poison the directory list.
|
||||
cmd := exec.CommandContext(ctx, "sudo", "-n", "-i", "-u", ampUser, "podman", "exec", container, "ls", "-1F", "/AMP/")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Probe failure (sudo prompt, container down, exec denied, etc.) —
|
||||
// caller falls back to defaults.
|
||||
return "", nil
|
||||
}
|
||||
for _, entry := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
// Skip blanks, the lost+found dir, and anything ls -F didn't tag as
|
||||
// a directory (file entries don't end in "/").
|
||||
if entry == "" || !strings.HasSuffix(entry, "/") {
|
||||
continue
|
||||
}
|
||||
dirName := strings.TrimSuffix(entry, "/")
|
||||
if dirName == "" || strings.HasPrefix(dirName, "lost+found") ||
|
||||
strings.HasPrefix(dirName, "AMP_Logs") || dirName == "Backups" {
|
||||
// Skip known AMP-meta directories — we want the game folder.
|
||||
continue
|
||||
}
|
||||
return "/AMP/" + dirName, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// summarizeInstance returns a single-line description suitable for the
|
||||
// instance picker in the setup wizard.
|
||||
func summarizeInstance(inst ampInstance) string {
|
||||
topology := "native"
|
||||
if inst.InContainer {
|
||||
topology = "container"
|
||||
}
|
||||
status := "stopped"
|
||||
if inst.Running {
|
||||
status = "running"
|
||||
}
|
||||
return fmt.Sprintf("%s (module=%s, %s, %s)", inst.Name, inst.Module, topology, status)
|
||||
}
|
||||
151
docs/reference-repos/icehunter/cmd/dune-admin/amp_detect_test.go
Normal file
151
docs/reference-repos/icehunter/cmd/dune-admin/amp_detect_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Golden fixture captured from a real `sudo -u amp ampinstmgr -l` run on an
|
||||
// AMP host running both an ADS instance (the AMP control panel itself) and a
|
||||
// GenericModule Dune instance. The parser should filter out ADS and surface
|
||||
// only the game instance.
|
||||
const ampInstmgrSampleOutput = `[Info/1] AMP Instance Manager v2.7.2.8 built 20/05/2026 06:54
|
||||
[Info/1] Stream: Mainline / Release - built by CUBECODERS/buildbot on CCL-DEV
|
||||
cannot chdir to /home/test: Permission denied
|
||||
Instance ID │ 88fe1020-71ed-4789-b390-c03a165f5630
|
||||
Module │ ADS
|
||||
Instance Name │ ADS01
|
||||
Friendly Name │ ADS01
|
||||
URL │ http://127.0.0.1:8080/
|
||||
Running │ Yes
|
||||
Runs in Container │ No
|
||||
Runs as Shared │ No
|
||||
Start on Boot │ Yes
|
||||
AMP Version │ 2.7.2.8
|
||||
Release Stream │ Mainline
|
||||
Data Path │ /home/amp/.ampdata/instances/ADS01
|
||||
|
||||
Instance ID │ 0f8247da-f1c9-4898-a806-8017beeb15e7
|
||||
Module │ GenericModule
|
||||
Instance Name │ DuneTest01
|
||||
Friendly Name │ DuneTest
|
||||
URL │ http://127.0.0.1:8081/
|
||||
Running │ No
|
||||
Runs in Container │ Yes
|
||||
Runs as Shared │ No
|
||||
Start on Boot │ Yes
|
||||
AMP Version │ 2.7.2.8
|
||||
Release Stream │ Mainline
|
||||
Data Path │ /home/amp/.ampdata/instances/DuneTest01
|
||||
`
|
||||
|
||||
func TestParseAmpInstmgrOutput_FiltersADSAndKeepsGame(t *testing.T) {
|
||||
got := parseAmpInstmgrOutput([]byte(ampInstmgrSampleOutput))
|
||||
want := []ampInstance{
|
||||
{
|
||||
Name: "DuneTest01",
|
||||
Module: "GenericModule",
|
||||
Running: false,
|
||||
InContainer: true,
|
||||
DataPath: "/home/amp/.ampdata/instances/DuneTest01",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("parseAmpInstmgrOutput: got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmpInstmgrOutput_MultipleGameInstances(t *testing.T) {
|
||||
in := `Instance Name │ DuneLive01
|
||||
Module │ DuneAwakening
|
||||
Running │ Yes
|
||||
Runs in Container │ No
|
||||
Data Path │ /home/amp/.ampdata/instances/DuneLive01
|
||||
|
||||
Instance Name │ DunePTS
|
||||
Module │ DuneAwakening
|
||||
Running │ No
|
||||
Runs in Container │ Yes
|
||||
Data Path │ /home/amp/.ampdata/instances/DunePTS
|
||||
`
|
||||
got := parseAmpInstmgrOutput([]byte(in))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 instances, got %d: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Name != "DuneLive01" || !got[0].Running || got[0].InContainer {
|
||||
t.Errorf("first instance wrong: %#v", got[0])
|
||||
}
|
||||
if got[1].Name != "DunePTS" || got[1].Running || !got[1].InContainer {
|
||||
t.Errorf("second instance wrong: %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmpInstmgrOutput_EmptyOrGarbage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"banner only", "[Info/1] AMP Instance Manager v2.7.2.8\n"},
|
||||
{"unrelated lines", "hello\nworld\nno separators here\n"},
|
||||
{"missing instance name", "Module │ DuneAwakening\nRunning │ Yes\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := parseAmpInstmgrOutput([]byte(c.in))
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0 instances, got %d: %#v", len(got), got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmpInstmgrOutput_AsciiPipeFallback(t *testing.T) {
|
||||
// Future-proofing: if ampinstmgr ever drops Unicode in scripted mode and
|
||||
// switches to plain ASCII pipes, we should still parse it.
|
||||
in := `Instance Name | DuneFallback
|
||||
Module | DuneAwakening
|
||||
Running | Yes
|
||||
Runs in Container | Yes
|
||||
Data Path | /home/amp/.ampdata/instances/DuneFallback
|
||||
`
|
||||
got := parseAmpInstmgrOutput([]byte(in))
|
||||
if len(got) != 1 || got[0].Name != "DuneFallback" {
|
||||
t.Errorf("ASCII-pipe parsing failed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAmpKV(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
key string
|
||||
val string
|
||||
ok bool
|
||||
}{
|
||||
{"Instance Name │ DuneTest01", "Instance Name", "DuneTest01", true},
|
||||
{"Module | DuneAwakening", "Module", "DuneAwakening", true},
|
||||
{"no separator here", "", "", false},
|
||||
{" │ ", "", "", true}, // edge case: empty key/val but valid split
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.line, func(t *testing.T) {
|
||||
k, v, ok := splitAmpKV(c.line)
|
||||
if ok != c.ok || k != c.key || v != c.val {
|
||||
t.Errorf("splitAmpKV(%q) = (%q, %q, %v); want (%q, %q, %v)",
|
||||
c.line, k, v, ok, c.key, c.val, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeInstance(t *testing.T) {
|
||||
got := summarizeInstance(ampInstance{
|
||||
Name: "DuneTest01", Module: "GenericModule", Running: true, InContainer: true,
|
||||
})
|
||||
for _, want := range []string{"DuneTest01", "GenericModule", "container", "running"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("summary %q missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
docs/reference-repos/icehunter/cmd/dune-admin/broker.go
Normal file
48
docs/reference-repos/icehunter/cmd/dune-admin/broker.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// brokerCredentials returns the configured AMQP username and password.
|
||||
// Both BROKER_USER and BROKER_PASS (or config equivalents) are required.
|
||||
func brokerCredentials() (user, pass string, err error) {
|
||||
user = brokerUser
|
||||
pass = brokerPass
|
||||
if user == "" || pass == "" {
|
||||
return "", "", fmt.Errorf("broker credentials are required: set BROKER_USER and BROKER_PASS")
|
||||
}
|
||||
return user, pass, nil
|
||||
}
|
||||
|
||||
// dialAMQP connects to an AMQP broker at addr. TCP is routed through the
|
||||
// global executor so it works for both direct and SSH-tunnelled connections.
|
||||
func dialAMQP(addr, user, pass string, useTLS bool) (*amqp.Connection, error) {
|
||||
cfg := amqp.Config{
|
||||
SASL: []amqp.Authentication{
|
||||
&amqp.PlainAuth{Username: user, Password: pass},
|
||||
},
|
||||
Vhost: "/",
|
||||
Locale: "en_US",
|
||||
Heartbeat: 10 * time.Second,
|
||||
Dial: func(_, _ string) (net.Conn, error) {
|
||||
if globalExecutor != nil {
|
||||
return globalExecutor.Dial("tcp", addr)
|
||||
}
|
||||
if globalSSH != nil {
|
||||
return globalSSH.Dial("tcp", addr)
|
||||
}
|
||||
return net.Dial("tcp", addr)
|
||||
},
|
||||
}
|
||||
if useTLS {
|
||||
cfg.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
return amqp.DialConfig("amqps://"+addr+"/", cfg)
|
||||
}
|
||||
return amqp.DialConfig("amqp://"+addr+"/", cfg)
|
||||
}
|
||||
7
docs/reference-repos/icehunter/cmd/dune-admin/compat.go
Normal file
7
docs/reference-repos/icehunter/cmd/dune-admin/compat.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
// Msg and Cmd replace charm.land/bubbletea/v2's tea.Msg and tea.Cmd so that
|
||||
// db.go and ssh.go can drop the bubbletea dependency while keeping their
|
||||
// existing return-type signatures.
|
||||
type Msg = any
|
||||
type Cmd = func() Msg
|
||||
203
docs/reference-repos/icehunter/cmd/dune-admin/connection.go
Normal file
203
docs/reference-repos/icehunter/cmd/dune-admin/connection.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConnectAll_ControlPlaneSurvivesDBFailure verifies that a DB connection
|
||||
// failure leaves the control plane and executor established. The control plane
|
||||
// (logs / battlegroup / server control) does not depend on the database, so a
|
||||
// DB outage must not disable it — the DB can be re-established later via
|
||||
// /api/v1/reconnect without losing control-plane functionality.
|
||||
func TestConnectAll_ControlPlaneSurvivesDBFailure(t *testing.T) {
|
||||
// connectAll mutates package-level globals — must not run in parallel.
|
||||
origCP, origSSH := controlPlane, sshHost
|
||||
origDBHost, origDBPort := dbHost, dbPort
|
||||
origDBUser, origDBPass, origDBName, origDBSchema := dbUser, dbPass, dbName, dbSchema
|
||||
origCfg := loadedConfig
|
||||
origDB, origExec, origCtl := globalDB, globalExecutor, globalControl
|
||||
t.Cleanup(func() {
|
||||
controlPlane, sshHost = origCP, origSSH
|
||||
dbHost, dbPort = origDBHost, origDBPort
|
||||
dbUser, dbPass, dbName, dbSchema = origDBUser, origDBPass, origDBName, origDBSchema
|
||||
loadedConfig = origCfg
|
||||
globalDB, globalExecutor, globalControl = origDB, origExec, origCtl
|
||||
})
|
||||
|
||||
controlPlane = "local" // local executor needs no network; control plane is pure
|
||||
sshHost = ""
|
||||
dbHost, dbPort = "127.0.0.1", 1 // nothing listens on :1 -> immediate connection refused
|
||||
dbUser, dbPass, dbName, dbSchema = "t", "t", "t", "t"
|
||||
loadedConfig = appConfig{}
|
||||
globalDB, globalExecutor, globalControl = nil, nil, nil
|
||||
|
||||
err := connectAll()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected connectAll to report the DB failure (closed port)")
|
||||
}
|
||||
if globalControl == nil {
|
||||
t.Error("control plane must be established despite DB failure (it does not depend on the DB)")
|
||||
}
|
||||
if globalExecutor == nil {
|
||||
t.Error("executor must remain established despite DB failure")
|
||||
}
|
||||
if globalDB != nil {
|
||||
t.Error("globalDB must be nil when the DB connect failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveControl(t *testing.T) {
|
||||
origControlPlane := controlPlane
|
||||
origSSHHost := sshHost
|
||||
t.Cleanup(func() {
|
||||
controlPlane = origControlPlane
|
||||
sshHost = origSSHHost
|
||||
})
|
||||
|
||||
controlPlane = "amp"
|
||||
sshHost = ""
|
||||
if got := resolveControl(); got != "amp" {
|
||||
t.Fatalf("expected explicit control plane to win, got %q", got)
|
||||
}
|
||||
|
||||
controlPlane = ""
|
||||
sshHost = "vm.example:22"
|
||||
if got := resolveControl(); got != "kubectl" {
|
||||
t.Fatalf("expected ssh host to default control to kubectl, got %q", got)
|
||||
}
|
||||
|
||||
controlPlane = ""
|
||||
sshHost = ""
|
||||
if got := resolveControl(); got != "local" {
|
||||
t.Fatalf("expected local default without ssh/control flags, got %q", got)
|
||||
}
|
||||
}
|
||||
151
docs/reference-repos/icehunter/cmd/dune-admin/control.go
Normal file
151
docs/reference-repos/icehunter/cmd/dune-admin/control.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ControlPlane abstracts the server management layer. It determines WHAT
|
||||
// commands to run (kubectl, docker, local shell) while the Executor determines
|
||||
// WHERE they run (locally or over SSH).
|
||||
type ControlPlane interface {
|
||||
// Name returns the control plane identifier for status reporting.
|
||||
Name() string
|
||||
|
||||
// GetStatus returns the battlegroup status and per-server runtime stats.
|
||||
GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error)
|
||||
|
||||
// ExecCommand runs a lifecycle command: start, stop, restart, update, backup.
|
||||
ExecCommand(ctx context.Context, exec Executor, cmd string) (string, error)
|
||||
|
||||
// ListProcesses returns running processes/pods/containers and a context label.
|
||||
ListProcesses(ctx context.Context, exec Executor) ([]ProcessInfo, string, error)
|
||||
|
||||
// ListLogSources returns available log sources (pods, containers, services).
|
||||
ListLogSources(ctx context.Context, exec Executor) ([]LogSource, error)
|
||||
|
||||
// StreamLog opens a log stream for the named source. The caller must invoke
|
||||
// cancel when done to release the underlying session/process.
|
||||
StreamLog(ctx context.Context, exec Executor, ns, name string) (<-chan string, func(), error)
|
||||
|
||||
// CaptureJWT extracts the ServiceAuthToken from the game daemon and returns
|
||||
// a HostId and freshly-signed JWT for broker authentication.
|
||||
CaptureJWT(ctx context.Context, exec Executor) (hostID, token string, err error)
|
||||
|
||||
// EvalOnGameBroker runs an Erlang expression via rabbitmqctl eval inside the
|
||||
// mq-game broker. Used for publishing server commands with user_id="fls",
|
||||
// which AMQP connections cannot set (broker validates UserId against auth'd user).
|
||||
EvalOnGameBroker(ctx context.Context, exec Executor, expr string) (string, error)
|
||||
|
||||
// DiscoverIniDir returns the directory containing UserGame.ini and
|
||||
// UserOverrides.ini. kubectl auto-discovers this from k3s storage;
|
||||
// docker and local require server_ini_dir to be set in config.
|
||||
DiscoverIniDir(ctx context.Context, exec Executor) (string, error)
|
||||
|
||||
// ReadDefaultINI reads DefaultGame.ini or DefaultEngine.ini from inside the
|
||||
// game container/pod, where the file lives as part of the image. Returns the
|
||||
// file contents or "" if unavailable. The local control plane returns "" and
|
||||
// lets the host-path fallback handle it.
|
||||
ReadDefaultINI(ctx context.Context, exec Executor, filename string) string
|
||||
}
|
||||
|
||||
// ── Types shared across control plane implementations ─────────────────────────
|
||||
|
||||
type BattlegroupStatus struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Phase string `json:"phase"`
|
||||
Database string `json:"database"`
|
||||
Servers []ServerRow `json:"servers"`
|
||||
}
|
||||
|
||||
type ServerRow struct {
|
||||
Map string `json:"map"`
|
||||
Sietch string `json:"sietch"`
|
||||
Dimension int `json:"dimension"`
|
||||
Partition int `json:"partition"`
|
||||
Phase string `json:"phase"`
|
||||
Ready bool `json:"ready"`
|
||||
Players int `json:"players"`
|
||||
PlayerHardCap int `json:"playerHardCap"`
|
||||
Queue int `json:"queue"`
|
||||
// Port is the game-server UDP port parsed from the process args (0 if
|
||||
// unknown). AgeSeconds is how long the process has been running, sourced
|
||||
// best-effort from `ps -o etimes=` (0 when unavailable, e.g. non-AMP planes).
|
||||
Port int `json:"port,omitempty"`
|
||||
AgeSeconds int `json:"ageSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type LogSource struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// newControlPlane returns the appropriate ControlPlane based on the control
|
||||
// name ("kubectl", "docker", "local", "amp"). Unrecognised names fall back to local.
|
||||
func newControlPlane(name string, cfg appConfig) ControlPlane {
|
||||
switch name {
|
||||
case "kubectl":
|
||||
return &kubectlControl{namespace: cfg.ControlNamespace}
|
||||
case "docker":
|
||||
return &dockerControl{
|
||||
gameserver: cfg.DockerGameserver,
|
||||
brokerGame: cfg.DockerBrokerGame,
|
||||
brokerAdmin: cfg.DockerBrokerAdmin,
|
||||
}
|
||||
case "amp":
|
||||
user := cfg.AmpUser
|
||||
if user == "" {
|
||||
user = "amp"
|
||||
}
|
||||
container := cfg.AmpContainer
|
||||
if container == "" && cfg.AmpInstance != "" {
|
||||
container = "AMP_" + cfg.AmpInstance
|
||||
}
|
||||
// Default to container mode (CubeCoders' standard template) unless the
|
||||
// admin explicitly opts out.
|
||||
useContainer := true
|
||||
if cfg.AmpUseContainer != nil {
|
||||
useContainer = *cfg.AmpUseContainer
|
||||
}
|
||||
return &Control{
|
||||
instance: cfg.AmpInstance,
|
||||
container: container,
|
||||
ampUser: user,
|
||||
logPath: cfg.AmpLogPath,
|
||||
directorURL: cfg.DirectorURL,
|
||||
iniDir: cfg.ServerIniDir,
|
||||
useContainer: useContainer,
|
||||
containerRuntime: cfg.AmpContainerRuntime,
|
||||
dataRoot: cfg.AmpDataRoot,
|
||||
apiUser: cfg.AmpAPIUser,
|
||||
apiPass: cfg.AmpAPIPass,
|
||||
apiPort: cfg.AmpAPIPort,
|
||||
pgBin: cfg.AmpPgBin,
|
||||
pgLib: cfg.AmpPgLib,
|
||||
}
|
||||
default:
|
||||
return &localControl{
|
||||
cmdStart: cfg.CmdStart,
|
||||
cmdStop: cfg.CmdStop,
|
||||
cmdRestart: cfg.CmdRestart,
|
||||
cmdStatus: cfg.CmdStatus,
|
||||
controlNamespace: cfg.ControlNamespace,
|
||||
brokerExecPrefix: cfg.BrokerExecPrefix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errNotSupported returns a consistent "not supported" error for control plane
|
||||
// methods that are not available in a given implementation.
|
||||
func errNotSupported(control, method string) error {
|
||||
return fmt.Errorf("%s control plane does not support %s", control, method)
|
||||
}
|
||||
836
docs/reference-repos/icehunter/cmd/dune-admin/control_amp.go
Normal file
836
docs/reference-repos/icehunter/cmd/dune-admin/control_amp.go
Normal file
@@ -0,0 +1,836 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ampControl implements ControlPlane for CubeCoders AMP installations. AMP can
|
||||
// run the game server in two modes:
|
||||
//
|
||||
// - containerised: game processes run inside a container (podman or docker).
|
||||
// Log/INI access and broker control require `<runtime> exec`; choose the
|
||||
// runtime with containerRuntime. Set useContainer = true.
|
||||
// - native: game processes run directly on the host as the AMP user. Logs and
|
||||
// INI files are on the host filesystem; rabbitmqctl is on the host PATH. Set
|
||||
// useContainer = false.
|
||||
//
|
||||
// Process discovery (GetStatus/ListProcesses/CaptureJWT) is identical in both
|
||||
// modes — game-server processes appear in the host's `ps` output regardless.
|
||||
//
|
||||
// All instance- and container-specific names come from config; this provider
|
||||
// is not specialised to any particular AMP install.
|
||||
type ampControl struct {
|
||||
instance string // ampinstmgr instance name (e.g. "MehDune01")
|
||||
container string // container name (only used when useContainer=true)
|
||||
ampUser string // OS user that owns the AMP instance (default "amp")
|
||||
logPath string // log directory — in-container path if containerised, host path if native
|
||||
directorURL string // optional Battlegroup Director URL for status/exchange discovery
|
||||
iniDir string // host path to UserGame.ini directory (configured)
|
||||
useContainer bool // true: wrap in-container ops in `<runtime> exec`; false: run on host directly
|
||||
containerRuntime string // "podman" (default) or "docker"; CLI for `<rt> exec` in container mode
|
||||
dataRoot string // per-game data root (default /AMP/duneawakening)
|
||||
|
||||
// AMP Web API credentials — used to write server settings through AMP's own
|
||||
// config (Core/SetConfig) so they survive AMP regenerating the game INIs.
|
||||
apiUser string
|
||||
apiPass string
|
||||
apiPort int // 0 → defaultAmpAPIPort (8081)
|
||||
|
||||
// Postgres client tooling inside the container, for #150 DB backups. The
|
||||
// game's PG17 ships a musl pg_dump under pgBin, but its libpq dir lacks the
|
||||
// compression/SSL libs — those live in the sibling db-utils tree, so pgLib is
|
||||
// a colon-joined path spanning both. Empty → validated AMP defaults.
|
||||
pgBin string // dir containing pg_dump/pg_restore
|
||||
pgLib string // LD_LIBRARY_PATH for the above
|
||||
}
|
||||
|
||||
const (
|
||||
defaultAmpPgBin = "/AMP/duneawakening/extracted/postgres/usr/local/bin"
|
||||
defaultAmpPgLib = "/AMP/duneawakening/extracted/postgres/usr/local/lib:" +
|
||||
"/AMP/duneawakening/extracted/db-utils/usr/lib"
|
||||
)
|
||||
|
||||
func (c *ampControl) pgBinDir() string {
|
||||
if c.pgBin != "" {
|
||||
return c.pgBin
|
||||
}
|
||||
return defaultAmpPgBin
|
||||
}
|
||||
|
||||
func (c *ampControl) pgLibPath() string {
|
||||
if c.pgLib != "" {
|
||||
return c.pgLib
|
||||
}
|
||||
return defaultAmpPgLib
|
||||
}
|
||||
|
||||
func (c *ampControl) Name() string { return "amp" }
|
||||
|
||||
// ── status & lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
ampPortRe = regexp.MustCompile(`-Port=(\d+)`)
|
||||
ampPartRe = regexp.MustCompile(`-PartitionIndex=(\d+)`)
|
||||
)
|
||||
|
||||
func (c *ampControl) GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error) {
|
||||
procs, err := c.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The host process args only carry -PartitionIndex, never a dimension. The
|
||||
// Battlegroup Director knows each partition's dimensionIndex and label, so
|
||||
// enrich rows from there. Best-effort: a missing/unreachable director just
|
||||
// leaves Dimension at zero.
|
||||
dirMeta, err := c.fetchDirectorPartitions(ctx, exec)
|
||||
if err != nil {
|
||||
log.Printf("ampControl.GetStatus: director enrichment unavailable: %v", err)
|
||||
}
|
||||
pids := make([]int, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
pids = append(pids, p.pid)
|
||||
}
|
||||
ages := c.fetchProcessAges(exec, pids)
|
||||
servers := make([]ServerRow, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
row := ServerRow{
|
||||
Map: p.mapName,
|
||||
Partition: p.partition,
|
||||
Phase: "Running",
|
||||
Ready: true,
|
||||
Players: 0,
|
||||
Port: p.port,
|
||||
AgeSeconds: ages[p.pid],
|
||||
}
|
||||
if meta, ok := dirMeta[p.partition]; ok {
|
||||
row.Dimension = meta.dimension
|
||||
row.Players = meta.players
|
||||
row.PlayerHardCap = meta.playerHardCap
|
||||
row.Queue = meta.queue
|
||||
if meta.label != "" {
|
||||
row.Sietch = meta.label
|
||||
}
|
||||
}
|
||||
servers = append(servers, row)
|
||||
}
|
||||
dbPhase := "Disconnected"
|
||||
if globalDB != nil {
|
||||
dbPhase = "Connected"
|
||||
}
|
||||
return &BattlegroupStatus{
|
||||
Name: c.container,
|
||||
Title: "AMP Managed",
|
||||
Phase: "Running",
|
||||
Database: dbPhase,
|
||||
Servers: servers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// partitionMeta is director-sourced metadata for one game-server partition.
|
||||
type partitionMeta struct {
|
||||
dimension int
|
||||
label string
|
||||
players int
|
||||
playerHardCap int
|
||||
queue int
|
||||
}
|
||||
|
||||
// fetchDirectorPartitions queries the Battlegroup Director's /v0/battlegroup
|
||||
// endpoint and returns a map of partitionId → metadata. It returns nil (no
|
||||
// error) when no director URL is configured; transport, status, and decode
|
||||
// failures are returned as errors so the caller can log them and continue.
|
||||
func (c *ampControl) fetchDirectorPartitions(ctx context.Context, exec Executor) (map[int]partitionMeta, error) {
|
||||
if c.directorURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := strings.TrimRight(c.directorURL, "/") + "/v0/battlegroup"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build director request: %w", err)
|
||||
}
|
||||
// Route through the executor so the director is reachable from wherever the
|
||||
// executor runs (e.g. the AMP box over SSH), not the dune-admin host. Status
|
||||
// polling must stay snappy, so a short timeout falls back fast.
|
||||
client := &http.Client{Timeout: 3 * time.Second, Transport: httpTransportVia(exec.Dial)}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query director: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("director returned status %d", resp.StatusCode)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("decode director response: %w", err)
|
||||
}
|
||||
meta := map[int]partitionMeta{}
|
||||
collectPartitions(raw, meta)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// collectPartitions recursively walks a decoded director response, recording
|
||||
// the dimensionIndex and label of every "partition" object it finds keyed by
|
||||
// partitionId. This is structure-agnostic: it picks up single-server,
|
||||
// dimension (serversByDimension), and instanced (instances) maps alike.
|
||||
func collectPartitions(v any, out map[int]partitionMeta) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
if p, ok := t["partition"].(map[string]any); ok {
|
||||
if id, ok := jsonPartitionID(p["partitionId"]); ok {
|
||||
// Player count, queue, and caps are siblings of "partition" on
|
||||
// the server node.
|
||||
out[id] = partitionMeta{
|
||||
dimension: jsonInt(p["dimensionIndex"]),
|
||||
label: jsonString(p["label"]),
|
||||
players: jsonInt(t["numPlayersInGame"]),
|
||||
playerHardCap: effectivePlayerHardCap(t),
|
||||
queue: jsonInt(t["numPlayersInQueue"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range t {
|
||||
collectPartitions(child, out)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
collectPartitions(child, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonPartitionID extracts a partition ID from a decoded JSON number, reporting
|
||||
// whether the value was present and numeric (a partition ID may legitimately
|
||||
// be 0, so absence must be distinguished from zero).
|
||||
func jsonPartitionID(v any) (int, bool) {
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(f), true
|
||||
}
|
||||
|
||||
// jsonInt coerces a decoded JSON number to int, returning 0 for non-numbers.
|
||||
func jsonInt(v any) int {
|
||||
f, _ := v.(float64)
|
||||
return int(f)
|
||||
}
|
||||
|
||||
// jsonString coerces a decoded JSON value to string, returning "" otherwise.
|
||||
func jsonString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// effectivePlayerHardCap resolves a server node's player cap: the per-server
|
||||
// override (serverPlayerHardCap) wins when positive, otherwise the configured
|
||||
// cap (cfg.playerHardCap). The director uses -1 for "no override".
|
||||
func effectivePlayerHardCap(node map[string]any) int {
|
||||
if override := jsonInt(node["serverPlayerHardCap"]); override > 0 {
|
||||
return override
|
||||
}
|
||||
if cfg, ok := node["cfg"].(map[string]any); ok {
|
||||
return jsonInt(cfg["playerHardCap"])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *ampControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
|
||||
if c.instance == "" {
|
||||
return "", fmt.Errorf("amp control plane requires amp_instance to be set")
|
||||
}
|
||||
switch cmd {
|
||||
case "start":
|
||||
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -s %s 2>&1", c.ampUser, c.instance))
|
||||
case "stop":
|
||||
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -q %s 2>&1", c.ampUser, c.instance))
|
||||
case "restart":
|
||||
return c.restartGame(exec)
|
||||
default:
|
||||
return "", fmt.Errorf("amp control does not support %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// restartGame cycles the game server so config changes (CVars / UPROPERTYs)
|
||||
// actually take effect.
|
||||
//
|
||||
// In container mode it restarts the whole AMP container. This is deliberate:
|
||||
// `ampinstmgr -q` does NOT reap the DuneSandboxServer processes — confirmed
|
||||
// in-game, where the game kept 4d+ uptime through both dune-admin's old restart
|
||||
// AND AMP's own Stop, so any setting needing a game restart never applied. A
|
||||
// `<runtime> restart` is the proven action that actually recycles the game, and
|
||||
// it preserves the container filesystem so AMP regenerates the game INIs from
|
||||
// its config on the way back up. Blast radius: this briefly cycles the
|
||||
// in-container Postgres and broker too — dune-admin reconnects to the DB after.
|
||||
//
|
||||
// In native mode (no container) the game runs as host processes ampinstmgr
|
||||
// manages directly, so the stop/start cycle is retained.
|
||||
func (c *ampControl) restartGame(exec Executor) (string, error) {
|
||||
if c.useContainer {
|
||||
if c.container == "" {
|
||||
return "", fmt.Errorf("amp control in container mode requires amp_container to be set")
|
||||
}
|
||||
return exec.Exec(fmt.Sprintf("sudo -i -u %s %s restart %s 2>&1",
|
||||
c.ampUser, c.runtimeCLI(), c.container))
|
||||
}
|
||||
return exec.Exec(fmt.Sprintf("sudo -i -u %s ampinstmgr -q %s 2>&1 && sudo -i -u %s ampinstmgr -s %s 2>&1",
|
||||
c.ampUser, c.instance, c.ampUser, c.instance))
|
||||
}
|
||||
|
||||
// ── database backup/restore (#150) ──────────────────────────────────────────
|
||||
|
||||
// pgDumpCommand builds the host shell command that runs pg_dump (-Fc) inside the
|
||||
// container, redirecting its stdout to a host file. The '>' redirect is handled
|
||||
// by the outer host shell (run by the dune-admin service user), so the dump
|
||||
// lands host-side and service-user-owned.
|
||||
func (c *ampControl) pgDumpCommand(conn dbConn, destPath string) string {
|
||||
inner := fmt.Sprintf(
|
||||
"%s exec -e PGPASSWORD=%s -e LD_LIBRARY_PATH=%s %s %s -Fc -h %s -p %d -U %s -d %s",
|
||||
c.runtimeCLI(),
|
||||
shellQuote(conn.Pass),
|
||||
shellQuote(c.pgLibPath()),
|
||||
shellQuote(c.container),
|
||||
shellQuote(c.pgBinDir()+"/pg_dump"),
|
||||
shellQuote(conn.Host), conn.Port, shellQuote(conn.User), shellQuote(conn.Name),
|
||||
)
|
||||
return fmt.Sprintf("sudo -i -u %s %s > %s", c.ampUser, inner, shellQuote(destPath))
|
||||
}
|
||||
|
||||
// pgRestoreCommand builds the host shell command that pipes a host dump file into
|
||||
// pg_restore (--clean --if-exists) running inside the container. DESTRUCTIVE:
|
||||
// the caller must ensure the game is stopped.
|
||||
func (c *ampControl) pgRestoreCommand(conn dbConn, srcPath string) string {
|
||||
inner := fmt.Sprintf(
|
||||
"%s exec -i -e PGPASSWORD=%s -e LD_LIBRARY_PATH=%s %s %s --clean --if-exists --no-owner -h %s -p %d -U %s -d %s",
|
||||
c.runtimeCLI(),
|
||||
shellQuote(conn.Pass),
|
||||
shellQuote(c.pgLibPath()),
|
||||
shellQuote(c.container),
|
||||
shellQuote(c.pgBinDir()+"/pg_restore"),
|
||||
shellQuote(conn.Host), conn.Port, shellQuote(conn.User), shellQuote(conn.Name),
|
||||
)
|
||||
return fmt.Sprintf("sudo -i -u %s %s < %s", c.ampUser, inner, shellQuote(srcPath))
|
||||
}
|
||||
|
||||
// BackupDatabase runs pg_dump in-container and writes the archive to destPath on
|
||||
// the host. Implements dbBackupProvider.
|
||||
func (c *ampControl) BackupDatabase(exec Executor, conn dbConn, destPath string) (string, error) {
|
||||
if !c.useContainer || c.container == "" {
|
||||
return "", fmt.Errorf("AMP database backup requires container mode (amp_container)")
|
||||
}
|
||||
out, err := exec.Exec(c.pgDumpCommand(conn, destPath))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("pg_dump: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RestoreDatabase pipes a host dump into pg_restore in-container. DESTRUCTIVE.
|
||||
// Implements dbBackupProvider.
|
||||
func (c *ampControl) RestoreDatabase(exec Executor, conn dbConn, srcPath string) (string, error) {
|
||||
if !c.useContainer || c.container == "" {
|
||||
return "", fmt.Errorf("AMP database restore requires container mode (amp_container)")
|
||||
}
|
||||
out, err := exec.Exec(c.pgRestoreCommand(conn, srcPath))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("pg_restore: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── process & log discovery ───────────────────────────────────────────────────
|
||||
|
||||
type ampGameProcess struct {
|
||||
pid int
|
||||
mapName string
|
||||
port int
|
||||
partition int
|
||||
}
|
||||
|
||||
func parseAMPMapName(argsFields []string) string {
|
||||
for i, field := range argsFields {
|
||||
if field == "DuneSandbox" && i+1 < len(argsFields) {
|
||||
return argsFields[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAMPArgInt(re *regexp.Regexp, args string) int {
|
||||
m := re.FindStringSubmatch(args)
|
||||
if len(m) <= 1 {
|
||||
return 0
|
||||
}
|
||||
value, _ := strconv.Atoi(m[1])
|
||||
return value
|
||||
}
|
||||
|
||||
func parseAMPGameProcess(line string) (ampGameProcess, bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return ampGameProcess{}, false
|
||||
}
|
||||
pid, _ := strconv.Atoi(fields[0])
|
||||
argsFields := fields[1:]
|
||||
args := strings.Join(argsFields, " ")
|
||||
return ampGameProcess{
|
||||
pid: pid,
|
||||
mapName: parseAMPMapName(argsFields),
|
||||
port: parseAMPArgInt(ampPortRe, args),
|
||||
partition: parseAMPArgInt(ampPartRe, args),
|
||||
}, true
|
||||
}
|
||||
|
||||
// parseProcessAges parses the output of `ps -o pid=,etimes=` into a pid→elapsed
|
||||
// seconds map. Each non-empty line has two whitespace-separated columns; lines
|
||||
// that don't parse cleanly are skipped rather than failing the whole map.
|
||||
func parseProcessAges(out string) map[int]int {
|
||||
ages := map[int]int{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
age, err := strconv.Atoi(fields[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ages[pid] = age
|
||||
}
|
||||
return ages
|
||||
}
|
||||
|
||||
// fetchProcessAges returns a best-effort pid→uptime-seconds map for the given
|
||||
// pids. It is deliberately separate from listGameProcesses so a `ps` that lacks
|
||||
// the etimes field (or any error) degrades to "no ages" rather than breaking the
|
||||
// core process listing that the status table and lifecycle commands depend on.
|
||||
func (c *ampControl) fetchProcessAges(exec Executor, pids []int) map[int]int {
|
||||
if len(pids) == 0 {
|
||||
return map[int]int{}
|
||||
}
|
||||
ids := make([]string, len(pids))
|
||||
for i, p := range pids {
|
||||
ids[i] = strconv.Itoa(p)
|
||||
}
|
||||
cmd := "ps -o pid=,etimes= -p " + strings.Join(ids, ",") + " 2>/dev/null"
|
||||
if c.useContainer {
|
||||
if c.container == "" {
|
||||
return map[int]int{}
|
||||
}
|
||||
cmd = c.wrapInContainer(cmd)
|
||||
}
|
||||
out, err := exec.Exec(cmd)
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
return map[int]int{}
|
||||
}
|
||||
return parseProcessAges(out)
|
||||
}
|
||||
|
||||
func (c *ampControl) listGameProcesses(exec Executor) ([]ampGameProcess, error) {
|
||||
cmd := `ps -eo pid,args --no-headers 2>/dev/null | grep 'DuneSandboxServer-Linux-Shipping' | grep -v grep`
|
||||
if c.useContainer {
|
||||
if c.container == "" {
|
||||
return nil, fmt.Errorf("amp_container not configured")
|
||||
}
|
||||
cmd = c.wrapInContainer(cmd)
|
||||
}
|
||||
out, err := exec.Exec(cmd)
|
||||
if err != nil && strings.TrimSpace(out) == "" {
|
||||
return []ampGameProcess{}, nil
|
||||
}
|
||||
var procs []ampGameProcess
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
proc, ok := parseAMPGameProcess(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
procs = append(procs, proc)
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
|
||||
func (c *ampControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
|
||||
procs, err := c.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var infos []ProcessInfo
|
||||
for _, p := range procs {
|
||||
infos = append(infos, ProcessInfo{
|
||||
Name: fmt.Sprintf("%s (pid=%d port=%d partition=%d)", p.mapName, p.pid, p.port, p.partition),
|
||||
Namespace: c.container,
|
||||
Status: "Running",
|
||||
})
|
||||
}
|
||||
if infos == nil {
|
||||
infos = []ProcessInfo{}
|
||||
}
|
||||
return infos, c.container, nil
|
||||
}
|
||||
|
||||
// runtimeCLI returns the container CLI used to wrap in-container operations as
|
||||
// `<rt> exec` when useContainer is true. Defaults to podman when unset so
|
||||
// existing (podman) installs are unaffected.
|
||||
func (c *ampControl) runtimeCLI() string {
|
||||
if c.containerRuntime == "" {
|
||||
return "podman"
|
||||
}
|
||||
return c.containerRuntime
|
||||
}
|
||||
|
||||
// wrapInContainer returns a command string that, when executed via the host
|
||||
// executor, runs the given remote command. In container mode this is wrapped
|
||||
// in `sudo -i -u <ampUser> <runtime> exec <container> sh -c '<remoteCmd>'`. In
|
||||
// native mode it's wrapped in `sudo -i -u <ampUser> sh -c '<remoteCmd>'`.
|
||||
//
|
||||
// The remote command is single-quoted; the caller MUST NOT embed single quotes
|
||||
// in the command itself.
|
||||
func (c *ampControl) wrapInContainer(remoteCmd string) string {
|
||||
if c.useContainer {
|
||||
return fmt.Sprintf("sudo -i -u %s %s exec %s sh -c %s",
|
||||
c.ampUser, c.runtimeCLI(), c.container, shellQuote(remoteCmd))
|
||||
}
|
||||
return fmt.Sprintf("sudo -i -u %s sh -c %s", c.ampUser, shellQuote(remoteCmd))
|
||||
}
|
||||
|
||||
func (c *ampControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
|
||||
if c.logPath == "" {
|
||||
return nil, fmt.Errorf("amp control requires amp_log_path to be set")
|
||||
}
|
||||
if c.useContainer && c.container == "" {
|
||||
return nil, fmt.Errorf("amp control in container mode requires amp_container to be set")
|
||||
}
|
||||
cmd := c.wrapInContainer(fmt.Sprintf("ls -1 %s 2>/dev/null", c.logPath))
|
||||
out, err := exec.Exec(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list log dir: %w (%s)", err, out)
|
||||
}
|
||||
ns := c.container
|
||||
if !c.useContainer {
|
||||
ns = "host:" + c.logPath
|
||||
}
|
||||
var sources []LogSource
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if !strings.HasSuffix(name, ".log") {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, LogSource{Namespace: ns, Name: name})
|
||||
}
|
||||
if sources == nil {
|
||||
sources = []LogSource{}
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
var ampLogFileNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.log$`)
|
||||
|
||||
func (c *ampControl) StreamLog(_ context.Context, exec Executor, _, name string) (<-chan string, func(), error) {
|
||||
if !ampLogFileNameRe.MatchString(name) {
|
||||
return nil, func() {}, fmt.Errorf("invalid log file name %q", name)
|
||||
}
|
||||
cmd := c.wrapInContainer(fmt.Sprintf("tail -n 200 -f %s/%s", c.logPath, name))
|
||||
return exec.Stream(cmd)
|
||||
}
|
||||
|
||||
// ── JWT capture ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *ampControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
|
||||
out, err := exec.Exec(`ps aux 2>/dev/null | grep DuneSandboxServer | grep -oP 'ServiceAuthToken=\K[^ ]+' | head -1`)
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return "", "", fmt.Errorf("could not find ServiceAuthToken in process args (game server not running?)")
|
||||
}
|
||||
token := strings.TrimSpace(out)
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return "", "", fmt.Errorf("malformed JWT")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decode JWT payload: %w", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", "", fmt.Errorf("parse JWT payload: %w", err)
|
||||
}
|
||||
hostID := fmt.Sprintf("%v", claims["HostId"])
|
||||
return hostID, token, nil
|
||||
}
|
||||
|
||||
// ── RabbitMQ admin (exchange listing + capture user provisioning) ─────────────
|
||||
|
||||
// defaultAmpDataRoot is the in-container per-game data root that AMP creates
|
||||
// for the Dune Awakening module.
|
||||
const defaultAmpDataRoot = "/AMP/duneawakening"
|
||||
|
||||
// ampDataRoot returns the AMP per-game data root (defaults to Dune Awakening).
|
||||
func (c *ampControl) ampDataRoot() string {
|
||||
if c.dataRoot != "" {
|
||||
return c.dataRoot
|
||||
}
|
||||
return defaultAmpDataRoot
|
||||
}
|
||||
|
||||
// buildRabbitmqctl emits a complete shell command that runs rabbitmqctl
|
||||
// against one of AMP's brokers. AMP bundles its own musl-linked Erlang
|
||||
// runtime but only patchelfs the binaries it boots at startup (beam.smp);
|
||||
// the admin-CLI escript binary is left with the original /lib/ld-musl-* shebang.
|
||||
// To call it from outside AMP's normal launch path we have to:
|
||||
// - invoke the bundled musl loader explicitly (works around the missing
|
||||
// /lib/ld-musl-x86_64.so.1 on Debian-based AMP containers)
|
||||
// - chain through the bundled escript and the rabbitmqctl escript wrapper
|
||||
// - set HOME to the broker's runtime dir so the right .erlang.cookie is
|
||||
// used (each broker has its own cookie under runtime/mq-<broker>-home/)
|
||||
// - point RABBITMQ_HOME at the AMP-bundled rabbitmq install
|
||||
// - target the right Erlang node name (rabbit-admin or rabbit-game)
|
||||
//
|
||||
// broker = "mq-admin" or "mq-game". args is the rabbitmqctl subcommand
|
||||
// plus its arguments, already shell-quoted by the caller as needed.
|
||||
func (c *ampControl) buildRabbitmqctl(broker, args string) string {
|
||||
root := c.ampDataRoot()
|
||||
mq := root + "/extracted/mq"
|
||||
home := root + "/runtime/" + broker + "-home"
|
||||
node := "rabbit-admin@localhost"
|
||||
if strings.Contains(broker, "game") {
|
||||
node = "rabbit-game@localhost"
|
||||
}
|
||||
inner := fmt.Sprintf(
|
||||
"env -i HOME=%s LC_ALL=C "+
|
||||
"LD_LIBRARY_PATH=%[2]s/lib:%[2]s/usr/lib:%[2]s/opt/openssl/lib "+
|
||||
"RABBITMQ_HOME=%[2]s/opt/rabbitmq "+
|
||||
"%[2]s/lib/ld-musl-x86_64.so.1 "+
|
||||
"%[2]s/opt/erlang/lib/erlang/bin/escript "+
|
||||
"%[2]s/opt/rabbitmq/escript/rabbitmqctl "+
|
||||
"--node %s %s",
|
||||
home, mq, node, args)
|
||||
if c.useContainer && c.container != "" {
|
||||
return fmt.Sprintf("sudo -i -u %s %s exec %s sh -c %s",
|
||||
c.ampUser, c.runtimeCLI(), c.container, shellQuote(inner))
|
||||
}
|
||||
return fmt.Sprintf("sudo -i -u %s sh -c %s", c.ampUser, shellQuote(inner))
|
||||
}
|
||||
|
||||
// EvalOnGameBroker runs an Erlang expression via rabbitmqctl eval against the
|
||||
// game broker. The RMQ server-commands publisher (rmq_commands.go) uses this to
|
||||
// fetch broker-side data — e.g. the ServerCommandsAuthToken — that must be
|
||||
// retrieved by an Erlang expression rather than a normal AMQP operation.
|
||||
func (c *ampControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
|
||||
cmd := c.buildRabbitmqctl("mq-game", "eval "+shellQuote(expr))
|
||||
out, err := exec.Exec(cmd + " 2>&1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// ── server settings (AMP Web API) ─────────────────────────────────────────────
|
||||
|
||||
// writeServerSettings applies fieldName→value updates through AMP's Web API
|
||||
// (Core/SetConfig). AMP persists them to its own config (GenericModule.kvp →
|
||||
// App.AppSettings) and regenerates UserEngine.ini / UserGame.ini with these
|
||||
// values on the next start. This is the only durable write path under AMP: a
|
||||
// direct INI edit is clobbered when AMP regenerates the files.
|
||||
//
|
||||
// Callers pass raw AMP FieldNames; the "Meta.GenericModule." node prefix is
|
||||
// added here. The write is fail-fast — a SetConfig error aborts the batch and
|
||||
// is returned naming the field, so partial application is possible on error.
|
||||
func (c *ampControl) writeServerSettings(_ context.Context, exec Executor, updates map[string]string) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if c.apiUser == "" || c.apiPass == "" {
|
||||
return fmt.Errorf("amp api credentials not configured — set amp_api_user and amp_api_pass to manage server settings under AMP")
|
||||
}
|
||||
client := newAMPAPIClient(exec, c.wrapInContainer, c.apiUser, c.apiPass, c.apiPort)
|
||||
for field, value := range updates {
|
||||
if err := client.setConfig("Meta.GenericModule."+field, value); err != nil {
|
||||
return fmt.Errorf("write server setting %s: %w", field, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readServerSettings reads the current value of each curated FieldName back from
|
||||
// AMP's live config (Core/GetConfig on node "Meta.GenericModule.<FieldName>").
|
||||
// AMP — not the INI files — is the source of truth for these settings, so this
|
||||
// lets the read path reflect values saved through the AMP API immediately,
|
||||
// without waiting for AMP to regenerate UserEngine.ini / UserGame.ini on the
|
||||
// next game restart. Implements serverSettingsReader. The session is reused
|
||||
// across fields (login happens once on the first GetConfig).
|
||||
func (c *ampControl) readServerSettings(_ context.Context, exec Executor, fields []string) (map[string]string, error) {
|
||||
if len(fields) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if c.apiUser == "" || c.apiPass == "" {
|
||||
return nil, fmt.Errorf("amp api credentials not configured — set amp_api_user and amp_api_pass to read server settings under AMP")
|
||||
}
|
||||
client := newAMPAPIClient(exec, c.wrapInContainer, c.apiUser, c.apiPass, c.apiPort)
|
||||
out := make(map[string]string, len(fields))
|
||||
for _, field := range fields {
|
||||
v, err := client.getConfig("Meta.GenericModule." + field)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read server setting %s: %w", field, err)
|
||||
}
|
||||
out[field] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── INI discovery ─────────────────────────────────────────────────────────────
|
||||
|
||||
// gameOverridePath returns the file AMP appends to UserGame.ini at boot:
|
||||
// UserOverrides.ini in the instance state dir. AMP owns UserGame.ini (written
|
||||
// from its dashboard), so dune-admin writes game-scoped settings here instead
|
||||
// of clobbering it. Keys in UserOverrides.ini take precedence at runtime.
|
||||
//
|
||||
// dir is the discovered INI directory. In the standard container layout that is
|
||||
// …/state/ue5-saved/UserSettings; UserOverrides.ini lives two levels up in
|
||||
// …/state. If dir does not match that layout the override file is placed
|
||||
// alongside it, so the method always returns a usable path.
|
||||
func (c *ampControl) gameOverridePath(dir string) string {
|
||||
d := strings.TrimRight(filepath.ToSlash(dir), "/")
|
||||
d = strings.TrimSuffix(d, "/ue5-saved/UserSettings")
|
||||
return d + "/UserOverrides.ini"
|
||||
}
|
||||
|
||||
// defaultINIDir returns the host directory holding the game's stock
|
||||
// DefaultGame.ini / DefaultEngine.ini so default discovery needs no
|
||||
// configuration under AMP. The game ships them in the extracted game-server
|
||||
// tree at <gameRoot>/extracted/game-server/home/dune/server/DuneSandbox/Config,
|
||||
// where gameRoot is the instance's duneawakening dir. gameRoot is recovered
|
||||
// from the discovered INI dir, then the configured server_ini_dir (both contain
|
||||
// "…/server/state"), and finally the conventional ampdata path for the
|
||||
// instance. Returns "" when none apply (e.g. native layout), letting the other
|
||||
// discovery strategies take over.
|
||||
func (c *ampControl) defaultINIDir(iniDir string) string {
|
||||
for _, base := range []string{iniDir, c.iniDir} {
|
||||
if i := strings.Index(base, "/server/state"); i > 0 {
|
||||
return base[:i] + ampDefaultsConfigSuffix
|
||||
}
|
||||
}
|
||||
if c.useContainer && c.instance != "" {
|
||||
user := c.ampUser
|
||||
if user == "" {
|
||||
user = "amp"
|
||||
}
|
||||
return fmt.Sprintf("/home/%s/.ampdata/instances/%s/duneawakening%s",
|
||||
user, c.instance, ampDefaultsConfigSuffix)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ampDefaultsConfigSuffix is the path, relative to the instance's duneawakening
|
||||
// gameRoot, to the directory containing the stock Default*.ini files.
|
||||
const ampDefaultsConfigSuffix = "/extracted/game-server/home/dune/server/DuneSandbox/Config"
|
||||
|
||||
func (c *ampControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
|
||||
base := c.iniDir
|
||||
if base == "" {
|
||||
if c.instance == "" {
|
||||
return "", fmt.Errorf("amp control requires server_ini_dir or amp_instance to derive an INI directory")
|
||||
}
|
||||
base = filepath.ToSlash(fmt.Sprintf(
|
||||
"/home/%s/.ampdata/instances/%s/duneawakening/server/state",
|
||||
c.ampUser, c.instance))
|
||||
}
|
||||
|
||||
// install.sh places UserGame.ini under ue5-saved/UserSettings/ inside the
|
||||
// state directory. Prefer that subdirectory over the base path — this probe
|
||||
// runs even when server_ini_dir is explicitly configured so the configured
|
||||
// path acts as a base directory rather than bypassing auto-detection.
|
||||
ue5Dir := base + "/ue5-saved/UserSettings"
|
||||
out, _ := exec.Exec(fmt.Sprintf(
|
||||
"test -f %s/UserGame.ini && echo yes || echo no",
|
||||
shellQuote(ue5Dir)))
|
||||
if strings.TrimSpace(out) == "yes" {
|
||||
return ue5Dir, nil
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// ReadDefaultINI returns the contents of DefaultGame.ini / DefaultEngine.ini.
|
||||
// In container mode this `find`s inside the game container; in native mode it
|
||||
// searches under the AMP install root. Returns "" when nothing matches so the
|
||||
// host-path traversal in handlers_server_settings.go can take over.
|
||||
func (c *ampControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
|
||||
if c.useContainer && c.container == "" {
|
||||
return ""
|
||||
}
|
||||
findRoot := "/"
|
||||
if !c.useContainer {
|
||||
// Native AMP installs put the game tree under /AMP/<game>/. Scan that
|
||||
// instead of /, which is faster and avoids permission noise.
|
||||
findRoot = "/AMP"
|
||||
}
|
||||
out, err := exec.Exec(c.wrapInContainer(fmt.Sprintf(
|
||||
"find %s -name %s -not -path '*/Saved/*' -not -path '*/saved/*' 2>/dev/null | head -1",
|
||||
findRoot, filename)))
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimSpace(out)
|
||||
out, err = exec.Exec(c.wrapInContainer(fmt.Sprintf("cat %s 2>/dev/null", path)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Battlegroup Director config (#147) ──────────────────────────────────────
|
||||
// director_config.ini is a HOST file ($STATE/director_config.ini, amp-owned
|
||||
// 0700) — NOT in the game container — so it's read/written on the host as the
|
||||
// AMP user. prestart.sh copies it into runtime/director-conf.d on every start,
|
||||
// so edits persist and apply on the next instance restart.
|
||||
|
||||
// directorConfigPath derives $STATE/director_config.ini from the resolved server
|
||||
// INI dir, which is $STATE/ue5-saved/UserSettings (so $STATE is two levels up).
|
||||
func (c *ampControl) directorConfigPath() (string, error) {
|
||||
dir, err := iniDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(filepath.Dir(filepath.Dir(dir)), "director_config.ini"), nil
|
||||
}
|
||||
|
||||
func (c *ampControl) readDirectorConfig(exec Executor) (string, string, error) {
|
||||
path, err := c.directorConfigPath()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
out, err := exec.Exec(fmt.Sprintf("sudo -i -u %s cat %s 2>/dev/null", shellQuote(c.ampUser), shellQuote(path)))
|
||||
if err != nil {
|
||||
return path, "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return path, "", fmt.Errorf("director config empty or unreadable at %s", path)
|
||||
}
|
||||
return path, out, nil
|
||||
}
|
||||
|
||||
func (c *ampControl) writeDirectorConfig(exec Executor, content string) (string, error) {
|
||||
path, err := c.directorConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := exec.WriteFile(path, strings.NewReader(content)); err != nil {
|
||||
return path, fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newAmpReadExec routes Core/Login and Core/GetConfig, returning canned
|
||||
// CurrentValue responses keyed by the requested node, and counts logins.
|
||||
func newAmpReadExec(t *testing.T, loginOK bool, values map[string]string, logins *int) *fnExecutor {
|
||||
return &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "Core/Login"):
|
||||
*logins++
|
||||
if !loginOK {
|
||||
return `{"success":false,"resultReason":"bad creds"}`, nil
|
||||
}
|
||||
return `{"success":true,"sessionID":"sess"}`, nil
|
||||
case strings.Contains(cmd, "Core/GetConfig"):
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
}
|
||||
decodePipedPayload(t, cmd, &p)
|
||||
b, _ := json.Marshal(values[p.Node])
|
||||
return `{"CurrentValue":` + string(b) + `}`, nil
|
||||
default:
|
||||
t.Fatalf("unexpected AMP API endpoint in cmd: %q", cmd)
|
||||
return "", nil
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAmpReadServerSettings_LoginOnceThenGetPerField(t *testing.T) {
|
||||
t.Parallel()
|
||||
logins := 0
|
||||
values := map[string]string{
|
||||
"Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier": "5.000000",
|
||||
"Meta.GenericModule.WorldTitle": "My Sietch",
|
||||
}
|
||||
exec := newAmpReadExec(t, true, values, &logins)
|
||||
|
||||
got, err := ampSettingsControl().readServerSettings(context.Background(), exec, []string{
|
||||
"ConsoleVariables.Dune.GlobalMiningOutputMultiplier",
|
||||
"WorldTitle",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("readServerSettings: %v", err)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Errorf("logins = %d, want 1 (session reused across reads)", logins)
|
||||
}
|
||||
if got["ConsoleVariables.Dune.GlobalMiningOutputMultiplier"] != "5.000000" {
|
||||
t.Errorf("mining = %q, want 5.000000 (got: %v)", got["ConsoleVariables.Dune.GlobalMiningOutputMultiplier"], got)
|
||||
}
|
||||
if got["WorldTitle"] != "My Sietch" {
|
||||
t.Errorf("title = %q, want My Sietch", got["WorldTitle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpReadServerSettings_EmptyFieldsIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
logins := 0
|
||||
exec := newAmpReadExec(t, true, nil, &logins)
|
||||
got, err := ampSettingsControl().readServerSettings(context.Background(), exec, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("readServerSettings: %v", err)
|
||||
}
|
||||
if len(got) != 0 || logins != 0 {
|
||||
t.Errorf("empty fields must not contact AMP: got=%v logins=%d", got, logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpReadServerSettings_MissingCredentialsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
exec := &fnExecutor{fn: func(string) (string, error) { called = true; return "", nil }}
|
||||
c := &Control{useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"} // no api creds
|
||||
_, err := c.readServerSettings(context.Background(), exec, []string{"WorldTitle"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when AMP API credentials are not configured")
|
||||
}
|
||||
if called {
|
||||
t.Error("must not contact the AMP API without credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpReadServerSettings_GetConfigFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
return "not json", nil // GetConfig garbage → decode error
|
||||
}}
|
||||
_, err := ampSettingsControl().readServerSettings(context.Background(), exec, []string{"WorldTitle"})
|
||||
if err == nil {
|
||||
t.Fatal("expected a GetConfig decode failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard that ampControl satisfies the optional reader interface the
|
||||
// settings GET handler routes on.
|
||||
func TestAmpControl_ImplementsServerSettingsReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ serverSettingsReader = (*ampControl)(nil)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAmpExecCommand_RestartContainerModeCyclesContainer verifies that under
|
||||
// containerised AMP, "restart" recycles the whole container rather than calling
|
||||
// ampinstmgr — proven in-game to be the only action that reaps the
|
||||
// DuneSandboxServer processes so settings actually apply.
|
||||
func TestAmpExecCommand_RestartContainerModeCyclesContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fakeAMPExecutor{out: "AMP_X"}
|
||||
c := &Control{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"}
|
||||
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "docker restart AMP_X") {
|
||||
t.Errorf("restart cmd = %q, want 'docker restart AMP_X'", exec.cmd)
|
||||
}
|
||||
if strings.Contains(exec.cmd, "ampinstmgr") {
|
||||
t.Errorf("container restart must not use ampinstmgr (does not reap game procs): %q", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpExecCommand_RestartContainerModeDefaultsPodman verifies the container
|
||||
// runtime defaults to podman when unset (backward compatible).
|
||||
func TestAmpExecCommand_RestartContainerModeDefaultsPodman(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fakeAMPExecutor{}
|
||||
c := &Control{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp"}
|
||||
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "podman restart AMP_X") {
|
||||
t.Errorf("restart cmd = %q, want 'podman restart AMP_X'", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpExecCommand_RestartNativeModeUsesAmpinstmgr verifies that without a
|
||||
// container (native AMP), restart keeps the ampinstmgr stop/start cycle.
|
||||
func TestAmpExecCommand_RestartNativeModeUsesAmpinstmgr(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fakeAMPExecutor{}
|
||||
c := &Control{instance: "Dune01", useContainer: false, ampUser: "amp"}
|
||||
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "ampinstmgr -q Dune01") || !strings.Contains(exec.cmd, "ampinstmgr -s Dune01") {
|
||||
t.Errorf("native restart cmd = %q, want ampinstmgr -q/-s cycle", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpExecCommand_RestartContainerModeMissingContainer verifies a clear error
|
||||
// when container mode is configured without a container name.
|
||||
func TestAmpExecCommand_RestartContainerModeMissingContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fakeAMPExecutor{}
|
||||
c := &Control{instance: "Dune01", useContainer: true, container: "", ampUser: "amp", containerRuntime: "docker"}
|
||||
if _, err := c.ExecCommand(context.Background(), exec, "restart"); err == nil {
|
||||
t.Fatal("expected error when container name missing in container mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpExecCommand_StartStopUnchanged guards that start/stop still use
|
||||
// ampinstmgr (only restart was proven to need container recycling).
|
||||
func TestAmpExecCommand_StartStopUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Control{instance: "Dune01", useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"}
|
||||
for _, tc := range []struct{ cmd, want string }{
|
||||
{"start", "ampinstmgr -s Dune01"},
|
||||
{"stop", "ampinstmgr -q Dune01"},
|
||||
} {
|
||||
exec := &fakeAMPExecutor{}
|
||||
if _, err := c.ExecCommand(context.Background(), exec, tc.cmd); err != nil {
|
||||
t.Fatalf("%s: %v", tc.cmd, err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, tc.want) {
|
||||
t.Errorf("%s cmd = %q, want %q", tc.cmd, exec.cmd, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ampSettingsExec routes Core/Login and Core/SetConfig, recording each
|
||||
// SetConfig's decoded node→value and counting logins.
|
||||
type ampSettingsCapture struct {
|
||||
logins int
|
||||
setCmds int
|
||||
nodes map[string]string
|
||||
loginOK bool
|
||||
setResp string
|
||||
setErr error
|
||||
}
|
||||
|
||||
func newAmpSettingsExec(t *testing.T, cap *ampSettingsCapture) *fnExecutor {
|
||||
cap.nodes = map[string]string{}
|
||||
return &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
switch {
|
||||
case strings.Contains(cmd, "Core/Login"):
|
||||
cap.logins++
|
||||
if !cap.loginOK {
|
||||
return `{"success":false,"resultReason":"bad creds"}`, nil
|
||||
}
|
||||
return `{"success":true,"sessionID":"sess"}`, nil
|
||||
case strings.Contains(cmd, "Core/SetConfig"):
|
||||
cap.setCmds++
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
decodePipedPayload(t, cmd, &p)
|
||||
cap.nodes[p.Node] = p.Value
|
||||
resp := cap.setResp
|
||||
if resp == "" {
|
||||
resp = `{"Status":true}`
|
||||
}
|
||||
return resp, cap.setErr
|
||||
default:
|
||||
t.Fatalf("unexpected AMP API endpoint in cmd: %q", cmd)
|
||||
return "", nil
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
func ampSettingsControl() *ampControl {
|
||||
return &Control{
|
||||
useContainer: true,
|
||||
container: "AMP_X",
|
||||
ampUser: "amp",
|
||||
containerRuntime: "docker",
|
||||
apiUser: "admin",
|
||||
apiPass: "pw",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_LoginOnceThenSetConfigPerField(t *testing.T) {
|
||||
t.Parallel()
|
||||
cap := &SettingsCapture{loginOK: true}
|
||||
exec := newAmpSettingsExec(t, cap)
|
||||
|
||||
err := ampSettingsControl().writeServerSettings(context.Background(), exec, map[string]string{
|
||||
"ConsoleVariables.Dune.GlobalMiningOutputMultiplier": "3.000000",
|
||||
"/Script/DuneSandbox.BuildingSettings.m_MaxNumLandclaimSegments": "6",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("writeServerSettings: %v", err)
|
||||
}
|
||||
if cap.logins != 1 {
|
||||
t.Errorf("logins = %d, want 1 (session must be reused across fields)", cap.logins)
|
||||
}
|
||||
if cap.setCmds != 2 {
|
||||
t.Errorf("SetConfig calls = %d, want 2", cap.setCmds)
|
||||
}
|
||||
// Node = Meta.GenericModule.<FieldName> verbatim (the proven AMP write path).
|
||||
if got := cap.nodes["Meta.GenericModule.ConsoleVariables.Dune.GlobalMiningOutputMultiplier"]; got != "3.000000" {
|
||||
t.Errorf("mining node value = %q, want 3.000000 (nodes: %v)", got, cap.nodes)
|
||||
}
|
||||
if got := cap.nodes["Meta.GenericModule./Script/DuneSandbox.BuildingSettings.m_MaxNumLandclaimSegments"]; got != "6" {
|
||||
t.Errorf("landclaim node value = %q, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_WrapsAPICallForContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
var loginCmd string
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "Core/Login") {
|
||||
loginCmd = cmd
|
||||
return `{"success":true,"sessionID":"s"}`, nil
|
||||
}
|
||||
return `{"Status":true}`, nil
|
||||
}}
|
||||
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
|
||||
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
|
||||
if err != nil {
|
||||
t.Fatalf("writeServerSettings: %v", err)
|
||||
}
|
||||
if !strings.Contains(loginCmd, "docker exec AMP_X") {
|
||||
t.Errorf("AMP API call must be wrapped for in-container exec, got: %q", loginCmd)
|
||||
}
|
||||
if !strings.Contains(loginCmd, "http://127.0.0.1:8081/API/Core/Login") {
|
||||
t.Errorf("AMP API call must hit the loopback ADS API, got: %q", loginCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_EmptyUpdatesIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
cap := &SettingsCapture{loginOK: true}
|
||||
exec := newAmpSettingsExec(t, cap)
|
||||
if err := ampSettingsControl().writeServerSettings(context.Background(), exec, map[string]string{}); err != nil {
|
||||
t.Fatalf("writeServerSettings: %v", err)
|
||||
}
|
||||
if cap.logins != 0 || cap.setCmds != 0 {
|
||||
t.Errorf("expected no API calls for empty updates, got logins=%d set=%d", cap.logins, cap.setCmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_MissingCredentialsErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
exec := &fnExecutor{fn: func(string) (string, error) { called = true; return "", nil }}
|
||||
c := &Control{useContainer: true, container: "AMP_X", ampUser: "amp", containerRuntime: "docker"} // no apiUser/apiPass
|
||||
err := c.writeServerSettings(context.Background(), exec,
|
||||
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when AMP API credentials are not configured")
|
||||
}
|
||||
if called {
|
||||
t.Error("must not contact the AMP API without credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_SetConfigFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
cap := &SettingsCapture{loginOK: true, setResp: `{"Status":false,"Reason":"No such node."}`}
|
||||
exec := newAmpSettingsExec(t, cap)
|
||||
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
|
||||
map[string]string{"ConsoleVariables.Bogus": "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected SetConfig failure to propagate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "No such node") {
|
||||
t.Errorf("error should surface AMP reason, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpWriteServerSettings_LoginFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
cap := &SettingsCapture{loginOK: false}
|
||||
exec := newAmpSettingsExec(t, cap)
|
||||
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
|
||||
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
|
||||
if err == nil {
|
||||
t.Fatal("expected login failure to abort the write")
|
||||
}
|
||||
if cap.setCmds != 0 {
|
||||
t.Error("must not SetConfig when login fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpControl_ImplementsServerSettingsWriter is a compile-time guard that
|
||||
// ampControl satisfies the optional interface the handler routes on.
|
||||
func TestAmpControl_ImplementsServerSettingsWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ serverSettingsWriter = (*ampControl)(nil)
|
||||
// Sanity: a transport error from the executor is wrapped, not swallowed.
|
||||
exec := &fnExecutor{fn: func(string) (string, error) { return "", errors.New("boom") }}
|
||||
err := ampSettingsControl().writeServerSettings(context.Background(), exec,
|
||||
map[string]string{"ConsoleVariables.Sandstorm.Enabled": "True"})
|
||||
if err == nil {
|
||||
t.Fatal("expected executor error to propagate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeAMPExecutor struct {
|
||||
out string
|
||||
err error
|
||||
cmd string
|
||||
}
|
||||
|
||||
// fnExecutor routes each Exec call through a provided function, allowing
|
||||
// tests to return different output for different commands.
|
||||
type fnExecutor struct {
|
||||
fn func(cmd string) (string, error)
|
||||
}
|
||||
|
||||
func (f *fnExecutor) Exec(cmd string) (string, error) { return f.fn(cmd) }
|
||||
func (f *fnExecutor) Stream(string) (<-chan string, func(), error) {
|
||||
return nil, func() {}, nil
|
||||
}
|
||||
func (f *fnExecutor) PipeToWriter(string, io.Writer) error { return nil }
|
||||
func (f *fnExecutor) WriteFile(string, io.Reader) error { return nil }
|
||||
func (f *fnExecutor) Dial(string, string) (net.Conn, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fnExecutor) Close() {}
|
||||
func (f *fnExecutor) Type() string { return "local" }
|
||||
|
||||
func (f *fakeAMPExecutor) Exec(cmd string) (string, error) {
|
||||
f.cmd = cmd
|
||||
return f.out, f.err
|
||||
}
|
||||
func (f *fakeAMPExecutor) Stream(string) (<-chan string, func(), error) {
|
||||
return nil, func() {}, nil
|
||||
}
|
||||
func (f *fakeAMPExecutor) PipeToWriter(string, io.Writer) error { return nil }
|
||||
func (f *fakeAMPExecutor) WriteFile(string, io.Reader) error { return nil }
|
||||
|
||||
// Dial mirrors localExecutor: a real direct dial. The director HTTP client now
|
||||
// routes through the executor, so GetStatus tests that hit a loopback httptest
|
||||
// server need a functioning Dial here.
|
||||
func (f *fakeAMPExecutor) Dial(network, addr string) (net.Conn, error) {
|
||||
return net.Dial(network, addr)
|
||||
}
|
||||
func (f *fakeAMPExecutor) Close() {}
|
||||
func (f *fakeAMPExecutor) Type() string { return "local" }
|
||||
|
||||
func TestParseAMPGameProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
line := "123 /srv/DuneSandboxServer-Linux-Shipping DuneSandbox HaggaBasinS -Port=7777 -PartitionIndex=3"
|
||||
got, ok := parseAMPGameProcess(line)
|
||||
if !ok {
|
||||
t.Fatalf("expected line to parse")
|
||||
}
|
||||
if got.pid != 123 || got.mapName != "HaggaBasinS" || got.port != 7777 || got.partition != 3 {
|
||||
t.Fatalf("unexpected parsed process: %+v", got)
|
||||
}
|
||||
|
||||
if _, ok := parseAMPGameProcess("garbage"); ok {
|
||||
t.Fatalf("expected malformed line to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmpPgDumpRestoreCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Control{
|
||||
container: "AMP_DuneTest01",
|
||||
ampUser: "amp",
|
||||
containerRuntime: "docker",
|
||||
pgBin: "/AMP/duneawakening/extracted/postgres/usr/local/bin",
|
||||
pgLib: "/pg/lib:/db/lib",
|
||||
}
|
||||
conn := dbConn{Host: "127.0.0.1", Port: 15432, User: "dune", Pass: "secret", Name: "dune"}
|
||||
|
||||
dump := c.pgDumpCommand(conn, "/home/test/db-backups/x.dump")
|
||||
for _, want := range []string{
|
||||
"sudo -i -u amp", "docker exec", "AMP_DuneTest01",
|
||||
"PGPASSWORD=", "LD_LIBRARY_PATH=", "/pg/lib:/db/lib",
|
||||
"pg_dump", "-Fc", "-h ", "127.0.0.1", "-p 15432", "-U ", "-d ",
|
||||
"> ", "/home/test/db-backups/x.dump",
|
||||
} {
|
||||
if !strings.Contains(dump, want) {
|
||||
t.Errorf("pgDumpCommand missing %q in:\n%s", want, dump)
|
||||
}
|
||||
}
|
||||
|
||||
restore := c.pgRestoreCommand(conn, "/home/test/db-backups/x.dump")
|
||||
for _, want := range []string{
|
||||
"docker exec -i", "pg_restore", "--clean", "--if-exists", "-d ",
|
||||
"< ", "/home/test/db-backups/x.dump",
|
||||
} {
|
||||
if !strings.Contains(restore, want) {
|
||||
t.Errorf("pgRestoreCommand missing %q in:\n%s", want, restore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcessAges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// `ps -o pid=,etimes=` emits two whitespace-separated columns (pid, elapsed
|
||||
// seconds), typically with leading padding. We only care about a pid→seconds
|
||||
// map; malformed lines are skipped, not fatal.
|
||||
out := " 123 3600\n456 90\nbad line here\n 789 0\n"
|
||||
got := parseProcessAges(out)
|
||||
|
||||
want := map[int]int{123: 3600, 456: 90, 789: 0}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("parseProcessAges len = %d, want %d (%+v)", len(got), len(want), got)
|
||||
}
|
||||
for pid, age := range want {
|
||||
if got[pid] != age {
|
||||
t.Fatalf("parseProcessAges[%d] = %d, want %d", pid, got[pid], age)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parseProcessAges("")) != 0 {
|
||||
t.Fatalf("expected empty input to yield empty map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGameProcesses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := &Control{}
|
||||
exec := &fakeAMPExecutor{
|
||||
out: "100 one DuneSandbox MapA -Port=7001 -PartitionIndex=1\nbad\n200 two DuneSandbox MapB -Port=7002",
|
||||
}
|
||||
procs, err := ctrl.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(procs) != 2 {
|
||||
t.Fatalf("expected 2 parsed processes, got %d", len(procs))
|
||||
}
|
||||
if procs[0].pid != 100 || procs[0].mapName != "MapA" || procs[0].port != 7001 || procs[0].partition != 1 {
|
||||
t.Fatalf("unexpected first process: %+v", procs[0])
|
||||
}
|
||||
if procs[1].pid != 200 || procs[1].mapName != "MapB" || procs[1].port != 7002 || procs[1].partition != 0 {
|
||||
t.Fatalf("unexpected second process: %+v", procs[1])
|
||||
}
|
||||
if exec.cmd == "" {
|
||||
t.Fatalf("expected process listing command to be executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGameProcesses_EmptyOnExecErrorWithoutOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := &Control{}
|
||||
exec := &fakeAMPExecutor{err: errors.New("ps failed")}
|
||||
procs, err := ctrl.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error when exec fails without output, got %v", err)
|
||||
}
|
||||
if len(procs) != 0 {
|
||||
t.Fatalf("expected empty process list, got %+v", procs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGameProcesses_NoContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := &Control{useContainer: false}
|
||||
exec := &fakeAMPExecutor{out: ""}
|
||||
_, err := ctrl.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(exec.cmd, " exec ") {
|
||||
t.Fatalf("expected no container wrapping for useContainer=false, got cmd: %q", exec.cmd)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "DuneSandboxServer") {
|
||||
t.Fatalf("expected ps command for DuneSandboxServer, got: %q", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGameProcesses_WithContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := &Control{
|
||||
useContainer: true,
|
||||
container: "AMP_Dune01",
|
||||
ampUser: "amp",
|
||||
containerRuntime: "podman",
|
||||
}
|
||||
exec := &fakeAMPExecutor{out: ""}
|
||||
_, err := ctrl.listGameProcesses(exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "podman exec AMP_Dune01") {
|
||||
t.Fatalf("expected podman exec wrapping, got cmd: %q", exec.cmd)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "DuneSandboxServer") {
|
||||
t.Fatalf("expected ps command inside wrapper, got: %q", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGameProcesses_WithContainer_MissingContainerName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := &Control{useContainer: true, container: "", ampUser: "amp"}
|
||||
exec := &fakeAMPExecutor{out: ""}
|
||||
_, err := ctrl.listGameProcesses(exec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when useContainer=true but container name is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpDiscoverIniDir_PrefersUE5SavedPath verifies that when
|
||||
// ue5-saved/UserSettings/UserGame.ini exists (install.sh layout),
|
||||
// DiscoverIniDir returns that sub-directory rather than the base state dir.
|
||||
func TestAmpDiscoverIniDir_PrefersUE5SavedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "ue5-saved/UserSettings") {
|
||||
return "yes\n", nil
|
||||
}
|
||||
return "no\n", nil
|
||||
}}
|
||||
ctrl := &Control{instance: "TestInst", ampUser: "amp"}
|
||||
|
||||
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := "/home/amp/.ampdata/instances/TestInst/duneawakening/server/state/ue5-saved/UserSettings"
|
||||
if dir != want {
|
||||
t.Errorf("got %q, want %q", dir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpDiscoverIniDir_FallsBackToState verifies that when ue5-saved/UserSettings
|
||||
// does not have a UserGame.ini, DiscoverIniDir returns the base state directory.
|
||||
func TestAmpDiscoverIniDir_FallsBackToState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
return "no\n", nil
|
||||
}}
|
||||
ctrl := &Control{instance: "TestInst", ampUser: "amp"}
|
||||
|
||||
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := "/home/amp/.ampdata/instances/TestInst/duneawakening/server/state"
|
||||
if dir != want {
|
||||
t.Errorf("got %q, want %q", dir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpDiscoverIniDir_ExplicitConfig_PrefersUE5SubDir verifies that when
|
||||
// server_ini_dir is set to a base state directory and ue5-saved/UserSettings
|
||||
// contains UserGame.ini, DiscoverIniDir returns the ue5-saved subdirectory.
|
||||
func TestAmpDiscoverIniDir_ExplicitConfig_PrefersUE5SubDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "ue5-saved/UserSettings") {
|
||||
return "yes\n", nil
|
||||
}
|
||||
return "no\n", nil
|
||||
}}
|
||||
ctrl := &Control{iniDir: "/custom/state"}
|
||||
|
||||
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := "/custom/state/ue5-saved/UserSettings"
|
||||
if dir != want {
|
||||
t.Errorf("got %q, want %q", dir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpDiscoverIniDir_ExplicitConfig_FallsBackToConfigured verifies that when
|
||||
// server_ini_dir is set and ue5-saved/UserSettings has no UserGame.ini, the
|
||||
// configured path is returned as-is.
|
||||
func TestAmpDiscoverIniDir_ExplicitConfig_FallsBackToConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exec := &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
return "no\n", nil
|
||||
}}
|
||||
ctrl := &Control{iniDir: "/custom/ini/dir"}
|
||||
|
||||
dir, err := ctrl.DiscoverIniDir(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if dir != "/custom/ini/dir" {
|
||||
t.Errorf("got %q, want %q", dir, "/custom/ini/dir")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpRuntimeCLI_DefaultsToPodman verifies the container-runtime selector
|
||||
// defaults to podman (backward compatible) and honours an explicit docker.
|
||||
func TestAmpRuntimeCLI_DefaultsToPodman(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := (&Control{}).runtimeCLI(); got != "podman" {
|
||||
t.Errorf("empty containerRuntime: got %q, want podman", got)
|
||||
}
|
||||
if got := (&Control{containerRuntime: "docker"}).runtimeCLI(); got != "docker" {
|
||||
t.Errorf("explicit docker: got %q, want docker", got)
|
||||
}
|
||||
if got := (&Control{containerRuntime: "podman"}).runtimeCLI(); got != "podman" {
|
||||
t.Errorf("explicit podman: got %q, want podman", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpWrapInContainer_RuntimeSelection verifies wrapInContainer emits the
|
||||
// configured container runtime as `<rt> exec` in container mode, defaulting to
|
||||
// podman when unset.
|
||||
func TestAmpWrapInContainer_RuntimeSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
runtime string
|
||||
wantSub string
|
||||
notWantSub string
|
||||
}{
|
||||
{"default empty -> podman", "", "podman exec AMP_X", "docker"},
|
||||
{"explicit podman", "podman", "podman exec AMP_X", "docker"},
|
||||
{"docker", "docker", "docker exec AMP_X", "podman"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Control{ampUser: "amp", container: "AMP_X", useContainer: true, containerRuntime: tt.runtime}
|
||||
got := c.wrapInContainer("ls /tmp")
|
||||
if !strings.Contains(got, tt.wantSub) {
|
||||
t.Errorf("wrapInContainer = %q, want substring %q", got, tt.wantSub)
|
||||
}
|
||||
if tt.notWantSub != "" && strings.Contains(got, tt.notWantSub) {
|
||||
t.Errorf("wrapInContainer = %q, must not contain %q", got, tt.notWantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpWrapInContainer_NativeIgnoresRuntime verifies native mode never wraps
|
||||
// in a container runtime even when one is configured.
|
||||
func TestAmpWrapInContainer_NativeIgnoresRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Control{ampUser: "amp", useContainer: false, containerRuntime: "docker"}
|
||||
got := c.wrapInContainer("ls")
|
||||
if strings.Contains(got, "docker") || strings.Contains(got, "podman") || strings.Contains(got, "exec") {
|
||||
t.Errorf("native wrapInContainer must not reference a container runtime: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpBuildRabbitmqctl_RuntimeSelection verifies the rabbitmqctl trampoline
|
||||
// is wrapped in the configured container runtime.
|
||||
func TestAmpBuildRabbitmqctl_RuntimeSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &Control{ampUser: "amp", container: "AMP_X", useContainer: true, containerRuntime: "docker"}
|
||||
cmd := c.buildRabbitmqctl("mq-admin", "status")
|
||||
if !strings.Contains(cmd, "docker exec AMP_X") {
|
||||
t.Errorf("buildRabbitmqctl = %q, want 'docker exec AMP_X'", cmd)
|
||||
}
|
||||
if strings.Contains(cmd, "podman") {
|
||||
t.Errorf("buildRabbitmqctl must not reference podman when runtime=docker: %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpListLogSources_UsesConfiguredRuntime is an end-to-end check that the
|
||||
// runtime selection flows through a real ControlPlane method to the executor.
|
||||
func TestAmpListLogSources_UsesConfiguredRuntime(t *testing.T) {
|
||||
t.Parallel()
|
||||
exec := &fakeAMPExecutor{out: "game.log\nserver.log\n"}
|
||||
c := &Control{container: "AMP_X", ampUser: "amp", logPath: "/AMP/duneawakening/logs", useContainer: true, containerRuntime: "docker"}
|
||||
if _, err := c.ListLogSources(context.Background(), exec); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(exec.cmd, "docker exec AMP_X") {
|
||||
t.Errorf("ListLogSources cmd = %q, want 'docker exec AMP_X'", exec.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// directorBattlegroupJSON mirrors the structurally-relevant subset of the
|
||||
// Battlegroup Director's /v0/battlegroup response: a single-server map, a
|
||||
// dimension map (sharded under serversByDimension), and an instanced map.
|
||||
// Each leaf server carries a "partition" object with partitionId,
|
||||
// dimensionIndex and label — the fields GetStatus enriches rows with.
|
||||
const directorBattlegroupJSON = `{
|
||||
"bgTitle": "Test BG",
|
||||
"singleServerMaps": {
|
||||
"Overmap": {
|
||||
"cfg": {"playerHardCap": 60},
|
||||
"gamePort": 7794,
|
||||
"numPlayersInGame": 5,
|
||||
"numPlayersInQueue": 2,
|
||||
"serverPlayerHardCap": -1,
|
||||
"partition": {"partitionId": 2, "dimensionIndex": 0, "label": "Overland"}
|
||||
}
|
||||
},
|
||||
"dimensionMaps": {
|
||||
"DeepDesert_1": {
|
||||
"cfg": null,
|
||||
"webOverrideCfg": null,
|
||||
"serversByDimension": {
|
||||
"0": {"gamePort": 7799, "numPlayersInGame": 2, "numPlayersInQueue": 0, "serverPlayerHardCap": -1, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 8, "dimensionIndex": 0, "label": "DeepDesert_0"}},
|
||||
"1": {"gamePort": 7800, "numPlayersInGame": 0, "numPlayersInQueue": 1, "serverPlayerHardCap": 40, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 143, "dimensionIndex": 1, "label": "DeepDesert_1"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"instancedMaps": {
|
||||
"SH_Arrakeen": {
|
||||
"instances": {
|
||||
"inst-a": {"gamePort": 7792, "numPlayersInGame": 7, "numPlayersInQueue": null, "serverPlayerHardCap": -1, "cfg": {"playerHardCap": 80}, "partition": {"partitionId": 3, "dimensionIndex": 0, "label": "Arrakeen_0"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// psLineFor builds a synthetic `ps`-style game-server line for a map/port/partition.
|
||||
func psLineFor(pid int, mapName string, port, partition int) string {
|
||||
return fmt.Sprintf(
|
||||
"%d /x/DuneSandboxServer-Linux-Shipping DuneSandbox %s -Port=%d -PartitionIndex=%d",
|
||||
pid, mapName, port, partition)
|
||||
}
|
||||
|
||||
// TestAmpGetStatus_EnrichesDimensionFromDirector verifies that GetStatus joins
|
||||
// each ps-derived partition to the director's dimensionIndex and label, walking
|
||||
// single-server, dimension, and instanced map categories alike.
|
||||
func TestAmpGetStatus_EnrichesDimensionFromDirector(t *testing.T) {
|
||||
// Not parallel: GetStatus reads the globalDB package global, which other
|
||||
// parallel tests mutate.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v0/battlegroup" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, directorBattlegroupJSON)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
psOut := strings.Join([]string{
|
||||
psLineFor(1001, "Overmap", 7794, 2),
|
||||
psLineFor(1002, "DeepDesert_1", 7799, 8),
|
||||
psLineFor(1003, "DeepDesert_1", 7800, 143),
|
||||
psLineFor(1004, "SH_Arrakeen", 7792, 3),
|
||||
}, "\n")
|
||||
|
||||
c := &Control{container: "AMP_X", useContainer: false, directorURL: srv.URL}
|
||||
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus: %v", err)
|
||||
}
|
||||
|
||||
want := map[int]struct {
|
||||
dim int
|
||||
sietch string
|
||||
players int
|
||||
cap int
|
||||
queue int
|
||||
}{
|
||||
2: {0, "Overland", 5, 60, 2}, // serverPlayerHardCap -1 → cfg cap 60
|
||||
8: {0, "DeepDesert_0", 2, 80, 0}, // cfg cap 80
|
||||
143: {1, "DeepDesert_1", 0, 40, 1}, // serverPlayerHardCap 40 overrides cfg 80
|
||||
3: {0, "Arrakeen_0", 7, 80, 0}, // queue null → 0
|
||||
}
|
||||
if len(status.Servers) != len(want) {
|
||||
t.Fatalf("got %d servers, want %d", len(status.Servers), len(want))
|
||||
}
|
||||
for _, row := range status.Servers {
|
||||
exp, ok := want[row.Partition]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected partition %d", row.Partition)
|
||||
}
|
||||
if row.Dimension != exp.dim {
|
||||
t.Errorf("partition %d: dimension = %d, want %d", row.Partition, row.Dimension, exp.dim)
|
||||
}
|
||||
if row.Sietch != exp.sietch {
|
||||
t.Errorf("partition %d: sietch = %q, want %q", row.Partition, row.Sietch, exp.sietch)
|
||||
}
|
||||
if row.Players != exp.players {
|
||||
t.Errorf("partition %d: players = %d, want %d", row.Partition, row.Players, exp.players)
|
||||
}
|
||||
if row.PlayerHardCap != exp.cap {
|
||||
t.Errorf("partition %d: playerHardCap = %d, want %d", row.Partition, row.PlayerHardCap, exp.cap)
|
||||
}
|
||||
if row.Queue != exp.queue {
|
||||
t.Errorf("partition %d: queue = %d, want %d", row.Partition, row.Queue, exp.queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpGetStatus_NoDirectorURL verifies that with no director configured,
|
||||
// GetStatus still returns rows from ps with dimension left at zero (current
|
||||
// behaviour) and makes no HTTP call.
|
||||
func TestAmpGetStatus_NoDirectorURL(t *testing.T) {
|
||||
// Not parallel: GetStatus reads the globalDB package global.
|
||||
psOut := psLineFor(2001, "Overmap", 7794, 2)
|
||||
c := &Control{container: "AMP_X", useContainer: false} // directorURL empty
|
||||
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus: %v", err)
|
||||
}
|
||||
if len(status.Servers) != 1 {
|
||||
t.Fatalf("got %d servers, want 1", len(status.Servers))
|
||||
}
|
||||
if status.Servers[0].Partition != 2 || status.Servers[0].Dimension != 0 {
|
||||
t.Errorf("row = %+v, want partition 2 dimension 0", status.Servers[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpGetStatus_DirectorUnreachable_FallsBack verifies that a transport
|
||||
// failure to the director is non-fatal: rows are still returned from ps with
|
||||
// dimension left at zero.
|
||||
func TestAmpGetStatus_DirectorUnreachable_FallsBack(t *testing.T) {
|
||||
// Not parallel: GetStatus reads the globalDB package global.
|
||||
// Closed server: take a listener address then immediately close it.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
deadURL := srv.URL
|
||||
srv.Close()
|
||||
|
||||
psOut := psLineFor(3001, "Overmap", 7794, 2)
|
||||
c := &Control{container: "AMP_X", useContainer: false, directorURL: deadURL}
|
||||
status, err := c.GetStatus(context.Background(), &fakeAMPExecutor{out: psOut})
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus should not fail on director error: %v", err)
|
||||
}
|
||||
if len(status.Servers) != 1 || status.Servers[0].Dimension != 0 {
|
||||
t.Fatalf("expected 1 row with dimension 0, got %+v", status.Servers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectPartitions_WalksNestedAndIgnoresNull verifies the recursive walker
|
||||
// records partitions from arbitrary nesting and ignores null/non-object
|
||||
// "partition" values.
|
||||
func TestCollectPartitions_WalksNestedAndIgnoresNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(directorBattlegroupJSON), &raw); err != nil {
|
||||
t.Fatalf("unmarshal sample: %v", err)
|
||||
}
|
||||
out := map[int]partitionMeta{}
|
||||
collectPartitions(raw, out)
|
||||
|
||||
for id, want := range map[int]partitionMeta{
|
||||
2: {dimension: 0, label: "Overland", players: 5, playerHardCap: 60, queue: 2},
|
||||
8: {dimension: 0, label: "DeepDesert_0", players: 2, playerHardCap: 80, queue: 0},
|
||||
143: {dimension: 1, label: "DeepDesert_1", players: 0, playerHardCap: 40, queue: 1},
|
||||
3: {dimension: 0, label: "Arrakeen_0", players: 7, playerHardCap: 80, queue: 0},
|
||||
} {
|
||||
got, ok := out[id]
|
||||
if !ok {
|
||||
t.Errorf("partition %d missing", id)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("partition %d = %+v, want %+v", id, got, want)
|
||||
}
|
||||
}
|
||||
if len(out) != 4 {
|
||||
t.Errorf("collected %d partitions, want 4: %+v", len(out), out)
|
||||
}
|
||||
}
|
||||
148
docs/reference-repos/icehunter/cmd/dune-admin/control_docker.go
Normal file
148
docs/reference-repos/icehunter/cmd/dune-admin/control_docker.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dockerControl implements ControlPlane using the Docker CLI.
|
||||
// It requires configured container names and expects the Docker socket to be
|
||||
// accessible by the executor (locally or via SSH to a Docker host).
|
||||
type dockerControl struct {
|
||||
gameserver string // container name for the game server
|
||||
brokerGame string // container name for mq-game broker
|
||||
brokerAdmin string // container name for mq-admin broker
|
||||
}
|
||||
|
||||
func (c *dockerControl) Name() string { return "docker" }
|
||||
|
||||
func (c *dockerControl) GetStatus(_ context.Context, exec Executor) (*BattlegroupStatus, error) {
|
||||
if c.gameserver == "" {
|
||||
return nil, errNotSupported("docker", "GetStatus (docker_gameserver not configured)")
|
||||
}
|
||||
out, err := exec.Exec(fmt.Sprintf(
|
||||
"docker inspect --format '{{.State.Status}}' %s 2>&1", c.gameserver))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker inspect: %w", err)
|
||||
}
|
||||
status := strings.TrimSpace(out)
|
||||
return &BattlegroupStatus{
|
||||
Name: c.gameserver,
|
||||
Title: c.gameserver,
|
||||
Phase: status,
|
||||
Servers: []ServerRow{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *dockerControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
|
||||
if c.gameserver == "" {
|
||||
return "", errNotSupported("docker", "ExecCommand (docker_gameserver not configured)")
|
||||
}
|
||||
var dockerCmd string
|
||||
switch cmd {
|
||||
case "start":
|
||||
dockerCmd = fmt.Sprintf("docker start %s 2>&1", c.gameserver)
|
||||
case "stop":
|
||||
dockerCmd = fmt.Sprintf("docker stop %s 2>&1", c.gameserver)
|
||||
case "restart":
|
||||
dockerCmd = fmt.Sprintf("docker restart %s 2>&1", c.gameserver)
|
||||
default:
|
||||
return "", fmt.Errorf("docker control does not support %q", cmd)
|
||||
}
|
||||
out, err := exec.Exec(dockerCmd)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("docker %s: %w — %s", cmd, err, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dockerControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
|
||||
out, err := exec.Exec("docker ps --format '{{.Names}}\\t{{.Status}}' 2>&1")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
var procs []ProcessInfo
|
||||
for _, line := range splitLines(out) {
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) < 1 || parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
status := ""
|
||||
if len(parts) == 2 {
|
||||
status = parts[1]
|
||||
}
|
||||
procs = append(procs, ProcessInfo{Name: parts[0], Status: status})
|
||||
}
|
||||
return procs, "docker", nil
|
||||
}
|
||||
|
||||
func (c *dockerControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
|
||||
out, err := exec.Exec("docker ps --format '{{.Names}}' 2>&1")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
var sources []LogSource
|
||||
for _, line := range splitLines(out) {
|
||||
name := strings.TrimSpace(line)
|
||||
if name != "" {
|
||||
sources = append(sources, LogSource{Namespace: "docker", Name: name})
|
||||
}
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func (c *dockerControl) StreamLog(_ context.Context, exec Executor, _, name string) (<-chan string, func(), error) {
|
||||
return exec.Stream(fmt.Sprintf("docker logs -f %s 2>&1", name))
|
||||
}
|
||||
|
||||
func (c *dockerControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
|
||||
if c.gameserver == "" {
|
||||
return "", "", errNotSupported("docker", "CaptureJWT (docker_gameserver not configured)")
|
||||
}
|
||||
existingToken, err := exec.Exec(fmt.Sprintf(
|
||||
"docker exec %s env 2>/dev/null | grep FuncomLiveServices__ServiceAuthToken | cut -d= -f2-",
|
||||
c.gameserver))
|
||||
if err != nil || strings.TrimSpace(existingToken) == "" {
|
||||
return "", "", fmt.Errorf("read ServiceAuthToken from container: %w", err)
|
||||
}
|
||||
return buildCaptureJWT(strings.TrimSpace(existingToken))
|
||||
}
|
||||
|
||||
func (c *dockerControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
|
||||
if c.brokerGame == "" {
|
||||
return "", errNotSupported("docker", "EvalOnGameBroker (docker_broker_game not configured)")
|
||||
}
|
||||
out, err := exec.Exec(fmt.Sprintf(
|
||||
"docker exec %s rabbitmqctl eval %s 2>&1",
|
||||
c.brokerGame, shellQuote(expr)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (c *dockerControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
|
||||
if c.gameserver == "" {
|
||||
return ""
|
||||
}
|
||||
pathOut, err := exec.Exec(fmt.Sprintf(
|
||||
"docker exec %s find / -name %s -not -path '*/Saved/*' -not -path '*/proc/*' -not -path '*/sys/*' -not -path '*/dev/*' 2>/dev/null | head -1",
|
||||
c.gameserver, shellQuote(filename)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
p := strings.TrimSpace(pathOut)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
content, err := exec.Exec(fmt.Sprintf("docker exec %s cat %s 2>/dev/null", c.gameserver, shellQuote(p)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (c *dockerControl) DiscoverIniDir(_ context.Context, _ Executor) (string, error) {
|
||||
return "", fmt.Errorf("docker control plane requires server_ini_dir to be set in config")
|
||||
}
|
||||
345
docs/reference-repos/icehunter/cmd/dune-admin/control_kubectl.go
Normal file
345
docs/reference-repos/icehunter/cmd/dune-admin/control_kubectl.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// kubectlControl implements ControlPlane using kubectl commands.
|
||||
// Commands run through the provided Executor (local or SSH-tunneled).
|
||||
type kubectlControl struct {
|
||||
namespace string // e.g. "funcom-seabass-mybg"
|
||||
}
|
||||
|
||||
func (c *kubectlControl) Name() string { return "kubectl" }
|
||||
|
||||
func kubectlCLI(exec Executor) string {
|
||||
if exec != nil && exec.Type() == "local" {
|
||||
return "kubectl"
|
||||
}
|
||||
return "sudo kubectl"
|
||||
}
|
||||
|
||||
func (c *kubectlControl) bgName() string {
|
||||
return strings.TrimPrefix(c.namespace, "funcom-seabass-")
|
||||
}
|
||||
|
||||
func (c *kubectlControl) GetStatus(ctx context.Context, exec Executor) (*BattlegroupStatus, error) {
|
||||
bgName := c.bgName()
|
||||
kctl := kubectlCLI(exec)
|
||||
|
||||
bgOut, _ := exec.Exec(fmt.Sprintf(
|
||||
`%s get battlegroups -n %s -o jsonpath="{.items[0].spec.title}|{.items[0].status.phase}|{.items[0].status.database.phase}" 2>/dev/null`,
|
||||
kctl, c.namespace))
|
||||
|
||||
bgParts := strings.SplitN(strings.TrimSpace(bgOut), "|", 3)
|
||||
|
||||
ssOut, _ := exec.Exec(fmt.Sprintf(
|
||||
"%s get serverstats -n %s -o jsonpath='{range .items[*]}{.spec.area.map}|{.spec.area.sietch}|{.spec.area.dimension}|{.spec.area.partition}|{.status.runtime.gamePhase}|{.status.runtime.ready}|{.status.runtime.players}{\"\\n\"}{end}' 2>/dev/null",
|
||||
kctl, c.namespace))
|
||||
|
||||
var servers []ServerRow
|
||||
for _, line := range strings.Split(strings.TrimSpace(ssOut), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
p := strings.SplitN(line, "|", 7)
|
||||
if len(p) < 7 {
|
||||
continue
|
||||
}
|
||||
dim, _ := strconv.Atoi(p[2])
|
||||
part, _ := strconv.Atoi(p[3])
|
||||
players, _ := strconv.Atoi(p[6])
|
||||
servers = append(servers, ServerRow{
|
||||
Map: p[0],
|
||||
Sietch: p[1],
|
||||
Dimension: dim,
|
||||
Partition: part,
|
||||
Phase: p[4],
|
||||
Ready: p[5] == "true",
|
||||
Players: players,
|
||||
})
|
||||
}
|
||||
sort.Slice(servers, func(i, j int) bool { return servers[i].Map < servers[j].Map })
|
||||
if servers == nil {
|
||||
servers = []ServerRow{}
|
||||
}
|
||||
|
||||
return &BattlegroupStatus{
|
||||
Name: bgName,
|
||||
Title: safeIdx(bgParts, 0),
|
||||
Phase: safeIdx(bgParts, 1),
|
||||
Database: safeIdx(bgParts, 2),
|
||||
Servers: servers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *kubectlControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
|
||||
bgName := c.bgName()
|
||||
ns := c.namespace
|
||||
kctl := kubectlCLI(exec)
|
||||
|
||||
switch cmd {
|
||||
case "start":
|
||||
return exec.Exec(fmt.Sprintf(
|
||||
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":false}}' 2>&1 && echo "Battlegroup starting"`,
|
||||
kctl, bgName, ns))
|
||||
case "stop":
|
||||
return exec.Exec(fmt.Sprintf(
|
||||
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":true}}' 2>&1 && echo "Battlegroup stopping"`,
|
||||
kctl, bgName, ns))
|
||||
case "restart":
|
||||
return exec.Exec(fmt.Sprintf(
|
||||
`%s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":true}}' 2>/dev/null && sleep 5 && %s patch battlegroup %s -n %s --type=merge -p '{"spec":{"stop":false}}' 2>/dev/null && echo "Battlegroup restarting"`,
|
||||
kctl, bgName, ns, kctl, bgName, ns))
|
||||
default:
|
||||
// TODO: NEVER run battlegroup.sh with sudo. The script manages files under
|
||||
// /home/dune/.dune/ and runs as the dune user. Using sudo corrupts ownership
|
||||
// of those files (bin/, symlinks, etc.) and breaks all subsequent battlegroup
|
||||
// commands until permissions are manually repaired. kubectl commands above
|
||||
// legitimately need sudo; battlegroup.sh does NOT.
|
||||
return exec.Exec(fmt.Sprintf("~/.dune/download/scripts/battlegroup.sh %s 2>&1", cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubectlControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
|
||||
kctl := kubectlCLI(exec)
|
||||
out, err := exec.Exec(fmt.Sprintf("%s get pods -n %s --no-headers 2>&1", kctl, c.namespace))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("kubectl: %w", err)
|
||||
}
|
||||
var procs []ProcessInfo
|
||||
for _, line := range splitLines(out) {
|
||||
if line != "" {
|
||||
procs = append(procs, ProcessInfo{Name: line, Namespace: c.namespace})
|
||||
}
|
||||
}
|
||||
return procs, c.namespace, nil
|
||||
}
|
||||
|
||||
func (c *kubectlControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
|
||||
kctl := kubectlCLI(exec)
|
||||
out, err := exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>&1", kctl, c.namespace))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kubectl: %w", err)
|
||||
}
|
||||
out2, _ := exec.Exec(
|
||||
fmt.Sprintf("%s get pods -n funcom-operators --no-headers -o custom-columns=NAME:.metadata.name 2>&1", kctl))
|
||||
|
||||
var sources []LogSource
|
||||
for _, line := range splitLines(out) {
|
||||
name := strings.TrimSpace(line)
|
||||
if name != "" && !strings.Contains(name, "db-dbdepl") {
|
||||
sources = append(sources, LogSource{Namespace: c.namespace, Name: name})
|
||||
}
|
||||
}
|
||||
for _, line := range splitLines(out2) {
|
||||
name := strings.TrimSpace(line)
|
||||
if name != "" {
|
||||
sources = append(sources, LogSource{Namespace: "funcom-operators", Name: name})
|
||||
}
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func (c *kubectlControl) StreamLog(_ context.Context, exec Executor, ns, name string) (<-chan string, func(), error) {
|
||||
kctl := kubectlCLI(exec)
|
||||
cmd := fmt.Sprintf("%s logs -f -n %s %s 2>&1", kctl, ns, name)
|
||||
return exec.Stream(cmd)
|
||||
}
|
||||
|
||||
func (c *kubectlControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
|
||||
kctl := kubectlCLI(exec)
|
||||
pod, err := exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
|
||||
kctl, c.namespace))
|
||||
if err != nil || strings.TrimSpace(pod) == "" {
|
||||
return "", "", fmt.Errorf("find bgd pod: %w", err)
|
||||
}
|
||||
pod = strings.TrimSpace(pod)
|
||||
|
||||
existingToken, err := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- env 2>/dev/null | grep FuncomLiveServices__ServiceAuthToken | cut -d= -f2-",
|
||||
kctl, c.namespace, pod))
|
||||
if err != nil || strings.TrimSpace(existingToken) == "" {
|
||||
return "", "", fmt.Errorf("read ServiceAuthToken: %w", err)
|
||||
}
|
||||
return buildCaptureJWT(strings.TrimSpace(existingToken))
|
||||
}
|
||||
|
||||
func (c *kubectlControl) EvalOnGameBroker(_ context.Context, exec Executor, expr string) (string, error) {
|
||||
if c.namespace == "" {
|
||||
return "", errNotSupported("kubectl", "EvalOnGameBroker (namespace not configured)")
|
||||
}
|
||||
kctl := kubectlCLI(exec)
|
||||
pod, err := exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep mq-game | head -1",
|
||||
kctl, c.namespace))
|
||||
if err != nil || strings.TrimSpace(pod) == "" {
|
||||
return "", fmt.Errorf("could not find mq-game pod in namespace %s", c.namespace)
|
||||
}
|
||||
pod = strings.TrimSpace(pod)
|
||||
out, err := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- rabbitmqctl eval %s 2>&1",
|
||||
kctl, c.namespace, pod, shellQuote(expr)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
// ── kubectl-specific discovery helpers (used by setup wizard) ─────────────────
|
||||
|
||||
// discoverDBPod uses kubectl to find the DB pod, returning namespace, name, and pod IP.
|
||||
func discoverDBPod(exec Executor) (ns, pod, podIP string, err error) {
|
||||
kctl := kubectlCLI(exec)
|
||||
out, err := exec.Exec(
|
||||
fmt.Sprintf(`%s get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.podIP}{"\n"}{end}' 2>/dev/null | grep db-dbdepl-sts | head -1`, kctl))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("kubectl: %w", err)
|
||||
}
|
||||
parts := strings.Fields(strings.TrimSpace(out))
|
||||
if len(parts) < 3 {
|
||||
return "", "", "", fmt.Errorf("database pod not found in cluster")
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
|
||||
// battlegroupFromPod extracts the battlegroup name from a pod name.
|
||||
// Pod naming pattern: <battlegroup>-db-dbdepl-sts-<N>
|
||||
func battlegroupFromPod(pod string) string {
|
||||
const suffix = "-db-dbdepl-sts-"
|
||||
if idx := strings.LastIndex(pod, suffix); idx != -1 {
|
||||
return pod[:idx]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// listBattlegroups returns battlegroup names via the battlegroup CLI.
|
||||
func listBattlegroups(exec Executor) []string {
|
||||
out, err := exec.Exec("bash -lc 'battlegroup list' 2>/dev/null")
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "- ") {
|
||||
if name := strings.TrimSpace(line[2:]); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// extractPasswordFromYAML reads DB credentials from a battlegroup YAML on the executor.
|
||||
func extractPasswordFromYAML(exec Executor, filePath string) (user, pass string) {
|
||||
out, err := exec.Exec(fmt.Sprintf("cat %s 2>/dev/null", shellQuote(filePath)))
|
||||
if err != nil || len(out) == 0 {
|
||||
out, err = exec.Exec(fmt.Sprintf("bash -c 'cat %s'", filePath))
|
||||
if err != nil || len(out) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
return parseDeploymentCredentials([]byte(out))
|
||||
}
|
||||
|
||||
// tryReadINIFromPod attempts to read filename from a specific pod by trying
|
||||
// well-known Config paths first, then falling back to a find-based search.
|
||||
func tryReadINIFromPod(exec Executor, kctl, namespace, pod, filename string) string {
|
||||
candidates := []string{
|
||||
"/DuneSandbox/Config/" + filename,
|
||||
"/home/dune/server/DuneSandbox/Config/" + filename,
|
||||
"/home/dune/DuneSandbox/Config/" + filename,
|
||||
"/game/DuneSandbox/Config/" + filename,
|
||||
}
|
||||
for _, p := range candidates {
|
||||
content, err := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- cat %s 2>/dev/null",
|
||||
kctl, namespace, pod, shellQuote(p)))
|
||||
if err == nil && len(strings.TrimSpace(content)) > 0 {
|
||||
log.Printf("[default-ini] kubectl: read %s (%d bytes) from pod %s", p, len(content), pod)
|
||||
return content
|
||||
}
|
||||
}
|
||||
pathOut, _ := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- find -L /DuneSandbox /home /app /game -name %s -not -path '*/Saved/*' 2>/dev/null | head -1",
|
||||
kctl, namespace, pod, shellQuote(filename)))
|
||||
if p := strings.TrimSpace(pathOut); p != "" {
|
||||
content, err := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- cat %s 2>/dev/null",
|
||||
kctl, namespace, pod, shellQuote(p)))
|
||||
if err == nil && len(strings.TrimSpace(content)) > 0 {
|
||||
log.Printf("[default-ini] kubectl: read %s (%d bytes) from pod %s", p, len(content), pod)
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *kubectlControl) ReadDefaultINI(_ context.Context, exec Executor, filename string) string {
|
||||
if c.namespace == "" {
|
||||
return ""
|
||||
}
|
||||
kctl := kubectlCLI(exec)
|
||||
|
||||
podOut, err := exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null",
|
||||
kctl, c.namespace))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sgPods, bgdPods, otherPods []string
|
||||
for _, line := range strings.Split(podOut, "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(name, "-sg-"):
|
||||
sgPods = append(sgPods, name)
|
||||
case strings.Contains(name, "bgd"):
|
||||
bgdPods = append(bgdPods, name)
|
||||
default:
|
||||
otherPods = append(otherPods, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(sgPods)
|
||||
sort.Strings(bgdPods)
|
||||
sort.Strings(otherPods)
|
||||
pods := append(append(sgPods, bgdPods...), otherPods...)
|
||||
if len(pods) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, pod := range pods {
|
||||
if content := tryReadINIFromPod(exec, kctl, c.namespace, pod, filename); content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[default-ini] kubectl: %s not found in namespace %s", filename, c.namespace)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *kubectlControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
|
||||
if c.namespace == "" {
|
||||
return "", fmt.Errorf("namespace not discovered yet; reconnect or set server_ini_dir in config")
|
||||
}
|
||||
// k3s local-path storage: /var/lib/rancher/k3s/storage/<vol>_<ns>_<pvc>/Saved/UserSettings
|
||||
out, err := exec.Exec(fmt.Sprintf(
|
||||
`sudo ls /var/lib/rancher/k3s/storage/ 2>/dev/null | grep -F %s | grep -v -- '-db-pvc' | head -1`,
|
||||
shellQuote(c.namespace)))
|
||||
if err != nil || strings.TrimSpace(out) == "" {
|
||||
return "", fmt.Errorf("could not auto-discover ini dir for namespace %s; set server_ini_dir in config", c.namespace)
|
||||
}
|
||||
dir := "/var/lib/rancher/k3s/storage/" + strings.TrimSpace(out) + "/Saved/UserSettings"
|
||||
return dir, nil
|
||||
}
|
||||
163
docs/reference-repos/icehunter/cmd/dune-admin/control_local.go
Normal file
163
docs/reference-repos/icehunter/cmd/dune-admin/control_local.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// localControl implements ControlPlane using configurable shell commands.
|
||||
// Intended for AMP, LGSM, bare-metal, or any environment where the user
|
||||
// manages the game server through their own tooling.
|
||||
type localControl struct {
|
||||
cmdStart string // e.g. "amp start dune"
|
||||
cmdStop string
|
||||
cmdRestart string
|
||||
cmdStatus string
|
||||
controlNamespace string
|
||||
brokerExecPrefix string // e.g. "podman exec AMP_MehDune01" — prepended to rabbitmqctl calls
|
||||
}
|
||||
|
||||
func (c *localControl) Name() string { return "local" }
|
||||
|
||||
func (c *localControl) kubectlEnabled(exec Executor) bool {
|
||||
if c.controlNamespace == "" || exec == nil {
|
||||
return false
|
||||
}
|
||||
_, err := exec.Exec(kubectlCLI(exec) + " version --client >/dev/null 2>&1")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *localControl) kubectlDelegate() *kubectlControl {
|
||||
return &kubectlControl{namespace: c.controlNamespace}
|
||||
}
|
||||
|
||||
func (c *localControl) GetStatus(_ context.Context, exec Executor) (*BattlegroupStatus, error) {
|
||||
if c.cmdStatus == "" {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().GetStatus(context.Background(), exec)
|
||||
}
|
||||
return nil, errNotSupported("local", "GetStatus (cmd_status not configured)")
|
||||
}
|
||||
out, err := exec.Exec(c.cmdStatus)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("status command: %w — %s", err, out)
|
||||
}
|
||||
return &BattlegroupStatus{
|
||||
Name: "local",
|
||||
Title: "Local Server",
|
||||
Phase: strings.TrimSpace(out),
|
||||
Servers: []ServerRow{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *localControl) ExecCommand(_ context.Context, exec Executor, cmd string) (string, error) {
|
||||
var shellCmd string
|
||||
switch cmd {
|
||||
case "start":
|
||||
shellCmd = c.cmdStart
|
||||
case "stop":
|
||||
shellCmd = c.cmdStop
|
||||
case "restart":
|
||||
shellCmd = c.cmdRestart
|
||||
default:
|
||||
return "", fmt.Errorf("local control does not support %q", cmd)
|
||||
}
|
||||
if shellCmd == "" {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().ExecCommand(context.Background(), exec, cmd)
|
||||
}
|
||||
return "", errNotSupported("local", fmt.Sprintf("ExecCommand %q (cmd_%s not configured)", cmd, cmd))
|
||||
}
|
||||
out, err := exec.Exec(shellCmd)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s command: %w — %s", cmd, err, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *localControl) ListProcesses(_ context.Context, exec Executor) ([]ProcessInfo, string, error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().ListProcesses(context.Background(), exec)
|
||||
}
|
||||
return nil, "", errNotSupported("local", "ListProcesses")
|
||||
}
|
||||
|
||||
func (c *localControl) ListLogSources(_ context.Context, exec Executor) ([]LogSource, error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().ListLogSources(context.Background(), exec)
|
||||
}
|
||||
return nil, errNotSupported("local", "ListLogSources")
|
||||
}
|
||||
|
||||
func (c *localControl) StreamLog(_ context.Context, exec Executor, ns, name string) (<-chan string, func(), error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().StreamLog(context.Background(), exec, ns, name)
|
||||
}
|
||||
return nil, func() {}, errNotSupported("local", "StreamLog")
|
||||
}
|
||||
|
||||
func (c *localControl) CaptureJWT(_ context.Context, exec Executor) (string, string, error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().CaptureJWT(context.Background(), exec)
|
||||
}
|
||||
return "", "", errNotSupported("local", "CaptureJWT")
|
||||
}
|
||||
|
||||
func (c *localControl) brokerBase() string {
|
||||
if c.brokerExecPrefix != "" {
|
||||
return c.brokerExecPrefix + " rabbitmqctl"
|
||||
}
|
||||
return "rabbitmqctl"
|
||||
}
|
||||
|
||||
func (c *localControl) EvalOnGameBroker(ctx context.Context, exec Executor, expr string) (string, error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().EvalOnGameBroker(ctx, exec, expr)
|
||||
}
|
||||
out, err := exec.Exec(fmt.Sprintf("%s eval %s 2>&1", c.brokerBase(), shellQuote(expr)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rabbitmqctl eval: %w (output: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func (c *localControl) ReadDefaultINI(ctx context.Context, exec Executor, filename string) string {
|
||||
if c.kubectlEnabled(exec) {
|
||||
return c.kubectlDelegate().ReadDefaultINI(ctx, exec, filename)
|
||||
}
|
||||
return "" // host-path traversal in readDefaultINIContent handles local/Hyper-V
|
||||
}
|
||||
|
||||
func (c *localControl) DiscoverIniDir(_ context.Context, exec Executor) (string, error) {
|
||||
if c.kubectlEnabled(exec) {
|
||||
ns := c.controlNamespace
|
||||
kctl := kubectlCLI(exec)
|
||||
// UserSettings live on game-server pods (-sg-), not the bgd deploy pod.
|
||||
podOut, err := exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- '-sg-' | head -1",
|
||||
kctl, ns,
|
||||
))
|
||||
if err != nil || strings.TrimSpace(podOut) == "" {
|
||||
podOut, err = exec.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
|
||||
kctl, ns,
|
||||
))
|
||||
}
|
||||
if err != nil || strings.TrimSpace(podOut) == "" {
|
||||
return "", fmt.Errorf("could not find game or bgd pod in namespace %s", ns)
|
||||
}
|
||||
pod := strings.TrimSpace(podOut)
|
||||
findCmd := `for d in /home/dune/server/DuneSandbox/Saved/UserSettings /DuneSandbox/Saved/UserSettings /game/DuneSandbox/Saved/UserSettings; do if [ -d "$d" ]; then echo "$d"; exit 0; fi; done; find / -type d -path "*/Saved/UserSettings" 2>/dev/null | head -1`
|
||||
dirOut, err := exec.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- sh -lc %s 2>/dev/null",
|
||||
kctl, ns, pod, shellQuote(findCmd),
|
||||
))
|
||||
if err != nil || strings.TrimSpace(dirOut) == "" {
|
||||
return "", fmt.Errorf("could not auto-discover ini dir in pod %s", pod)
|
||||
}
|
||||
dir := strings.TrimSpace(dirOut)
|
||||
return fmt.Sprintf("k8s://%s/%s%s", ns, pod, dir), nil
|
||||
}
|
||||
return "", fmt.Errorf("local control plane requires server_ini_dir to be set in config")
|
||||
}
|
||||
6243
docs/reference-repos/icehunter/cmd/dune-admin/db.go
Normal file
6243
docs/reference-repos/icehunter/cmd/dune-admin/db.go
Normal file
File diff suppressed because it is too large
Load Diff
149
docs/reference-repos/icehunter/cmd/dune-admin/db_backup.go
Normal file
149
docs/reference-repos/icehunter/cmd/dune-admin/db_backup.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Database backups (#150) ─────────────────────────────────────────────────
|
||||
// AMP-native Postgres backups. The existing handleBGBackup* family targets
|
||||
// kubectl/k8s pod paths and does nothing on AMP, so this is a separate,
|
||||
// control-plane-aware path: pg_dump (-Fc) runs inside the AMP container and its
|
||||
// stdout is redirected to a host file the dune-admin service user owns, so the
|
||||
// list/download/delete operations are plain os.* calls on the host. Restore
|
||||
// (pg_restore --clean) is destructive and guarded at the handler layer.
|
||||
|
||||
// dbConn is the Postgres connection target for backup/restore.
|
||||
type dbConn struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Pass string
|
||||
Name string
|
||||
}
|
||||
|
||||
type dbBackupFile struct {
|
||||
Name string `json:"name"`
|
||||
SizeB int64 `json:"size_bytes"`
|
||||
Modified string `json:"modified"`
|
||||
}
|
||||
|
||||
// dbBackupProvider is the optional control-plane capability for native database
|
||||
// backup/restore. Only the AMP control plane implements it; other planes get a
|
||||
// 501 via the handler's type assertion.
|
||||
type dbBackupProvider interface {
|
||||
BackupDatabase(exec Executor, conn dbConn, destPath string) (string, error)
|
||||
RestoreDatabase(exec Executor, conn dbConn, srcPath string) (string, error)
|
||||
}
|
||||
|
||||
var backupNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+\.dump$`)
|
||||
|
||||
// validateBackupName guards against path traversal and shell metacharacters: a
|
||||
// backup filename must be a bare name (no separators or "..") matching a strict
|
||||
// charset and ending in .dump.
|
||||
func validateBackupName(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("invalid backup name")
|
||||
}
|
||||
if !backupNameRe.MatchString(name) {
|
||||
return fmt.Errorf("invalid backup name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupsToPrune returns the names to delete to satisfy a keep-N retention
|
||||
// policy, given names sorted newest-first. keepN <= 0 disables pruning.
|
||||
func backupsToPrune(newestFirst []string, keepN int) []string {
|
||||
if keepN <= 0 || len(newestFirst) <= keepN {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), newestFirst[keepN:]...)
|
||||
}
|
||||
|
||||
// dbBackupFilename is the canonical timestamped name for a new backup.
|
||||
func dbBackupFilename(t time.Time) string {
|
||||
return "dune-" + t.UTC().Format("20060102-150405") + ".dump"
|
||||
}
|
||||
|
||||
// dbBackupDir resolves (and creates) the host directory where dumps live.
|
||||
func dbBackupDir() (string, error) {
|
||||
dir := loadedConfig.AmpBackupDir
|
||||
if dir == "" {
|
||||
dir = filepath.Join(configDir(), "db-backups")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// dbBackupConn builds the Postgres connection target from config. The AMP
|
||||
// Postgres listens on 127.0.0.1:<db_port> both inside and outside the container.
|
||||
func dbBackupConn() dbConn {
|
||||
port := loadedConfig.DBPort
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
name := loadedConfig.DBName
|
||||
if name == "" {
|
||||
name = "dune"
|
||||
}
|
||||
user := loadedConfig.DBUser
|
||||
if user == "" {
|
||||
user = "dune"
|
||||
}
|
||||
return dbConn{Host: "127.0.0.1", Port: port, User: user, Pass: loadedConfig.DBPass, Name: name}
|
||||
}
|
||||
|
||||
// listDBBackups lists the .dump files in the backup dir, newest first.
|
||||
func listDBBackups() ([]dbBackupFile, error) {
|
||||
dir, err := dbBackupDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read backup dir: %w", err)
|
||||
}
|
||||
files := make([]dbBackupFile, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".dump") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files = append(files, dbBackupFile{
|
||||
Name: e.Name(),
|
||||
SizeB: info.Size(),
|
||||
Modified: info.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
// RFC3339 UTC strings sort lexicographically in chronological order.
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Modified > files[j].Modified })
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// deleteDBBackup removes a backup file (and its sibling, if any) from the dir,
|
||||
// after validating the name. Used by manual delete and retention pruning.
|
||||
func deleteDBBackup(name string) error {
|
||||
if err := validateBackupName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := dbBackupDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
// #nosec G304 G703 -- name validated by validateBackupName above (no separators/..)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete backup %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateBackupName(t *testing.T) {
|
||||
t.Parallel()
|
||||
good := []string{"dune-20260608-221700.dump", "a.dump", "BG_1.backup.dump"}
|
||||
for _, n := range good {
|
||||
if err := validateBackupName(n); err != nil {
|
||||
t.Errorf("validateBackupName(%q) = %v, want nil", n, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"foo.txt", // wrong ext
|
||||
"foo.dump.exe", // wrong ext
|
||||
"../etc/passwd.dump", // traversal
|
||||
"a/b.dump", // path sep
|
||||
"a\\b.dump", // win path sep
|
||||
"foo .dump", // space
|
||||
"foo;rm.dump", // shell metachar
|
||||
".dump", // no stem
|
||||
}
|
||||
for _, n := range bad {
|
||||
if err := validateBackupName(n); err == nil {
|
||||
t.Errorf("validateBackupName(%q) = nil, want error", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsToPrune(t *testing.T) {
|
||||
t.Parallel()
|
||||
names := []string{"d5.dump", "d4.dump", "d3.dump", "d2.dump", "d1.dump"} // newest-first
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
keepN int
|
||||
want []string
|
||||
}{
|
||||
{"keep 3 prunes oldest 2", 3, []string{"d2.dump", "d1.dump"}},
|
||||
{"keep more than present prunes none", 10, nil},
|
||||
{"keep exactly present prunes none", 5, nil},
|
||||
{"keep 0 disables pruning", 0, nil},
|
||||
{"negative disables pruning", -1, nil},
|
||||
{"keep 1 prunes rest", 1, []string{"d4.dump", "d3.dump", "d2.dump", "d1.dump"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := backupsToPrune(names, tt.keepN)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("backupsToPrune(keepN=%d) = %v, want %v", tt.keepN, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("backupsToPrune(keepN=%d) = %v, want %v", tt.keepN, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBBackupFilename(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts := time.Date(2026, 6, 8, 22, 17, 5, 0, time.UTC)
|
||||
got := dbBackupFilename(ts)
|
||||
want := "dune-20260608-221705.dump"
|
||||
if got != want {
|
||||
t.Fatalf("dbBackupFilename = %q, want %q", got, want)
|
||||
}
|
||||
if err := validateBackupName(got); err != nil {
|
||||
t.Fatalf("generated name failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputeAwardCharXPOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("caps xp and marks capped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
outcome := computeAwardCharXPOutcome(maxCharXP-10, 5, 3, 99)
|
||||
if outcome.newXP != maxCharXP {
|
||||
t.Fatalf("expected capped xp %d, got %d", maxCharXP, outcome.newXP)
|
||||
}
|
||||
if !outcome.capped {
|
||||
t.Fatalf("expected capped=true")
|
||||
}
|
||||
if outcome.newTotalSP != outcome.newLevel+3 {
|
||||
t.Fatalf("expected total SP to include keystone bonus, got level=%d total=%d", outcome.newLevel, outcome.newTotalSP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clamps unspent to zero", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
outcome := computeAwardCharXPOutcome(0, 999, 0, 0)
|
||||
if outcome.newUnspentSP != 0 {
|
||||
t.Fatalf("expected unspent SP clamp to 0, got %d", outcome.newUnspentSP)
|
||||
}
|
||||
if outcome.capped {
|
||||
t.Fatalf("expected capped=false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatAwardCharXPSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
outcome := charXPOutcome{
|
||||
newXP: 1234,
|
||||
newLevel: 42,
|
||||
newUnspentSP: 7,
|
||||
newIntel: 99,
|
||||
capped: true,
|
||||
}
|
||||
msg := formatAwardCharXPSuccess(777, outcome, 11)
|
||||
if !strings.Contains(msg, "Player 777") ||
|
||||
!strings.Contains(msg, "level 42 (capped at level 200)") ||
|
||||
!strings.Contains(msg, "XP 1234") ||
|
||||
!strings.Contains(msg, "SP 7 unspent (11 spent)") ||
|
||||
!strings.Contains(msg, "Intel 99") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadControllerKeystoneIDs_NoController(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ids, err := loadControllerKeystoneIDs(t.Context(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if ids != nil {
|
||||
t.Fatalf("expected nil ids, got %#v", ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// buildFactionDataArray is the pure heart of the in-game rank fix: it produces
|
||||
// the FactionPlayerComponent.m_FactionDataArray cache the game reads for rank
|
||||
// and per-territory vendor gating. It must always emit BOTH great houses
|
||||
// (Atreides=1, Harkonnen=2), defaulting a missing house to 0, and ignore any
|
||||
// non-great-house faction ids (None=3, Smuggler=4).
|
||||
func TestBuildFactionDataArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const ts = 1780198964.0
|
||||
|
||||
repOf := func(entries []factionDataEntry, name string) (int32, bool) {
|
||||
for _, e := range entries {
|
||||
if e.Faction.Name == name {
|
||||
return e.ReputationAmount, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reps map[int16]int32
|
||||
wantAtre int32
|
||||
wantHark int32
|
||||
}{
|
||||
{name: "both houses present", reps: map[int16]int32{1: 1500, 2: 2000}, wantAtre: 1500, wantHark: 2000},
|
||||
{name: "only harkonnen → atreides defaults 0", reps: map[int16]int32{2: 2000}, wantAtre: 0, wantHark: 2000},
|
||||
{name: "only atreides → harkonnen defaults 0", reps: map[int16]int32{1: 1500}, wantAtre: 1500, wantHark: 0},
|
||||
{name: "empty → both 0", reps: map[int16]int32{}, wantAtre: 0, wantHark: 0},
|
||||
{name: "ignores none and smuggler", reps: map[int16]int32{1: 100, 3: 50, 4: 999}, wantAtre: 100, wantHark: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := buildFactionDataArray(tt.reps, ts)
|
||||
|
||||
// Exactly the two great houses, always.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries (both great houses), got %d: %+v", len(got), got)
|
||||
}
|
||||
atre, okA := repOf(got, "Atreides")
|
||||
hark, okH := repOf(got, "Harkonnen")
|
||||
if !okA || !okH {
|
||||
t.Fatalf("expected both Atreides and Harkonnen entries, got %+v", got)
|
||||
}
|
||||
if atre != tt.wantAtre {
|
||||
t.Fatalf("Atreides rep: want %d, got %d", tt.wantAtre, atre)
|
||||
}
|
||||
if hark != tt.wantHark {
|
||||
t.Fatalf("Harkonnen rep: want %d, got %d", tt.wantHark, hark)
|
||||
}
|
||||
// timestamp propagated to every entry.
|
||||
for _, e := range got {
|
||||
if e.Timestamp != ts {
|
||||
t.Fatalf("entry %s timestamp: want %v, got %v", e.Faction.Name, ts, e.Timestamp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The marshaled shape must match what the game reads exactly:
|
||||
// {"Faction":{"Name":"Harkonnen"},"timestamp":<float>,"ReputationAmount":<int>}
|
||||
func TestFactionDataEntryJSONShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
arr := buildFactionDataArray(map[int16]int32{2: 2000}, 1780198964.5)
|
||||
raw, err := json.Marshal(arr)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var back []map[string]any
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
var hark map[string]any
|
||||
for _, e := range back {
|
||||
if f, ok := e["Faction"].(map[string]any); ok && f["Name"] == "Harkonnen" {
|
||||
hark = e
|
||||
}
|
||||
}
|
||||
if hark == nil {
|
||||
t.Fatalf("no Harkonnen entry in %s", raw)
|
||||
}
|
||||
if _, ok := hark["Faction"].(map[string]any)["Name"]; !ok {
|
||||
t.Fatalf("missing Faction.Name in %s", raw)
|
||||
}
|
||||
if _, ok := hark["timestamp"]; !ok {
|
||||
t.Fatalf("missing timestamp key in %s", raw)
|
||||
}
|
||||
if rep, ok := hark["ReputationAmount"]; !ok || rep.(float64) != 2000 {
|
||||
t.Fatalf("ReputationAmount wrong in %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// stubExecer lets us test writeFactionComponent's row-count guard without a real DB.
|
||||
type stubExecer struct {
|
||||
tag pgconn.CommandTag
|
||||
err error
|
||||
gotSQL string
|
||||
gotArgs []any
|
||||
}
|
||||
|
||||
func (s *stubExecer) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
s.gotSQL = sql
|
||||
s.gotArgs = args
|
||||
return s.tag, s.err
|
||||
}
|
||||
|
||||
func TestWriteFactionComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
arr := buildFactionDataArray(map[int16]int32{2: 2000}, 1.0)
|
||||
|
||||
t.Run("one row affected → success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &stubExecer{tag: pgconn.NewCommandTag("UPDATE 1")}
|
||||
if err := writeFactionComponent(context.Background(), s, 17, arr); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(s.gotArgs) != 2 {
|
||||
t.Fatalf("expected 2 args (payload, controllerID), got %d", len(s.gotArgs))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero rows → error (kills the silent no-op)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &stubExecer{tag: pgconn.NewCommandTag("UPDATE 0")}
|
||||
err := writeFactionComponent(context.Background(), s, 999, arr)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when no row updated, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exec error is wrapped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sentinel := errors.New("connection refused")
|
||||
s := &stubExecer{err: sentinel}
|
||||
err := writeFactionComponent(context.Background(), s, 17, arr)
|
||||
if err == nil || !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected wrapped exec error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestValidateGiveItemInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
playerID int64
|
||||
template string
|
||||
qty int64
|
||||
wantTpl string
|
||||
wantError string
|
||||
}{
|
||||
{name: "valid", playerID: 123, template: " Dune.Item ", qty: 2, wantTpl: "Dune.Item"},
|
||||
{name: "missing-player", playerID: 0, template: "x", qty: 1, wantError: "player ID required"},
|
||||
{name: "missing-template", playerID: 1, template: " ", qty: 1, wantError: "item template required"},
|
||||
{name: "invalid-qty", playerID: 1, template: "x", qty: 0, wantError: "quantity must be > 0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := validateGiveItemInput(tt.playerID, tt.template, tt.qty)
|
||||
if tt.wantError != "" {
|
||||
if err == nil || err.Error() != tt.wantError {
|
||||
t.Fatalf("expected error %q, got %v", tt.wantError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantTpl {
|
||||
t.Fatalf("expected template %q, got %q", tt.wantTpl, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanGiveItemStacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stacks := []giveItemStackSlot{
|
||||
{id: 1, size: 8},
|
||||
{id: 2, size: 10},
|
||||
{id: 3, size: 2},
|
||||
}
|
||||
updates, created := planGiveItemStacks(17, 10, stacks)
|
||||
|
||||
if len(updates) != 2 {
|
||||
t.Fatalf("expected 2 updates, got %d", len(updates))
|
||||
}
|
||||
if updates[0].id != 1 || updates[0].add != 2 {
|
||||
t.Fatalf("unexpected first update: %+v", updates[0])
|
||||
}
|
||||
if updates[1].id != 3 || updates[1].add != 8 {
|
||||
t.Fatalf("unexpected second update: %+v", updates[1])
|
||||
}
|
||||
if len(created) != 1 || created[0] != 7 {
|
||||
t.Fatalf("unexpected created stacks: %#v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanGiveItemStacks_NoStacking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
updates, created := planGiveItemStacks(3, 1, []giveItemStackSlot{{id: 1, size: 1}})
|
||||
if len(updates) != 0 {
|
||||
t.Fatalf("expected no updates, got %#v", updates)
|
||||
}
|
||||
if len(created) != 3 || created[0] != 1 || created[1] != 1 || created[2] != 1 {
|
||||
t.Fatalf("unexpected created stacks: %#v", created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGiveItemSlotCapacity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inv := giveItemInventory{maxSlots: 5, hasSlotCap: true}
|
||||
state := giveItemInventoryState{usedSlots: 3}
|
||||
if err := ensureGiveItemSlotCapacity(inv, state, 2); err != nil {
|
||||
t.Fatalf("expected capacity to fit, got %v", err)
|
||||
}
|
||||
if err := ensureGiveItemSlotCapacity(inv, state, 3); err == nil {
|
||||
t.Fatalf("expected slot-capacity error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryItemVolume(t *testing.T) {
|
||||
oldItemData := itemData
|
||||
itemData = itemDataFile{
|
||||
DefaultVolume: 2.5,
|
||||
Items: map[string]itemRule{
|
||||
"dune.item.known": {Volume: 1.25},
|
||||
"dune.item.zero": {Volume: 0},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() { itemData = oldItemData })
|
||||
|
||||
override := pgtype.Float8{Float64: 3.0, Valid: true}
|
||||
if got := inventoryItemVolume("any", override); got != 3.0 {
|
||||
t.Fatalf("expected override volume 3.0, got %v", got)
|
||||
}
|
||||
if got := inventoryItemVolume("Dune.Item.Known", pgtype.Float8{}); got != 1.25 {
|
||||
t.Fatalf("expected known-rule volume 1.25, got %v", got)
|
||||
}
|
||||
if got := inventoryItemVolume("Dune.Item.Zero", pgtype.Float8{}); got != 0 {
|
||||
t.Fatalf("expected zero volume from rule, got %v", got)
|
||||
}
|
||||
if got := inventoryItemVolume("Dune.Item.Unknown", pgtype.Float8{}); got != 2.5 {
|
||||
t.Fatalf("expected default volume 2.5, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGiveItemResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatGiveItemResult(42, "Dune.Item", 3, 1, 2)
|
||||
want := "Added 3 × Dune.Item to player 42 (1 stack(s) topped up, 2 new stack(s))"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGiveItemVolumeCapacity(t *testing.T) {
|
||||
oldItemData := itemData
|
||||
itemData = itemDataFile{
|
||||
Items: map[string]itemRule{
|
||||
"dune.item": {Volume: 2},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() { itemData = oldItemData })
|
||||
|
||||
inv := giveItemInventory{hasVolumeCap: true, maxVolume: 10}
|
||||
state := giveItemInventoryState{usedVolume: 4}
|
||||
|
||||
if err := ensureGiveItemVolumeCapacity(t.Context(), inv, state, "Dune.Item", 3); err != nil {
|
||||
t.Fatalf("expected capacity to fit, got %v", err)
|
||||
}
|
||||
err := ensureGiveItemVolumeCapacity(t.Context(), inv, state, "Dune.Item", 4)
|
||||
if err == nil {
|
||||
t.Fatalf("expected volume-capacity error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxItemsByVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := maxItemsByVolume(100, 40, 15); got != 4 {
|
||||
t.Fatalf("expected 4 items by volume, got %d", got)
|
||||
}
|
||||
if got := maxItemsByVolume(100, 140, 10); got != 0 {
|
||||
t.Fatalf("expected clamped 0 items by volume, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredStackCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := requiredStackCount(10, 3); got != 4 {
|
||||
t.Fatalf("expected 4 required stacks, got %d", got)
|
||||
}
|
||||
if got := requiredStackCount(1, 1); got != 1 {
|
||||
t.Fatalf("expected 1 required stack, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveStackMax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stackMax int64
|
||||
known bool
|
||||
qty int64
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
// Known stackable (e.g. from item-data): use its real cap.
|
||||
name: "known stackable", stackMax: 100, known: true, qty: 5000, want: 100,
|
||||
},
|
||||
{
|
||||
// Known non-stackable (armour/weapon): one per slot.
|
||||
name: "known non-stackable", stackMax: 1, known: true, qty: 5000, want: 1,
|
||||
},
|
||||
{
|
||||
// The bug: unknown stack max (ammo not in item-data, no existing
|
||||
// stacks) must NOT be treated as one-per-slot, or 5000 rounds
|
||||
// demand 5000 slots. Assume it stacks into the requested quantity.
|
||||
name: "unknown assumes fully stackable", stackMax: 1, known: false, qty: 5000, want: 5000,
|
||||
},
|
||||
{
|
||||
name: "unknown qty=1 stays 1", stackMax: 1, known: false, qty: 1, want: 1,
|
||||
},
|
||||
{
|
||||
// Defensive: a known result below 1 is meaningless → treat as qty.
|
||||
name: "known but zero falls back to qty", stackMax: 0, known: true, qty: 42, want: 42,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := effectiveStackMax(tt.stackMax, tt.known, tt.qty); got != tt.want {
|
||||
t.Errorf("effectiveStackMax(%d, %v, %d) = %d, want %d",
|
||||
tt.stackMax, tt.known, tt.qty, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllKeystoneIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ids := allKeystoneIDs()
|
||||
if len(ids) != 205 {
|
||||
t.Fatalf("expected 205 keystone ids, got %d", len(ids))
|
||||
}
|
||||
if ids[0] != 1 || ids[len(ids)-1] != 205 {
|
||||
t.Fatalf("unexpected ID bounds: first=%d last=%d", ids[0], ids[len(ids)-1])
|
||||
}
|
||||
for i, id := range ids {
|
||||
if int(id) != i+1 {
|
||||
t.Fatalf("unexpected id sequence at index %d: got %d", i, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantAllKeystoneTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bonus := keystoneSPBonus(allKeystoneIDs())
|
||||
expectedTotal, expectedUnspent, gotBonus := grantAllKeystoneTargets(0, 0)
|
||||
if gotBonus != bonus {
|
||||
t.Fatalf("expected bonus %d, got %d", bonus, gotBonus)
|
||||
}
|
||||
if expectedTotal != bonus {
|
||||
t.Fatalf("expected total skill points %d at xp=0, got %d", bonus, expectedTotal)
|
||||
}
|
||||
if expectedUnspent != expectedTotal-1 {
|
||||
t.Fatalf("expected unspent=%d, got %d", expectedTotal-1, expectedUnspent)
|
||||
}
|
||||
|
||||
_, expectedUnspent, _ = grantAllKeystoneTargets(0, 99999)
|
||||
if expectedUnspent != 0 {
|
||||
t.Fatalf("expected negative unspent to clamp to 0, got %d", expectedUnspent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Phase 1 (Live Map): the map-key allow-list is the pure, testable unit of the
|
||||
// markers query. v1 scope is open-world only (Hagga Basin + Deep Desert); the
|
||||
// city instances (Arrakeen/HarkoVillage) are deliberately out of scope. The key
|
||||
// is also the one piece of caller-supplied input that reaches the query, so the
|
||||
// allow-list doubles as an injection guard even though the query parameterises it.
|
||||
func TestValidateMapKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "hagga basin", key: "HaggaBasin", wantErr: false},
|
||||
{name: "deep desert", key: "DeepDesert", wantErr: false},
|
||||
{name: "city out of v1 scope", key: "Arrakeen", wantErr: true},
|
||||
{name: "unknown map", key: "Atlantis", wantErr: true},
|
||||
{name: "empty", key: "", wantErr: true},
|
||||
{name: "injection attempt", key: "HaggaBasin'; DROP TABLE dune.actors; --", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateMapKey(tt.key)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("validateMapKey(%q): expected error, got nil", tt.key)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("validateMapKey(%q): unexpected error: %v", tt.key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProgressionFactionConfigFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
atreides, err := progressionFactionConfigFor("atreides")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if atreides.factionID != 1 || atreides.alignedFlag != "DialogueFlags.Factions.AlignedAtreides" {
|
||||
t.Fatalf("unexpected atreides config: %+v", atreides)
|
||||
}
|
||||
|
||||
if _, err := progressionFactionConfigFor("unknown"); err == nil {
|
||||
t.Fatalf("expected error for unknown faction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressionTargetTierForPreset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tier, err := progressionTargetTierForPreset("ch3_start"); err != nil || tier != 5 {
|
||||
t.Fatalf("expected ch3_start => 5, got tier=%d err=%v", tier, err)
|
||||
}
|
||||
if tier, err := progressionTargetTierForPreset("rank19_eligible"); err != nil || tier != 19 {
|
||||
t.Fatalf("expected rank19_eligible => 19, got tier=%d err=%v", tier, err)
|
||||
}
|
||||
if _, err := progressionTargetTierForPreset("bad"); err == nil {
|
||||
t.Fatalf("expected error for bad preset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressionUnlockTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, _ := progressionFactionConfigFor("atreides")
|
||||
tags := progressionUnlockTags(cfg, 19)
|
||||
|
||||
required := []string{
|
||||
"DialogueFlags.Factions.SentToMeetHawat",
|
||||
"DialogueFlags.Factions.AlignedAtreides",
|
||||
"Journey.LandsraadContractsUnlocked",
|
||||
"Faction.Atreides.Tier0",
|
||||
"Faction.Atreides.Tier5",
|
||||
}
|
||||
for _, tag := range required {
|
||||
if !containsString(tags, tag) {
|
||||
t.Fatalf("expected tag %q in output: %#v", tag, tags)
|
||||
}
|
||||
}
|
||||
if containsString(tags, "Faction.Atreides.Tier6") {
|
||||
t.Fatalf("did not expect Tier6 tag in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProgressionUnlockSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := formatProgressionUnlockSuccess("ch3_start", "atreides", 12, "Atreides", 5, 777)
|
||||
expectParts := []string{
|
||||
"Progression unlock (ch3_start/atreides)",
|
||||
"12 journey nodes completed",
|
||||
"Atreides tier tags 0–5",
|
||||
"rep tier 5 on controller 777",
|
||||
}
|
||||
for _, part := range expectParts {
|
||||
if !strings.Contains(msg, part) {
|
||||
t.Fatalf("expected message to contain %q, got %q", part, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressionReverseTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := []string{"A", "B"}
|
||||
got := progressionReverseTags(base, []string{"unknown.node"})
|
||||
if len(got) != 2 || got[0] != "A" || got[1] != "B" {
|
||||
t.Fatalf("expected base tags unchanged for unknown node, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProgressionReverseSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := formatProgressionReverseSuccess("rank19_eligible", "harkonnen", 7, 19)
|
||||
expectParts := []string{
|
||||
"Reversed progression unlock (rank19_eligible/harkonnen)",
|
||||
"reset 7 node(s)",
|
||||
"removed 19 tag(s)",
|
||||
}
|
||||
for _, part := range expectParts {
|
||||
if !strings.Contains(msg, part) {
|
||||
t.Fatalf("expected message to contain %q, got %q", part, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRepairItemNoChangeMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := repairItemNoChangeMessage(42, false)
|
||||
if msg.err == nil || msg.err.Error() != "item 42 has no durability field" {
|
||||
t.Fatalf("expected no-durability error, got %+v", msg)
|
||||
}
|
||||
|
||||
msg = repairItemNoChangeMessage(42, true)
|
||||
if msg.err != nil {
|
||||
t.Fatalf("expected success message, got error %v", msg.err)
|
||||
}
|
||||
if msg.ok != "Item 42 already at full durability" {
|
||||
t.Fatalf("unexpected success message: %q", msg.ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairItemSuccessMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := repairItemSuccessMessage(99)
|
||||
if msg.err != nil {
|
||||
t.Fatalf("expected nil error, got %v", msg.err)
|
||||
}
|
||||
if msg.ok != "Repaired item 99 — relog to see in-game" {
|
||||
t.Fatalf("unexpected success message: %q", msg.ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestParseDurabilityText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := parseDurabilityText(pgtype.Text{}); got != 0 {
|
||||
t.Fatalf("expected invalid text to parse as 0, got %v", got)
|
||||
}
|
||||
if got := parseDurabilityText(pgtype.Text{String: "12.5", Valid: true}); got != 12.5 {
|
||||
t.Fatalf("expected 12.5, got %v", got)
|
||||
}
|
||||
if got := parseDurabilityText(pgtype.Text{String: "not-a-number", Valid: true}); got != 0 {
|
||||
t.Fatalf("expected parse failure to fall back to 0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairTargetForItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
maxText pgtype.Text
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "in-row MaxDurability is the source of truth (200-scale item)",
|
||||
maxText: pgtype.Text{String: "200", Valid: true},
|
||||
want: 200,
|
||||
},
|
||||
{
|
||||
name: "plain gear without MaxDurability defaults to 100",
|
||||
maxText: pgtype.Text{},
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "unparseable MaxDurability defaults to 100",
|
||||
maxText: pgtype.Text{String: "oops", Valid: true},
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
name: "zero MaxDurability defaults to 100",
|
||||
maxText: pgtype.Text{String: "0", Valid: true},
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := repairTargetForItem(tt.maxText); got != tt.want {
|
||||
t.Fatalf("expected %v, got %v", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRepairCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
itemID int64
|
||||
maxDurability pgtype.Text
|
||||
current pgtype.Text
|
||||
decayed pgtype.Text
|
||||
wantNeedsFix bool
|
||||
wantTarget float64
|
||||
}{
|
||||
{
|
||||
name: "already at target",
|
||||
itemID: 1,
|
||||
maxDurability: pgtype.Text{String: "100", Valid: true},
|
||||
current: pgtype.Text{String: "100", Valid: true},
|
||||
decayed: pgtype.Text{String: "100", Valid: true},
|
||||
wantNeedsFix: false,
|
||||
},
|
||||
{
|
||||
name: "plain 0-100 gear restores to 100",
|
||||
itemID: 2,
|
||||
maxDurability: pgtype.Text{},
|
||||
current: pgtype.Text{String: "75", Valid: true},
|
||||
decayed: pgtype.Text{String: "100", Valid: true},
|
||||
wantNeedsFix: true,
|
||||
wantTarget: 100,
|
||||
},
|
||||
{
|
||||
name: "200-scale item restores to 200, not capped at 100",
|
||||
itemID: 3,
|
||||
maxDurability: pgtype.Text{String: "200", Valid: true},
|
||||
current: pgtype.Text{String: "50", Valid: true},
|
||||
decayed: pgtype.Text{String: "200", Valid: true},
|
||||
wantNeedsFix: true,
|
||||
wantTarget: 200,
|
||||
},
|
||||
{
|
||||
name: "never lowers below an existing value when MaxDurability absent",
|
||||
itemID: 4,
|
||||
maxDurability: pgtype.Text{},
|
||||
current: pgtype.Text{String: "150", Valid: true},
|
||||
decayed: pgtype.Text{String: "120", Valid: true},
|
||||
wantNeedsFix: true,
|
||||
wantTarget: 150,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
candidate, needsFix := buildRepairCandidate(tt.itemID, tt.maxDurability, tt.current, tt.decayed)
|
||||
if needsFix != tt.wantNeedsFix {
|
||||
t.Fatalf("expected needsFix=%v, got %v", tt.wantNeedsFix, needsFix)
|
||||
}
|
||||
if !needsFix {
|
||||
return
|
||||
}
|
||||
if candidate.id != tt.itemID {
|
||||
t.Fatalf("expected item ID %d, got %d", tt.itemID, candidate.id)
|
||||
}
|
||||
if math.Abs(candidate.target-tt.wantTarget) > 0.0001 {
|
||||
t.Fatalf("expected target %.4f, got %.4f", tt.wantTarget, candidate.target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRepairPlayerGearInput(t *testing.T) {
|
||||
originalDB := globalDB
|
||||
t.Cleanup(func() { globalDB = originalDB })
|
||||
|
||||
globalDB = nil
|
||||
if err := validateRepairPlayerGearInput(42); err == nil || err.Error() != "not connected" {
|
||||
t.Fatalf("expected not connected error, got %v", err)
|
||||
}
|
||||
|
||||
globalDB = &pgxpool.Pool{}
|
||||
if err := validateRepairPlayerGearInput(0); err == nil || err.Error() != "player ID required" {
|
||||
t.Fatalf("expected player ID required error, got %v", err)
|
||||
}
|
||||
if err := validateRepairPlayerGearInput(42); err != nil {
|
||||
t.Fatalf("expected valid input, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// durText builds a valid pgtype.Text holding a numeric durability string.
|
||||
func durText(s string) pgtype.Text {
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func TestValidateRepairVehicleInput(t *testing.T) {
|
||||
originalDB := globalDB
|
||||
t.Cleanup(func() { globalDB = originalDB })
|
||||
|
||||
globalDB = nil
|
||||
if err := validateRepairVehicleInput(42); err == nil || err.Error() != "not connected" {
|
||||
t.Fatalf("expected not connected error, got %v", err)
|
||||
}
|
||||
|
||||
globalDB = &pgxpool.Pool{}
|
||||
if err := validateRepairVehicleInput(0); err == nil || err.Error() != "player ID required" {
|
||||
t.Fatalf("expected player ID required error, got %v", err)
|
||||
}
|
||||
if err := validateRepairVehicleInput(42); err != nil {
|
||||
t.Fatalf("expected valid input, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleModuleRepairTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
module vehicleModule
|
||||
wantTgt float64
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "in-row MaxDurability is the source of truth (regression: was halved by catalog)",
|
||||
module: vehicleModule{maxDurability: durText("12000"), currentDurability: durText("6000"), decayedDurability: durText("6000")},
|
||||
wantTgt: 12000,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "no in-row MaxDurability is skipped, never guessed from catalog",
|
||||
module: vehicleModule{maxDurability: pgtype.Text{}, currentDurability: durText("6000")},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "unparseable MaxDurability is skipped",
|
||||
module: vehicleModule{maxDurability: durText("oops")},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "zero MaxDurability is skipped",
|
||||
module: vehicleModule{maxDurability: durText("0")},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "never lowers an existing higher value",
|
||||
module: vehicleModule{maxDurability: durText("100"), currentDurability: durText("80"), decayedDurability: durText("150")},
|
||||
wantTgt: 150,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
target, ok := vehicleModuleRepairTarget(tt.module)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("expected ok=%v, got ok=%v (target=%v)", tt.wantOK, ok, target)
|
||||
}
|
||||
if ok && target != tt.wantTgt {
|
||||
t.Fatalf("expected target=%v, got %v", tt.wantTgt, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleModuleAtTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
full := vehicleModule{currentDurability: durText("12000"), decayedDurability: durText("12000")}
|
||||
if !vehicleModuleAtTarget(full, 12000) {
|
||||
t.Fatalf("expected full module to be at target")
|
||||
}
|
||||
|
||||
damaged := vehicleModule{currentDurability: durText("6000"), decayedDurability: durText("6000")}
|
||||
if vehicleModuleAtTarget(damaged, 12000) {
|
||||
t.Fatalf("expected damaged module to not be at target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVehicleModuleRepairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
modules := []vehicleModule{
|
||||
{id: 1, maxDurability: durText("12000"), currentDurability: durText("6000"), decayedDurability: durText("6000")}, // repair → 12000
|
||||
{id: 2, maxDurability: pgtype.Text{}, currentDurability: durText("6000")}, // skip (no in-row max)
|
||||
{id: 3, maxDurability: durText("9000"), currentDurability: durText("9000"), decayedDurability: durText("9000")}, // full → no-op
|
||||
{id: 4, maxDurability: durText("12000"), currentDurability: durText("0"), decayedDurability: durText("12000")}, // repair → 12000
|
||||
}
|
||||
|
||||
var repairedIDs []int64
|
||||
summary := runVehicleModuleRepairs(modules, func(module vehicleModule, target float64) error {
|
||||
if target != 12000 {
|
||||
t.Fatalf("expected target 12000, got %v for module %d", target, module.id)
|
||||
}
|
||||
repairedIDs = append(repairedIDs, module.id)
|
||||
return nil
|
||||
})
|
||||
if summary.err != nil {
|
||||
t.Fatalf("unexpected error: %v", summary.err)
|
||||
}
|
||||
if summary.total != 4 || summary.repaired != 2 || summary.skipped != 1 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
if len(repairedIDs) != 2 || repairedIDs[0] != 1 || repairedIDs[1] != 4 {
|
||||
t.Fatalf("unexpected repaired IDs: %v", repairedIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVehicleModuleRepairs_StopsOnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
modules := []vehicleModule{
|
||||
{id: 10, maxDurability: durText("125"), currentDurability: durText("0"), decayedDurability: durText("0")},
|
||||
{id: 11, maxDurability: durText("125"), currentDurability: durText("0"), decayedDurability: durText("0")},
|
||||
{id: 12, maxDurability: pgtype.Text{}},
|
||||
}
|
||||
|
||||
failErr := errors.New("boom")
|
||||
summary := runVehicleModuleRepairs(modules, func(module vehicleModule, _ float64) error {
|
||||
if module.id == 11 {
|
||||
return failErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if summary.err == nil {
|
||||
t.Fatalf("expected repair error")
|
||||
}
|
||||
if summary.err.Error() != "repair module 11: boom" {
|
||||
t.Fatalf("unexpected error: %v", summary.err)
|
||||
}
|
||||
if summary.total != 3 || summary.repaired != 1 || summary.skipped != 0 {
|
||||
t.Fatalf("unexpected summary after failure: %+v", summary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateContractMutationInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accountID int64
|
||||
contracts []string
|
||||
wantError string
|
||||
}{
|
||||
{name: "valid", accountID: 10, contracts: []string{"DA_CT_A"}},
|
||||
{name: "missing-account", accountID: 0, contracts: []string{"DA_CT_A"}, wantError: "account ID required"},
|
||||
{name: "missing-contracts", accountID: 10, contracts: nil, wantError: "at least one contract required"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateContractMutationInput(tt.accountID, tt.contracts)
|
||||
if tt.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || err.Error() != tt.wantError {
|
||||
t.Fatalf("expected error %q, got %v", tt.wantError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContractRemovalSet(t *testing.T) {
|
||||
originalTagsData := tagsData
|
||||
tagsData = tagsDataFile{
|
||||
ContractAliases: map[string]string{
|
||||
"shortA": "DA_CT_A",
|
||||
},
|
||||
ContractTags: map[string][]string{
|
||||
"DA_CT_A": {"Tag.A", "Tag.B"},
|
||||
"DA_CT_B": {"Tag.B", "Tag.C"},
|
||||
},
|
||||
ContractSkillGrants: map[string][]string{
|
||||
"DA_CT_A": {"Skills.Key.A", "Skills.Key.B"},
|
||||
"DA_CT_B": {"Skills.Key.B", "Skills.Key.C"},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() { tagsData = originalTagsData })
|
||||
|
||||
set, err := buildContractRemovalSet([]string{"shortA", "DA_CT_B", "shortA"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
wantResolved := []string{"DA_CT_A", "DA_CT_B", "DA_CT_A"}
|
||||
if !reflect.DeepEqual(set.resolvedNames, wantResolved) {
|
||||
t.Fatalf("unexpected resolved names: %#v", set.resolvedNames)
|
||||
}
|
||||
|
||||
wantTags := []string{"Tag.A", "Tag.B", "Tag.C"}
|
||||
if !reflect.DeepEqual(set.removeTags, wantTags) {
|
||||
t.Fatalf("unexpected remove tags: %#v", set.removeTags)
|
||||
}
|
||||
|
||||
wantSkills := []string{"Skills.Key.A", "Skills.Key.B", "Skills.Key.C"}
|
||||
if !reflect.DeepEqual(set.removeSkills, wantSkills) {
|
||||
t.Fatalf("unexpected remove skills: %#v", set.removeSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContractRemovalSet_UnknownContract(t *testing.T) {
|
||||
originalTagsData := tagsData
|
||||
tagsData = tagsDataFile{
|
||||
ContractAliases: map[string]string{},
|
||||
ContractTags: map[string][]string{},
|
||||
}
|
||||
t.Cleanup(func() { tagsData = originalTagsData })
|
||||
|
||||
_, err := buildContractRemovalSet([]string{"missing"})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown contract error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractBatchSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := contractBatchSummary([]string{"DA_CT_SINGLE"}); got != "DA_CT_SINGLE" {
|
||||
t.Fatalf("unexpected single summary: %q", got)
|
||||
}
|
||||
if got := contractBatchSummary([]string{"A", "B"}); got != "2 contracts" {
|
||||
t.Fatalf("unexpected multi summary: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveContractTags_NoTagsIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := removeContractTags(context.Background(), 123, nil); err != nil {
|
||||
t.Fatalf("expected no-op nil tags, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripContractSkillBlocks_NoopCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if stripped, err := stripContractSkillBlocks(context.Background(), 0, []string{"Skills.Key.A"}); err != nil || stripped != 0 {
|
||||
t.Fatalf("expected pawn 0 no-op, stripped=%d err=%v", stripped, err)
|
||||
}
|
||||
if stripped, err := stripContractSkillBlocks(context.Background(), 10, nil); err != nil || stripped != 0 {
|
||||
t.Fatalf("expected empty skills no-op, stripped=%d err=%v", stripped, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyContractSkillGrants_NoSkillsIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extra, err := applyContractSkillGrants(context.Background(), 123, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if extra != "" {
|
||||
t.Fatalf("expected empty extra string, got %q", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractShortNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := contractShortNames([]string{"DA_CT_Trainer", "NoPrefix"})
|
||||
want := []string{"Trainer", "NoPrefix"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected short names: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSQLRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
row := formatSQLRow([]any{int64(7), "name", nil})
|
||||
if row != "7 │ name │ <nil>" {
|
||||
t.Fatalf("unexpected row format: %q", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSQLStringRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in [][]any
|
||||
want [][]string
|
||||
}{
|
||||
{
|
||||
name: "integers and strings convert to string representation",
|
||||
in: [][]any{{int64(1), "alpha"}, {int64(2), "beta"}},
|
||||
want: [][]string{{"1", "alpha"}, {"2", "beta"}},
|
||||
},
|
||||
{
|
||||
name: "nil becomes <nil>",
|
||||
in: [][]any{{nil, "x"}},
|
||||
want: [][]string{{"<nil>", "x"}},
|
||||
},
|
||||
{
|
||||
name: "empty input returns empty slice",
|
||||
in: [][]any{},
|
||||
want: [][]string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := formatSQLStringRows(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len=%d want=%d", len(got), len(tt.want))
|
||||
}
|
||||
for i, row := range got {
|
||||
if len(row) != len(tt.want[i]) {
|
||||
t.Fatalf("row %d: len=%d want=%d", i, len(row), len(tt.want[i]))
|
||||
}
|
||||
for j, cell := range row {
|
||||
if cell != tt.want[i][j] {
|
||||
t.Fatalf("[%d][%d]: got %q want %q", i, j, cell, tt.want[i][j])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSQLResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := buildSQLResult(
|
||||
[]string{"id", "name"},
|
||||
[][]any{
|
||||
{int64(1), "alpha"},
|
||||
{int64(2), "beta"},
|
||||
},
|
||||
false,
|
||||
)
|
||||
if !strings.Contains(result, "id │ name\n") {
|
||||
t.Fatalf("expected header line in result: %q", result)
|
||||
}
|
||||
if !strings.Contains(result, "1 │ alpha\n") || !strings.Contains(result, "2 │ beta\n") {
|
||||
t.Fatalf("expected row lines in result: %q", result)
|
||||
}
|
||||
if strings.Contains(result, "limited to 200 rows") {
|
||||
t.Fatalf("did not expect truncation marker in non-truncated result")
|
||||
}
|
||||
|
||||
truncated := buildSQLResult([]string{"id"}, [][]any{{1}}, true)
|
||||
if !strings.Contains(truncated, "… (limited to 200 rows)\n") {
|
||||
t.Fatalf("expected truncation marker in truncated result: %q", truncated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSampleTableQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
origSchema := dbSchema
|
||||
t.Cleanup(func() { dbSchema = origSchema })
|
||||
dbSchema = "dune"
|
||||
|
||||
query := sampleTableQuery(`items"; DROP TABLE dune.items; --`, 25)
|
||||
want := `SELECT * FROM "dune"."items""; DROP TABLE dune.items; --" LIMIT 25`
|
||||
if query != want {
|
||||
t.Fatalf("unexpected query sanitization\nwant: %q\ngot: %q", want, query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSampleRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
row := formatSampleRow([]any{int64(1), "alpha", nil, true})
|
||||
want := []string{"1", "alpha", "<nil>", "true"}
|
||||
if len(row) != len(want) {
|
||||
t.Fatalf("unexpected row length: got %d want %d", len(row), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if row[i] != want[i] {
|
||||
t.Fatalf("unexpected row[%d]: got %q want %q", i, row[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveStarterClassAbility(t *testing.T) {
|
||||
originalTagsData := tagsData
|
||||
t.Cleanup(func() { tagsData = originalTagsData })
|
||||
|
||||
tagsData = tagsDataFile{
|
||||
JobSkillBlocks: map[string][]string{
|
||||
"Trooper": {"Skills.Key.Trooper1"},
|
||||
},
|
||||
}
|
||||
|
||||
ability, err := resolveStarterClassAbility("Trooper")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error resolving known job: %v", err)
|
||||
}
|
||||
if ability != "Skills.Ability.SuspensorGrenade_Reduction" {
|
||||
t.Fatalf("unexpected starter ability: %q", ability)
|
||||
}
|
||||
|
||||
if _, err := resolveStarterClassAbility("Unknown"); err == nil {
|
||||
t.Fatalf("expected error for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarterKeysToRemove(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if keys := starterKeysToRemove("", "Trooper"); len(keys) != 0 {
|
||||
t.Fatalf("expected no keys when old starter is empty, got %#v", keys)
|
||||
}
|
||||
if keys := starterKeysToRemove("Skills.Key.Trooper1", "Trooper"); len(keys) != 0 {
|
||||
t.Fatalf("expected no keys when switching to same job, got %#v", keys)
|
||||
}
|
||||
|
||||
keys := starterKeysToRemove("Skills.Key.Mentat1", "Trooper")
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("expected 2 keys to remove, got %#v", keys)
|
||||
}
|
||||
if keys[0] != `(TagName="Skills.Key.Mentat1")` {
|
||||
t.Fatalf("unexpected starter key removal: %q", keys[0])
|
||||
}
|
||||
if keys[1] != `(TagName="Skills.Ability.PoisonCapsuleLauncher")` {
|
||||
t.Fatalf("unexpected ability key removal: %q", keys[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarterClassTagAndKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tag, starterKey, abilityKey := starterClassTagAndKeys("Trooper", "Skills.Ability.SuspensorGrenade_Reduction")
|
||||
if tag != "Skills.Key.Trooper1" {
|
||||
t.Fatalf("unexpected starter tag: %q", tag)
|
||||
}
|
||||
if starterKey != `(TagName="Skills.Key.Trooper1")` {
|
||||
t.Fatalf("unexpected starter key: %q", starterKey)
|
||||
}
|
||||
if abilityKey != `(TagName="Skills.Ability.SuspensorGrenade_Reduction")` {
|
||||
t.Fatalf("unexpected ability key: %q", abilityKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatStarterClassMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := formatStarterClassMessage("Trooper", "Skills.Key.Trooper1", "Skills.Ability.SuspensorGrenade_Reduction", 2)
|
||||
if !strings.Contains(msg, "Starter class set to Trooper") ||
|
||||
!strings.Contains(msg, "Skills.Key.Trooper1 + Skills.Ability.SuspensorGrenade_Reduction active") ||
|
||||
!strings.Contains(msg, "cleared previous starter (2 module(s))") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSpiceVisionNodeIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// DA_MQ_FindTheFremen and any of its sub-nodes must trigger spice vision.
|
||||
cases := []struct {
|
||||
nodeID string
|
||||
want bool
|
||||
}{
|
||||
{"DA_MQ_FindTheFremen", true},
|
||||
{"DA_MQ_FindTheFremen.FourthTest", true},
|
||||
{"DA_MQ_FindTheFremen.FourthTest.FourthQuestion.CompleteFourthTest", true},
|
||||
{"DA_MQ_FindTheFremen.Epilogue", true},
|
||||
{"DA_MQ_ANewBeginning", false},
|
||||
{"DA_MQ_ANewBeginning.Aql No 1.FabricateStillsuit.Equip the Stillsuit", false},
|
||||
{"DA_SQ_VermiliusGap", false},
|
||||
{"DA_FQ_ClimbTheRanks", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := nodeIDTriggersSpiceVision(tc.nodeID)
|
||||
if got != tc.want {
|
||||
t.Errorf("nodeIDTriggersSpiceVision(%q) = %v, want %v", tc.nodeID, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpiceVisionSQL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify the SQL snippet is well-formed and targets the right JSONB path.
|
||||
sql := spiceVisionEnableSQL
|
||||
for _, substr := range []string{
|
||||
"FSpiceAddictionComponent",
|
||||
"SpiceVisionEnabledStatus",
|
||||
"FullyEnabled",
|
||||
"DuneCharacter",
|
||||
} {
|
||||
if !containsSubstring(sql, substr) {
|
||||
t.Errorf("spiceVisionEnableSQL missing %q", substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubstring(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstringHelper(s, sub))
|
||||
}
|
||||
|
||||
func containsSubstringHelper(s, sub string) bool {
|
||||
for i := range s {
|
||||
if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGMSeedSpec locks the recon-derived seed values for the GM/Server persona:
|
||||
// the sentinel ids (collision-free per Phase 0 recon), the exact actor class paths
|
||||
// the live schema uses (or the game's player-info lookup fails and the sender never
|
||||
// renders), and the blast-radius-safe defaults (Offline status; the seed routine
|
||||
// leaves actors.transform NULL so the GM never plots on the live map).
|
||||
func TestGMSeedSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := gmSeedSpec()
|
||||
|
||||
if s.AccountID != gmIdentityAccountID {
|
||||
t.Fatalf("AccountID = %d, want %d", s.AccountID, gmIdentityAccountID)
|
||||
}
|
||||
// Actor ids derive from the account id: 9000001 -> 900000101/02/03.
|
||||
if s.ControllerID != 900000101 || s.StateID != 900000102 || s.PawnID != 900000103 {
|
||||
t.Fatalf("actor ids wrong: %d/%d/%d", s.ControllerID, s.StateID, s.PawnID)
|
||||
}
|
||||
if !strings.Contains(s.ControllerClass, "BP_DunePlayerController") {
|
||||
t.Fatalf("controller class wrong: %s", s.ControllerClass)
|
||||
}
|
||||
if !strings.Contains(s.StateClass, "DunePlayerState") {
|
||||
t.Fatalf("state class wrong: %s", s.StateClass)
|
||||
}
|
||||
if !strings.Contains(s.PawnClass, "BP_DunePlayerCharacter") {
|
||||
t.Fatalf("pawn class wrong: %s", s.PawnClass)
|
||||
}
|
||||
// Blast-radius: Offline keeps the GM out of the online pollers / welcome scanner.
|
||||
if s.OnlineStatus != "Offline" {
|
||||
t.Fatalf("OnlineStatus = %q, want Offline", s.OnlineStatus)
|
||||
}
|
||||
if s.LifeState != "Alive" {
|
||||
t.Fatalf("LifeState = %q, want Alive", s.LifeState)
|
||||
}
|
||||
if s.FuncomID != "GM#0001" || s.CharacterName != "GM" {
|
||||
t.Fatalf("persona wrong: funcom=%q char=%q", s.FuncomID, s.CharacterName)
|
||||
}
|
||||
if s.Map != "HaggaBasin" || s.PartitionID != 1 {
|
||||
t.Fatalf("location wrong: map=%q partition=%d", s.Map, s.PartitionID)
|
||||
}
|
||||
}
|
||||
245
docs/reference-repos/icehunter/cmd/dune-admin/db_market.go
Normal file
245
docs/reference-repos/icehunter/cmd/dune-admin/db_market.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// schematicCategory returns the effective category for a template ID.
|
||||
// Schematics (detected by is_schematic flag or naming conventions) are
|
||||
// reclassified under "schematics/<type>" where <type> is only the first
|
||||
// path segment after "items/" (e.g. "weapons", "utility").
|
||||
// Using a single sub-level prevents mirroring the full items tree under schematics/.
|
||||
//
|
||||
// Naming patterns covered:
|
||||
// - T6_Lasgun_Schematic (suffix _Schematic — regular schematics)
|
||||
// - Schematic_UniqueXxx (prefix Schematic_ — unique/named schematics)
|
||||
// - ChoamHeavyLasgunSchematic (suffix Schematic, no underscore)
|
||||
func schematicCategory(templateID, baseCategory string, isSchematic bool) string {
|
||||
lc := strings.ToLower(templateID)
|
||||
if !isSchematic &&
|
||||
!strings.HasSuffix(lc, "_schematic") &&
|
||||
!strings.HasPrefix(lc, "schematic_") &&
|
||||
!strings.HasSuffix(lc, "schematic") {
|
||||
return baseCategory
|
||||
}
|
||||
rest := strings.TrimPrefix(baseCategory, "items/")
|
||||
if rest == "" || rest == baseCategory {
|
||||
return "schematics"
|
||||
}
|
||||
// Take only the first segment (e.g. "utility" from "utility/gatheringtools/compactor").
|
||||
if idx := strings.Index(rest, "/"); idx != -1 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return "schematics/" + rest
|
||||
}
|
||||
|
||||
// itemRuleLookup returns the itemRule for a template ID, trying exact key then lowercase fallback.
|
||||
func itemRuleLookup(templateID string) (itemRule, bool) {
|
||||
if r, ok := itemData.Items[templateID]; ok {
|
||||
return r, true
|
||||
}
|
||||
if r, ok := itemData.Items[strings.ToLower(templateID)]; ok {
|
||||
return r, true
|
||||
}
|
||||
return itemRule{}, false
|
||||
}
|
||||
|
||||
// itemNameLookup returns the display name for a template ID.
|
||||
func itemNameLookup(templateID string) string {
|
||||
if n := itemData.Names[templateID]; n != "" {
|
||||
return n
|
||||
}
|
||||
if n := itemData.Names[strings.ToLower(templateID)]; n != "" {
|
||||
return n
|
||||
}
|
||||
return templateID
|
||||
}
|
||||
|
||||
// cmdFetchMarketItems returns all active exchange listings aggregated by template ID,
|
||||
// enriched with catalog metadata from item-data.json.
|
||||
func cmdFetchMarketItems() Msg {
|
||||
if globalDB == nil {
|
||||
return msgMarketItems{err: fmt.Errorf("not connected")}
|
||||
}
|
||||
rows, err := globalDB.Query(context.Background(), `
|
||||
SELECT
|
||||
o.template_id,
|
||||
o.quality_level,
|
||||
MIN(o.item_price) AS lowest_price,
|
||||
COALESCE(SUM(COALESCE(i.stack_size, s.initial_stack_size)), 0) AS total_stock,
|
||||
COALESCE(SUM(CASE WHEN o.is_npc_order
|
||||
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS bot_stock,
|
||||
COUNT(*) AS listing_count
|
||||
FROM dune.dune_exchange_orders o
|
||||
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
|
||||
LEFT JOIN dune.items i ON i.id = o.item_id
|
||||
GROUP BY o.template_id, o.quality_level
|
||||
ORDER BY o.template_id, o.quality_level`)
|
||||
if err != nil {
|
||||
return msgMarketItems{err: err}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []marketItem
|
||||
for rows.Next() {
|
||||
var tmpl string
|
||||
var quality, lowestPrice, totalStock, botStock, listingCount int64
|
||||
if err := rows.Scan(&tmpl, &quality, &lowestPrice, &totalStock, &botStock, &listingCount); err != nil {
|
||||
continue
|
||||
}
|
||||
rule, _ := itemRuleLookup(tmpl)
|
||||
cat := schematicCategory(tmpl, rule.Category, rule.IsSchematic)
|
||||
name := itemNameLookup(tmpl)
|
||||
if cat == "schematics" || strings.HasPrefix(cat, "schematics/") {
|
||||
name += " (Schematic)"
|
||||
}
|
||||
items = append(items, marketItem{
|
||||
TemplateID: tmpl,
|
||||
Quality: quality,
|
||||
DisplayName: name,
|
||||
Category: cat,
|
||||
Tier: rule.Tier,
|
||||
Rarity: rule.Rarity,
|
||||
LowestPrice: lowestPrice,
|
||||
TotalStock: totalStock,
|
||||
BotStock: botStock,
|
||||
ListingCount: listingCount,
|
||||
Icon: rule.Icon,
|
||||
})
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return msgMarketItems{err: rows.Err()}
|
||||
}
|
||||
return msgMarketItems{rows: items}
|
||||
}
|
||||
|
||||
// cmdFetchMarketListings returns all active exchange listings, optionally filtered by template ID.
|
||||
// Pass templateID="" to fetch all listings.
|
||||
func cmdFetchMarketListings(templateID string) Msg {
|
||||
if globalDB == nil {
|
||||
return msgMarketListings{err: fmt.Errorf("not connected")}
|
||||
}
|
||||
|
||||
var args []any
|
||||
where := ""
|
||||
if templateID != "" {
|
||||
where = "WHERE o.template_id = $1"
|
||||
args = append(args, templateID)
|
||||
}
|
||||
|
||||
rows, err := globalDB.Query(context.Background(), `
|
||||
SELECT
|
||||
o.id,
|
||||
o.template_id,
|
||||
o.is_npc_order,
|
||||
COALESCE(ps.character_name, a.class, 'Unknown') AS owner_name,
|
||||
o.item_price,
|
||||
COALESCE(i.stack_size, s.initial_stack_size) AS stock,
|
||||
COALESCE(o.quality_level, 0) AS quality
|
||||
FROM dune.dune_exchange_orders o
|
||||
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
|
||||
LEFT JOIN dune.items i ON i.id = o.item_id
|
||||
LEFT JOIN dune.actors a ON a.id = o.owner_id
|
||||
LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id
|
||||
`+where+`
|
||||
ORDER BY o.template_id, o.item_price`, args...)
|
||||
if err != nil {
|
||||
return msgMarketListings{err: err}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var listings []marketListing
|
||||
for rows.Next() {
|
||||
var l marketListing
|
||||
var isNPC bool
|
||||
if err := rows.Scan(&l.OrderID, &l.TemplateID, &isNPC, &l.OwnerName, &l.Price, &l.Stock, &l.Quality); err != nil {
|
||||
continue
|
||||
}
|
||||
if isNPC {
|
||||
l.OwnerType = "bot"
|
||||
l.OwnerName = "Revy"
|
||||
} else {
|
||||
l.OwnerType = "player"
|
||||
}
|
||||
listings = append(listings, l)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return msgMarketListings{err: rows.Err()}
|
||||
}
|
||||
return msgMarketListings{rows: listings}
|
||||
}
|
||||
|
||||
// cmdFetchMarketSales returns recent sales from bot listings (players buying from Revy).
|
||||
func cmdFetchMarketSales() Msg {
|
||||
if globalDB == nil {
|
||||
return msgMarketSales{err: fmt.Errorf("not connected")}
|
||||
}
|
||||
rows, err := globalDB.Query(context.Background(), `
|
||||
SELECT
|
||||
f.order_id,
|
||||
o.template_id,
|
||||
o.is_npc_order,
|
||||
COALESCE(ps.character_name, a.class, 'Unknown') AS seller_name,
|
||||
o.item_price,
|
||||
f.stack_size
|
||||
FROM dune.dune_exchange_fulfilled_orders f
|
||||
JOIN dune.dune_exchange_orders o ON o.id = f.order_id
|
||||
LEFT JOIN dune.actors a ON a.id = o.owner_id
|
||||
LEFT JOIN dune.player_state ps ON ps.account_id = a.owner_account_id
|
||||
ORDER BY f.order_id DESC
|
||||
LIMIT 200`)
|
||||
if err != nil {
|
||||
return msgMarketSales{err: err}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sales []marketSale
|
||||
for rows.Next() {
|
||||
var s marketSale
|
||||
var isNPC bool
|
||||
if err := rows.Scan(&s.OrderID, &s.TemplateID, &isNPC, &s.SellerName, &s.Price, &s.Quantity); err != nil {
|
||||
continue
|
||||
}
|
||||
if isNPC {
|
||||
s.SellerType = "bot"
|
||||
s.SellerName = "Revy"
|
||||
} else {
|
||||
s.SellerType = "player"
|
||||
}
|
||||
sales = append(sales, s)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return msgMarketSales{err: rows.Err()}
|
||||
}
|
||||
return msgMarketSales{rows: sales}
|
||||
}
|
||||
|
||||
// cmdFetchMarketStats returns aggregate market statistics.
|
||||
func cmdFetchMarketStats() Msg {
|
||||
if globalDB == nil {
|
||||
return msgMarketStats{err: fmt.Errorf("not connected")}
|
||||
}
|
||||
var stats marketStats
|
||||
err := globalDB.QueryRow(context.Background(), `
|
||||
SELECT
|
||||
COUNT(*) AS total_listings,
|
||||
COUNT(*) FILTER (WHERE o.is_npc_order) AS bot_listings,
|
||||
COUNT(*) FILTER (WHERE NOT o.is_npc_order) AS player_listings,
|
||||
COALESCE(SUM(COALESCE(i.stack_size, s.initial_stack_size)), 0) AS total_stock,
|
||||
COALESCE(SUM(CASE WHEN o.is_npc_order
|
||||
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS bot_stock,
|
||||
COALESCE(SUM(CASE WHEN NOT o.is_npc_order
|
||||
THEN COALESCE(i.stack_size, s.initial_stack_size) ELSE 0 END), 0) AS player_stock,
|
||||
COUNT(DISTINCT o.template_id) AS unique_items
|
||||
FROM dune.dune_exchange_orders o
|
||||
JOIN dune.dune_exchange_sell_orders s ON s.order_id = o.id
|
||||
LEFT JOIN dune.items i ON i.id = o.item_id`).
|
||||
Scan(&stats.TotalListings, &stats.BotListings, &stats.PlayerListings,
|
||||
&stats.TotalStock, &stats.BotStock, &stats.PlayerStock, &stats.UniqueItems)
|
||||
if err != nil {
|
||||
return msgMarketStats{err: err}
|
||||
}
|
||||
return msgMarketStats{stats: stats}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dialRecordingExecutor is an Executor whose Dial records the requested address
|
||||
// and then connects to a fixed target instead. It lets tests prove that HTTP
|
||||
// traffic is routed through the executor: the requested address is unreachable,
|
||||
// so a successful response can only have arrived via the redirected dial.
|
||||
type dialRecordingExecutor struct {
|
||||
psOut string
|
||||
target string // real address Dial connects to, regardless of requested addr
|
||||
dialAddr string // requested address, recorded for assertions
|
||||
}
|
||||
|
||||
func (e *dialRecordingExecutor) Exec(string) (string, error) { return e.psOut, nil }
|
||||
func (e *dialRecordingExecutor) Stream(string) (<-chan string, func(), error) {
|
||||
return nil, func() {}, nil
|
||||
}
|
||||
func (e *dialRecordingExecutor) PipeToWriter(string, io.Writer) error { return nil }
|
||||
func (e *dialRecordingExecutor) WriteFile(string, io.Reader) error { return nil }
|
||||
func (e *dialRecordingExecutor) Dial(network, addr string) (net.Conn, error) {
|
||||
e.dialAddr = addr
|
||||
return net.Dial(network, e.target)
|
||||
}
|
||||
func (e *dialRecordingExecutor) Close() {}
|
||||
func (e *dialRecordingExecutor) Type() string { return "ssh" }
|
||||
|
||||
// TestHTTPTransportVia_UsesProvidedDialer verifies the transport built by
|
||||
// httpTransportVia establishes every connection through the supplied dialer,
|
||||
// not via a direct dial to the request's host.
|
||||
func TestHTTPTransportVia_UsesProvidedDialer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "reached-backend")
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
var dialedAddr string
|
||||
transport := httpTransportVia(func(network, addr string) (net.Conn, error) {
|
||||
dialedAddr = addr
|
||||
return net.Dial(network, backend.Listener.Addr().String())
|
||||
})
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
// director.invalid is unresolvable: success proves the dialer was used.
|
||||
resp, err := client.Get("http://director.invalid:11717/v0/battlegroup")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != "reached-backend" {
|
||||
t.Errorf("body = %q, want %q", body, "reached-backend")
|
||||
}
|
||||
if dialedAddr != "director.invalid:11717" {
|
||||
t.Errorf("dialed addr = %q, want %q", dialedAddr, "director.invalid:11717")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDirectorProxy_RoutesThroughDialer verifies the /director/ reverse
|
||||
// proxy strips the /director prefix and routes the upstream connection through
|
||||
// the supplied dialer (the executor tunnel).
|
||||
func TestNewDirectorProxy_RoutesThroughDialer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "path="+r.URL.Path)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
target, err := url.Parse("http://director.invalid:11717")
|
||||
if err != nil {
|
||||
t.Fatalf("parse target: %v", err)
|
||||
}
|
||||
var dialedAddr string
|
||||
handler := newDirectorProxy(target, func(network, addr string) (net.Conn, error) {
|
||||
dialedAddr = addr
|
||||
return net.Dial(network, backend.Listener.Addr().String())
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/director/v0/battlegroup", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Body.String(); got != "path=/v0/battlegroup" {
|
||||
t.Errorf("backend saw %q, want %q (prefix not stripped?)", got, "path=/v0/battlegroup")
|
||||
}
|
||||
if dialedAddr != "director.invalid:11717" {
|
||||
t.Errorf("dialed addr = %q, want %q", dialedAddr, "director.invalid:11717")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialThroughExecutor verifies the shared dialer routes through the active
|
||||
// executor when set and falls back to a direct dial when it is nil.
|
||||
func TestDialThroughExecutor(t *testing.T) {
|
||||
// Not parallel: mutates the globalExecutor package global.
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer backend.Close()
|
||||
addr := backend.Listener.Addr().String()
|
||||
|
||||
saved := globalExecutor
|
||||
defer func() { globalExecutor = saved }()
|
||||
|
||||
// nil executor → direct dial succeeds against the listening backend.
|
||||
globalExecutor = nil
|
||||
conn, err := dialThroughExecutor("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("direct dial failed: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
// executor set → unreachable addr still connects, routed through executor.
|
||||
rec := &dialRecordingExecutor{target: addr}
|
||||
globalExecutor = rec
|
||||
conn2, err := dialThroughExecutor("tcp", "director.invalid:11717")
|
||||
if err != nil {
|
||||
t.Fatalf("executor dial failed: %v", err)
|
||||
}
|
||||
_ = conn2.Close()
|
||||
if rec.dialAddr != "director.invalid:11717" {
|
||||
t.Errorf("executor dialed %q, want %q", rec.dialAddr, "director.invalid:11717")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAmpGetStatus_DirectorDialsThroughExecutor proves director enrichment in
|
||||
// GetStatus reaches the director through the executor's tunnel: the configured
|
||||
// director URL is unresolvable, so enrichment can only succeed via the
|
||||
// executor's redirected dial.
|
||||
func TestAmpGetStatus_DirectorDialsThroughExecutor(t *testing.T) {
|
||||
// Not parallel: GetStatus reads the globalDB package global.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v0/battlegroup" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, directorBattlegroupJSON)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := &dialRecordingExecutor{
|
||||
psOut: psLineFor(1001, "Overmap", 7794, 2),
|
||||
target: srv.Listener.Addr().String(),
|
||||
}
|
||||
c := &Control{
|
||||
container: "AMP_X",
|
||||
useContainer: false,
|
||||
directorURL: "http://director.invalid:11717",
|
||||
}
|
||||
status, err := c.GetStatus(t.Context(), exec)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus: %v", err)
|
||||
}
|
||||
if len(status.Servers) != 1 {
|
||||
t.Fatalf("got %d servers, want 1", len(status.Servers))
|
||||
}
|
||||
row := status.Servers[0]
|
||||
if row.Partition != 2 || row.Sietch != "Overland" || row.Players != 5 {
|
||||
t.Errorf("row = %+v, want partition 2 sietch Overland players 5 (director enrichment via executor)", row)
|
||||
}
|
||||
if exec.dialAddr != "director.invalid:11717" {
|
||||
t.Errorf("director dialed %q, want %q (not routed through executor)", exec.dialAddr, "director.invalid:11717")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !embed
|
||||
|
||||
package main
|
||||
|
||||
import "net/http"
|
||||
|
||||
func embeddedSPAFS() http.FileSystem { return nil }
|
||||
21
docs/reference-repos/icehunter/cmd/dune-admin/embed_prod.go
Normal file
21
docs/reference-repos/icehunter/cmd/dune-admin/embed_prod.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build embed
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embeddedDist embed.FS
|
||||
|
||||
func embeddedSPAFS() http.FileSystem {
|
||||
sub, err := fs.Sub(embeddedDist, "dist")
|
||||
if err != nil {
|
||||
log.Fatal("embedded dist is malformed:", err)
|
||||
}
|
||||
return http.FS(sub)
|
||||
}
|
||||
240
docs/reference-repos/icehunter/cmd/dune-admin/executor.go
Normal file
240
docs/reference-repos/icehunter/cmd/dune-admin/executor.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// dialThroughExecutor establishes a TCP connection through the active executor
|
||||
// (an SSH tunnel when configured), falling back to a direct dial when no
|
||||
// executor is set. This is the same dial path used for the DB pool and the
|
||||
// RabbitMQ brokers, letting HTTP clients reach hosts reachable from wherever
|
||||
// the executor runs (e.g. the AMP box over SSH) rather than the machine
|
||||
// dune-admin runs on.
|
||||
func dialThroughExecutor(network, addr string) (net.Conn, error) {
|
||||
if globalExecutor != nil {
|
||||
return globalExecutor.Dial(network, addr)
|
||||
}
|
||||
return net.Dial(network, addr)
|
||||
}
|
||||
|
||||
// httpTransportVia returns an *http.Transport that establishes every connection
|
||||
// through dial. It clones http.DefaultTransport so timeouts and connection
|
||||
// pooling match the stdlib defaults; only the dial path is overridden. Used to
|
||||
// tunnel director HTTP traffic through the executor.
|
||||
func httpTransportVia(dial func(network, addr string) (net.Conn, error)) *http.Transport {
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dial(network, addr)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Executor abstracts where commands run and how TCP connections are made.
|
||||
// localExecutor runs everything on the same machine; sshExecutor tunnels
|
||||
// through an SSH connection to a remote host.
|
||||
type Executor interface {
|
||||
Exec(cmd string) (string, error)
|
||||
Stream(cmd string) (<-chan string, func(), error)
|
||||
PipeToWriter(cmd string, w io.Writer) error
|
||||
WriteFile(path string, data io.Reader) error
|
||||
Dial(network, addr string) (net.Conn, error)
|
||||
Close()
|
||||
// Type returns "local" or "ssh" for status reporting.
|
||||
Type() string
|
||||
}
|
||||
|
||||
// newExecutor returns an sshExecutor when sshHost is non-empty, otherwise
|
||||
// a localExecutor. The SSH connection is established immediately; the error
|
||||
// must be checked before using the executor.
|
||||
func newExecutor(sshHost, sshUser, sshKeyPath string) (Executor, error) {
|
||||
if sshHost == "" {
|
||||
return &localExecutor{}, nil
|
||||
}
|
||||
client, err := dialSSH(sshHost, sshUser, sshKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sshExecutor{client: client}, nil
|
||||
}
|
||||
|
||||
// ── SSH executor ──────────────────────────────────────────────────────────────
|
||||
|
||||
type sshExecutor struct {
|
||||
client *ssh.Client
|
||||
}
|
||||
|
||||
func (e *sshExecutor) Type() string { return "ssh" }
|
||||
|
||||
func (e *sshExecutor) Close() {
|
||||
if e.client != nil {
|
||||
_ = e.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *sshExecutor) Exec(cmd string) (string, error) {
|
||||
sess, err := e.client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
out, err := sess.CombinedOutput(cmd)
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func (e *sshExecutor) Stream(cmd string) (<-chan string, func(), error) {
|
||||
sess, err := e.client.NewSession()
|
||||
if err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
pipe, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = sess.Close()
|
||||
return nil, func() {}, err
|
||||
}
|
||||
if err := sess.Start(cmd); err != nil {
|
||||
_ = sess.Close()
|
||||
return nil, func() {}, err
|
||||
}
|
||||
ch := make(chan string, 256)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
sc := bufio.NewScanner(pipe)
|
||||
for sc.Scan() {
|
||||
ch <- sc.Text()
|
||||
}
|
||||
_ = sess.Wait()
|
||||
}()
|
||||
return ch, func() { _ = sess.Close() }, nil
|
||||
}
|
||||
|
||||
func (e *sshExecutor) PipeToWriter(cmd string, w io.Writer) error {
|
||||
sess, err := e.client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
sess.Stdout = w
|
||||
return sess.Run(cmd)
|
||||
}
|
||||
|
||||
func (e *sshExecutor) WriteFile(path string, data io.Reader) error {
|
||||
sess, err := e.client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sess.Start(fmt.Sprintf("sudo tee %s > /dev/null", shellQuote(path))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = stdin.Close()
|
||||
return sess.Wait()
|
||||
}
|
||||
|
||||
func (e *sshExecutor) Dial(network, addr string) (net.Conn, error) {
|
||||
return e.client.Dial(network, addr)
|
||||
}
|
||||
|
||||
// ── Local executor ────────────────────────────────────────────────────────────
|
||||
|
||||
type localExecutor struct{}
|
||||
|
||||
func (e *localExecutor) Type() string { return "local" }
|
||||
func (e *localExecutor) Close() {}
|
||||
|
||||
func (e *localExecutor) Exec(cmd string) (string, error) {
|
||||
c := exec.Command("sh", "-c", cmd)
|
||||
var buf bytes.Buffer
|
||||
c.Stdout = &buf
|
||||
c.Stderr = &buf
|
||||
err := c.Run()
|
||||
return strings.TrimSpace(buf.String()), err
|
||||
}
|
||||
|
||||
func (e *localExecutor) Stream(cmd string) (<-chan string, func(), error) {
|
||||
c := exec.Command("sh", "-c", cmd)
|
||||
pipe, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Start(); err != nil {
|
||||
return nil, func() {}, err
|
||||
}
|
||||
ch := make(chan string, 256)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
sc := bufio.NewScanner(pipe)
|
||||
for sc.Scan() {
|
||||
ch <- sc.Text()
|
||||
}
|
||||
_ = c.Wait()
|
||||
}()
|
||||
cancel := func() {
|
||||
if c.Process != nil {
|
||||
_ = c.Process.Kill()
|
||||
}
|
||||
}
|
||||
return ch, cancel, nil
|
||||
}
|
||||
|
||||
func (e *localExecutor) PipeToWriter(cmd string, w io.Writer) error {
|
||||
c := exec.Command("sh", "-c", cmd) // #nosec G702 -- all callers build cmd via shellQuote
|
||||
c.Stdout = w
|
||||
var errBuf bytes.Buffer
|
||||
c.Stderr = &errBuf
|
||||
return c.Run()
|
||||
}
|
||||
|
||||
func (e *localExecutor) WriteFile(path string, data io.Reader) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) // #nosec G304 -- path comes from admin config
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
_, err = io.Copy(f, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *localExecutor) Dial(network, addr string) (net.Conn, error) {
|
||||
return net.Dial(network, addr)
|
||||
}
|
||||
|
||||
// ── SSH dialer (used by newExecutor and setup wizard) ─────────────────────────
|
||||
|
||||
func dialSSH(host, user, keyPath string) (*ssh.Client, error) {
|
||||
keyData, err := os.ReadFile(keyPath) // #nosec G304 -- keyPath is admin-supplied config
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read key %s: %w", keyPath, err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(keyData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
client, err := ssh.Dial("tcp", host, &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // #nosec G106 -- private admin tool, known host
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH dial %s: %w", host, err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ampExecutor wraps any Executor with sudo-elevated file writes. The dune-admin
|
||||
// process typically runs as a non-AMP user (e.g. mehdune) and cannot write
|
||||
// UserGame.ini directly — that file is owned by the AMP user. WriteFile pipes
|
||||
// content through `sudo -i -u <ampUser> tee`, which the sudoers grant allows.
|
||||
//
|
||||
// All other methods delegate to the inner executor unchanged, so ampExecutor
|
||||
// works whether the inner executor is a localExecutor or an sshExecutor.
|
||||
type ampExecutor struct {
|
||||
Executor // inner: *localExecutor or *sshExecutor
|
||||
ampUser string // OS user to write files as (default "amp")
|
||||
}
|
||||
|
||||
func (e *ampExecutor) Type() string { return "amp" }
|
||||
|
||||
func (e *ampExecutor) WriteFile(path string, data io.Reader) error {
|
||||
if e.ampUser == "" {
|
||||
return fmt.Errorf("amp executor requires amp_user to be configured")
|
||||
}
|
||||
cleanPath := filepath.Clean(path)
|
||||
if !filepath.IsAbs(cleanPath) {
|
||||
return fmt.Errorf("WriteFile path must be absolute: %s", path)
|
||||
}
|
||||
path = cleanPath
|
||||
cmd := fmt.Sprintf("sudo -i -u %s tee %s > /dev/null", shellQuote(e.ampUser), shellQuote(path))
|
||||
if sshExec, ok := e.Executor.(*sshExecutor); ok {
|
||||
sess, err := sshExec.client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = sess.Close() }()
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errBuf bytes.Buffer
|
||||
sess.Stderr = &errBuf
|
||||
if err := sess.Start(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = stdin.Close()
|
||||
if err := sess.Wait(); err != nil {
|
||||
return fmt.Errorf("sudo tee %s: %w — %s", path, err, errBuf.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
c := exec.Command("sudo", "-i", "-u", e.ampUser, "tee", path) // #nosec G204,G702 -- args passed as slice (no shell); ampUser and path are admin-supplied config
|
||||
c.Stdin = data
|
||||
c.Stdout = io.Discard
|
||||
var errBuf bytes.Buffer
|
||||
c.Stderr = &errBuf
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sudo tee %s: %w — %s", path, err, errBuf.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dial delegates to the inner executor so SSH-tunnelled TCP connections work.
|
||||
func (e *ampExecutor) Dial(network, addr string) (net.Conn, error) {
|
||||
return e.Executor.Dial(network, addr)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Code generated by dune-item-data/build-fillables-gen.sh. DO NOT EDIT.
|
||||
|
||||
package main
|
||||
|
||||
// waterFillableTemplates is the set of lowercase item template IDs for water-only
|
||||
// fillable containers. Used by cmdRefillWaterOffline to target the correct items.
|
||||
// Source: DT_ItemTableFillables.json (FillableTypeRestriction = Water).
|
||||
var waterFillableTemplates = []string{
|
||||
"advancedstillsuit",
|
||||
"combat_nati_fremenexile04_top",
|
||||
"decajon",
|
||||
"dewpack",
|
||||
"highcapacityliterjon",
|
||||
"highcapacityliterjon_02",
|
||||
"highcapacityliterjon_03",
|
||||
"highcapacityliterjon_04",
|
||||
"highcapacityliterjon_05",
|
||||
"highcapacityliterjon_06",
|
||||
"literjon",
|
||||
"literjon_03",
|
||||
"literjon_04",
|
||||
"literjon_05",
|
||||
"literjon_06",
|
||||
"literjon_07",
|
||||
"literjon_08",
|
||||
"literjon_09",
|
||||
"literjon_t6",
|
||||
"simplestillsuit",
|
||||
"stillsuit_choam_01_top",
|
||||
"stillsuit_choam_02_top",
|
||||
"stillsuit_choam_04_top",
|
||||
"stillsuit_choam_05_top",
|
||||
"stillsuit_choam_06_top",
|
||||
"stillsuit_choam_unique_dashed02_top",
|
||||
"stillsuit_choam_unique_dashed03_top",
|
||||
"stillsuit_choam_unique_dashed04_top",
|
||||
"stillsuit_choam_unique_dashed05_top",
|
||||
"stillsuit_choam_unique_dashed06_top",
|
||||
"stillsuit_nati_05_body",
|
||||
"stillsuit_nati_06_body",
|
||||
"stillsuit_nati_07_body",
|
||||
"stillsuit_nati_08_body",
|
||||
"stillsuit_nati_arrakeen05_body",
|
||||
"stillsuit_neut_leaking01_top",
|
||||
"stillsuit_neut_patchy02_top",
|
||||
"stillsuit_unique_armored_01_top",
|
||||
"stillsuit_unique_armored_02_top",
|
||||
"stillsuit_unique_armored_03_top",
|
||||
"stillsuit_unique_armored_04_top",
|
||||
"stillsuit_unique_armored_05_top",
|
||||
"stillsuit_unique_armored_06_top",
|
||||
"stillsuit_unique_efficient_04_top",
|
||||
"stillsuit_unique_efficient_05_top",
|
||||
"stillsuit_unique_efficient_06_top",
|
||||
"stillsuit_unique_highcapacity_06_top",
|
||||
"stillsuit_unique_thermalsuit_06_top",
|
||||
"waterpack_consumable",
|
||||
}
|
||||
125
docs/reference-repos/icehunter/cmd/dune-admin/give_packs.go
Normal file
125
docs/reference-repos/icehunter/cmd/dune-admin/give_packs.go
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go sqlite driver (registers "sqlite")
|
||||
)
|
||||
|
||||
// givePacksStore persists the operator-configurable give-items pack library in
|
||||
// a local SQLite database. Kept in our own file so we never touch Funcom's
|
||||
// dune schema. Mirrors welcomeStore / locationStore in structure and intent.
|
||||
type givePacksStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const givePacksStoreSchema = `
|
||||
CREATE TABLE IF NOT EXISTS give_packs_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
base_packs_loaded INTEGER NOT NULL DEFAULT 0,
|
||||
packs_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);`
|
||||
|
||||
// initGivePacksSchema creates the give_packs_config table on db. Safe to call
|
||||
// against a shared handle (the unified store). Idempotent.
|
||||
func initGivePacksSchema(db *sql.DB) error {
|
||||
if _, err := db.Exec(givePacksStoreSchema); err != nil {
|
||||
return fmt.Errorf("init give-packs schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newGivePacksStore wraps an already-initialised shared handle (schema created
|
||||
// by openUnifiedStore). Used so all stores share one SQLite file in production.
|
||||
func newGivePacksStore(db *sql.DB) *givePacksStore {
|
||||
return &givePacksStore{db: db}
|
||||
}
|
||||
|
||||
// openGivePacksStore opens (or creates) the give-packs database at path and
|
||||
// ensures the schema exists. path may be ":memory:" for tests.
|
||||
func openGivePacksStore(path string) (*givePacksStore, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open give-packs store: %w", err)
|
||||
}
|
||||
if err := initGivePacksSchema(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &givePacksStore{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *givePacksStore) close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// saveConfig upserts the single give_packs_config row (id=1).
|
||||
// packsJSON must be a valid JSON array (never nil — use "[]" for empty).
|
||||
// basePacksLoaded=true means the default seed has been applied; subsequent
|
||||
// startups will skip re-seeding even when packsJSON is "[]" (user deleted all).
|
||||
func (s *givePacksStore) saveConfig(packsJSON string, basePacksLoaded bool) error {
|
||||
loaded := 0
|
||||
if basePacksLoaded {
|
||||
loaded = 1
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO give_packs_config (id, base_packs_loaded, packs_json, updated_at)
|
||||
VALUES (1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
base_packs_loaded = excluded.base_packs_loaded,
|
||||
packs_json = excluded.packs_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
loaded, packsJSON, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save give-packs config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadConfig reads the single give_packs_config row.
|
||||
// Returns (basePacksLoaded, packsJSON, ok, err).
|
||||
// ok=false when the table is empty (first boot); in that case the caller
|
||||
// should seed from the embedded default.
|
||||
func (s *givePacksStore) loadConfig() (basePacksLoaded bool, packsJSON string, ok bool, err error) {
|
||||
var loadedInt int
|
||||
scanErr := s.db.QueryRow(`
|
||||
SELECT base_packs_loaded, packs_json FROM give_packs_config WHERE id = 1`).
|
||||
Scan(&loadedInt, &packsJSON)
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return false, "", false, nil
|
||||
}
|
||||
if scanErr != nil {
|
||||
return false, "", false, fmt.Errorf("load give-packs config: %w", scanErr)
|
||||
}
|
||||
return loadedInt != 0, packsJSON, true, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openMemGivePacksStore opens an in-memory give-packs store for testing.
|
||||
func openMemGivePacksStore(t *testing.T) *givePacksStore {
|
||||
t.Helper()
|
||||
s, err := openGivePacksStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("openGivePacksStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestGivePacksStore_LoadMissingReturnsNotOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openMemGivePacksStore(t)
|
||||
|
||||
loaded, packsJSON, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected ok=false on empty store, got true")
|
||||
}
|
||||
if loaded {
|
||||
t.Error("expected base_packs_loaded=false on empty store")
|
||||
}
|
||||
if packsJSON != "" {
|
||||
t.Errorf("expected empty packsJSON, got %q", packsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGivePacksStore_SaveAndLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openMemGivePacksStore(t)
|
||||
|
||||
const testJSON = `[{"id":"starter-t1","name":"T1","category":"Starter","tier":1,"items":[]}]`
|
||||
if err := s.saveConfig(testJSON, true); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, packsJSON, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true after save")
|
||||
}
|
||||
if !loaded {
|
||||
t.Error("expected base_packs_loaded=true after saveConfig(..., true)")
|
||||
}
|
||||
if packsJSON != testJSON {
|
||||
t.Errorf("packsJSON mismatch:\nwant: %s\ngot: %s", testJSON, packsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGivePacksStore_SavedUnloaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openMemGivePacksStore(t)
|
||||
|
||||
if err := s.saveConfig(`[]`, false); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, _, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if loaded {
|
||||
t.Error("expected base_packs_loaded=false when saved with false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGivePacksStore_OverwriteWithSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openMemGivePacksStore(t)
|
||||
|
||||
if err := s.saveConfig(`[{"id":"a"}]`, false); err != nil {
|
||||
t.Fatalf("first save: %v", err)
|
||||
}
|
||||
const second = `[{"id":"b"},{"id":"c"}]`
|
||||
if err := s.saveConfig(second, true); err != nil {
|
||||
t.Fatalf("second save: %v", err)
|
||||
}
|
||||
|
||||
loaded, packsJSON, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if !loaded {
|
||||
t.Error("expected base_packs_loaded=true after second save")
|
||||
}
|
||||
if packsJSON != second {
|
||||
t.Errorf("expected second packs JSON, got %q", packsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGivePacksStore_EmptyPacksRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := openMemGivePacksStore(t)
|
||||
|
||||
// Saving empty packs with loaded=true (user deleted all) must not re-seed.
|
||||
if err := s.saveConfig(`[]`, true); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
loaded, packsJSON, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if !loaded {
|
||||
t.Error("base_packs_loaded should remain true even when packs are empty")
|
||||
}
|
||||
if packsJSON != `[]` {
|
||||
t.Errorf("expected empty array, got %q", packsJSON)
|
||||
}
|
||||
}
|
||||
185
docs/reference-repos/icehunter/cmd/dune-admin/give_packs_test.go
Normal file
185
docs/reference-repos/icehunter/cmd/dune-admin/give_packs_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupGivePacksStore wires a fresh in-memory store into givePacksStoreDB and
|
||||
// restores nil on cleanup. NOT parallel — mutates a package global.
|
||||
func setupGivePacksStore(t *testing.T) *givePacksStore {
|
||||
t.Helper()
|
||||
s := openMemGivePacksStore(t)
|
||||
givePacksStoreDB = s
|
||||
t.Cleanup(func() { givePacksStoreDB = nil })
|
||||
return s
|
||||
}
|
||||
|
||||
// ── validateGivePacks ────────────────────────────────────────────────────────
|
||||
|
||||
func TestValidateGivePacks_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{
|
||||
{ID: "starter-t1", Name: "T1 Starter", Category: "Starter", Tier: 1, Items: []welcomePackageItem{
|
||||
{Template: "Ammo", Qty: 100, Quality: 0},
|
||||
}},
|
||||
{ID: "buggy-t6", Name: "Buggy T6", Category: "Buggy", Tier: 6, Items: []welcomePackageItem{
|
||||
{Template: "BuggyBoost_6", Qty: 1, Quality: 0},
|
||||
}},
|
||||
}
|
||||
if err := validateGivePacks(packs); err != nil {
|
||||
t.Fatalf("unexpected error for valid packs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptySliceIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
// An empty pack list is valid — operator deleted everything intentionally.
|
||||
if err := validateGivePacks([]givePack{}); err != nil {
|
||||
t.Fatalf("empty packs should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "", Name: "No ID", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for empty id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_DuplicateID(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{
|
||||
{ID: "dup", Name: "A", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}},
|
||||
{ID: "dup", Name: "B", Category: "Y", Tier: 2, Items: []welcomePackageItem{{Template: "B", Qty: 1, Quality: 0}}},
|
||||
}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for duplicate id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptyName(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptyCategory(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for empty category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_ZeroQtyItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 0, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for item with qty=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_NegativeQtyItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: -1, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for item with qty=-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_NegativeQualityItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: -1}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for item with quality=-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptyTemplateItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "", Qty: 1, Quality: 0}}}}
|
||||
if err := validateGivePacks(packs); err == nil {
|
||||
t.Fatal("expected error for empty template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGivePacks_EmptyItemsIsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A pack with zero items is valid — operator may be building it.
|
||||
packs := []givePack{{ID: "ok-id", Name: "OK", Category: "X", Tier: 1, Items: []welcomePackageItem{}}}
|
||||
if err := validateGivePacks(packs); err != nil {
|
||||
t.Fatalf("pack with empty items should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseDefaultPacks ────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseDefaultPacks_NonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs, err := parseDefaultPacks()
|
||||
if err != nil {
|
||||
t.Fatalf("parseDefaultPacks: %v", err)
|
||||
}
|
||||
if len(packs) == 0 {
|
||||
t.Fatal("expected at least one default pack from embedded JSON")
|
||||
}
|
||||
// Spot-check shape: every pack must have ID, Name, Category.
|
||||
for _, p := range packs {
|
||||
if p.ID == "" {
|
||||
t.Error("pack missing ID")
|
||||
}
|
||||
if p.Name == "" {
|
||||
t.Errorf("pack %q missing name", p.ID)
|
||||
}
|
||||
if p.Category == "" {
|
||||
t.Errorf("pack %q missing category", p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultPacks_ValidShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
packs, err := parseDefaultPacks()
|
||||
if err != nil {
|
||||
t.Fatalf("parseDefaultPacks: %v", err)
|
||||
}
|
||||
if err := validateGivePacks(packs); err != nil {
|
||||
t.Fatalf("default packs fail validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── seedGivePacks ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSeedGivePacks_SetsBasePacksLoaded(t *testing.T) {
|
||||
s := setupGivePacksStore(t)
|
||||
|
||||
if err := seedGivePacks(); err != nil {
|
||||
t.Fatalf("seedGivePacks: %v", err)
|
||||
}
|
||||
|
||||
loaded, packsJSON, ok, err := s.loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig after seed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected config row after seed")
|
||||
}
|
||||
if !loaded {
|
||||
t.Error("expected base_packs_loaded=true after seedGivePacks")
|
||||
}
|
||||
if packsJSON == "" || packsJSON == "null" || packsJSON == "[]" {
|
||||
t.Error("expected non-empty packs JSON after seed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedGivePacks_NilStore(t *testing.T) {
|
||||
// When the store is nil, seedGivePacks should return an error gracefully.
|
||||
givePacksStoreDB = nil
|
||||
err := seedGivePacks()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
}
|
||||
317
docs/reference-repos/icehunter/cmd/dune-admin/handlers_bases.go
Normal file
317
docs/reference-repos/icehunter/cmd/dune-admin/handlers_bases.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func quatToYaw(qx, qy, qz, qw float64) float64 {
|
||||
return math.Atan2(2*(qw*qz+qx*qy), 1-2*(qy*qy+qz*qz)) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func quatToEuler(qx, qy, qz, qw float64) (rx, ry, rz float64) {
|
||||
rx = math.Atan2(2*(qw*qx+qy*qz), 1-2*(qx*qx+qy*qy)) * 180 / math.Pi
|
||||
sinp := 2 * (qw*qy - qz*qx)
|
||||
if sinp >= 1 {
|
||||
ry = 90
|
||||
} else if sinp <= -1 {
|
||||
ry = -90
|
||||
} else {
|
||||
ry = math.Asin(sinp) * 180 / math.Pi
|
||||
}
|
||||
rz = math.Atan2(2*(qw*qz+qx*qy), 1-2*(qy*qy+qz*qz)) * 180 / math.Pi
|
||||
return
|
||||
}
|
||||
|
||||
func parseVec3(s string) (x, y, z float64, err error) {
|
||||
s = strings.Trim(strings.TrimSpace(s), "()")
|
||||
parts := strings.SplitN(s, ",", 3)
|
||||
if len(parts) != 3 {
|
||||
return 0, 0, 0, fmt.Errorf("expected 3 components in %q", s)
|
||||
}
|
||||
if x, err = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err != nil {
|
||||
return
|
||||
}
|
||||
if y, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err != nil {
|
||||
return
|
||||
}
|
||||
z, err = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
||||
return
|
||||
}
|
||||
|
||||
func parseVec4(s string) (x, y, z, w float64, err error) {
|
||||
s = strings.Trim(strings.TrimSpace(s), "()")
|
||||
parts := strings.SplitN(s, ",", 4)
|
||||
if len(parts) != 4 {
|
||||
return 0, 0, 0, 0, fmt.Errorf("expected 4 components in %q", s)
|
||||
}
|
||||
if x, err = strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err != nil {
|
||||
return
|
||||
}
|
||||
if y, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err != nil {
|
||||
return
|
||||
}
|
||||
if z, err = strconv.ParseFloat(strings.TrimSpace(parts[2]), 64); err != nil {
|
||||
return
|
||||
}
|
||||
w, err = strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
|
||||
return
|
||||
}
|
||||
|
||||
// @Summary List all player bases
|
||||
// @Tags bases
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/bases [get]
|
||||
func handleListBases(w http.ResponseWriter, _ *http.Request) {
|
||||
msg, ok := cmdListBases().(msgBaseList)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
rows := msg.rows
|
||||
if rows == nil {
|
||||
rows = []baseRow{}
|
||||
}
|
||||
jsonOK(w, rows)
|
||||
}
|
||||
|
||||
type rawBaseInstance struct {
|
||||
buildingType string
|
||||
transform []float32
|
||||
ownerEntityID int64
|
||||
}
|
||||
|
||||
type rawBasePlaceable struct {
|
||||
buildingType string
|
||||
location string
|
||||
rotation string
|
||||
properties map[string]any
|
||||
}
|
||||
|
||||
func parseBasePathID(id string) (int64, error) {
|
||||
parsedID, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid id")
|
||||
}
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
func queryBaseExportInstances(ctx context.Context, id int64) ([]rawBaseInstance, error) {
|
||||
rows, err := globalDB.Query(ctx, `
|
||||
SELECT building_type, transform, owner_entity_id
|
||||
FROM dune.building_instances
|
||||
WHERE building_id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
raws := make([]rawBaseInstance, 0, 32)
|
||||
for rows.Next() {
|
||||
var ri rawBaseInstance
|
||||
if err := rows.Scan(&ri.buildingType, &ri.transform, &ri.ownerEntityID); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(ri.transform) < 7 {
|
||||
continue
|
||||
}
|
||||
raws = append(raws, ri)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read instances: %w", err)
|
||||
}
|
||||
return raws, nil
|
||||
}
|
||||
|
||||
func queryBaseExportPlaceables(ctx context.Context, ownerEntityID int64) ([]rawBasePlaceable, error) {
|
||||
rows, err := globalDB.Query(ctx, `
|
||||
SELECT p.building_type,
|
||||
(a.transform).location::text,
|
||||
(a.transform).rotation::text,
|
||||
a.properties
|
||||
FROM dune.placeables p
|
||||
JOIN dune.actors a ON a.id = p.id
|
||||
WHERE p.owner_entity_id = $1`, ownerEntityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query placeables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
raws := make([]rawBasePlaceable, 0, 32)
|
||||
for rows.Next() {
|
||||
var rp rawBasePlaceable
|
||||
if err := rows.Scan(&rp.buildingType, &rp.location, &rp.rotation, &rp.properties); err != nil {
|
||||
continue
|
||||
}
|
||||
raws = append(raws, rp)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read placeables: %w", err)
|
||||
}
|
||||
return raws, nil
|
||||
}
|
||||
|
||||
func calculateBaseCentroid(raws []rawBaseInstance) (float64, float64, float64) {
|
||||
var sumX, sumY, sumZ float64
|
||||
for _, ri := range raws {
|
||||
sumX += float64(ri.transform[0])
|
||||
sumY += float64(ri.transform[1])
|
||||
sumZ += float64(ri.transform[2])
|
||||
}
|
||||
n := float64(len(raws))
|
||||
return sumX / n, sumY / n, sumZ / n
|
||||
}
|
||||
|
||||
func buildBlueprintInstances(raws []rawBaseInstance, cx, cy, cz float64) []blueprintInstance {
|
||||
instances := make([]blueprintInstance, 0, len(raws))
|
||||
for _, ri := range raws {
|
||||
qx, qy, qz, qw := float64(ri.transform[3]), float64(ri.transform[4]), float64(ri.transform[5]), float64(ri.transform[6])
|
||||
instances = append(instances, blueprintInstance{
|
||||
BuildingType: ri.buildingType,
|
||||
X: float64(ri.transform[0]) - cx,
|
||||
Y: float64(ri.transform[1]) - cy,
|
||||
Z: float64(ri.transform[2]) - cz,
|
||||
Rotation: quatToYaw(qx, qy, qz, qw),
|
||||
})
|
||||
}
|
||||
return instances
|
||||
}
|
||||
|
||||
func extractPentashieldScale(buildingType string, props map[string]any) ([3]int, bool) {
|
||||
var scale [3]int
|
||||
if props == nil {
|
||||
return scale, false
|
||||
}
|
||||
inner, ok := props[strings.TrimSuffix(buildingType, "_Placeable")+"_C"].(map[string]any)
|
||||
if !ok {
|
||||
return scale, false
|
||||
}
|
||||
scaleValues, ok := inner["m_Scale"].([]any)
|
||||
if !ok || len(scaleValues) < 3 {
|
||||
return scale, false
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
value, ok := scaleValues[i].(float64)
|
||||
if !ok {
|
||||
return scale, false
|
||||
}
|
||||
scale[i] = int(value)
|
||||
}
|
||||
return scale, true
|
||||
}
|
||||
|
||||
func convertExportPlaceable(raw rawBasePlaceable, cx, cy, cz float64, placeableID int) (blueprintPlaceable, *blueprintPentashield, bool) {
|
||||
if raw.buildingType == "Totem_Placeable" {
|
||||
return blueprintPlaceable{}, nil, false
|
||||
}
|
||||
lx, ly, lz, locErr := parseVec3(raw.location)
|
||||
qx, qy, qz, qw, rotErr := parseVec4(raw.rotation)
|
||||
if locErr != nil || rotErr != nil {
|
||||
return blueprintPlaceable{}, nil, false
|
||||
}
|
||||
rx, ry, rz := quatToEuler(qx, qy, qz, qw)
|
||||
placeable := blueprintPlaceable{
|
||||
BuildingType: raw.buildingType,
|
||||
X: lx - cx,
|
||||
Y: ly - cy,
|
||||
Z: lz - cz,
|
||||
RX: rx,
|
||||
RY: ry,
|
||||
RZ: rz,
|
||||
}
|
||||
if !strings.Contains(raw.buildingType, "PentashieldSurface") {
|
||||
return placeable, nil, true
|
||||
}
|
||||
scale, ok := extractPentashieldScale(raw.buildingType, raw.properties)
|
||||
if !ok {
|
||||
return blueprintPlaceable{}, nil, false
|
||||
}
|
||||
return placeable, &blueprintPentashield{PlaceableID: placeableID, Scale: scale}, true
|
||||
}
|
||||
|
||||
func buildBlueprintPlaceables(raws []rawBasePlaceable, cx, cy, cz float64) ([]blueprintPlaceable, []blueprintPentashield) {
|
||||
placeables := make([]blueprintPlaceable, 0, len(raws))
|
||||
pentashields := make([]blueprintPentashield, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
nextID := len(placeables)
|
||||
placeable, pentashield, ok := convertExportPlaceable(raw, cx, cy, cz, nextID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
placeables = append(placeables, placeable)
|
||||
if pentashield != nil {
|
||||
pentashields = append(pentashields, *pentashield)
|
||||
}
|
||||
}
|
||||
return placeables, pentashields
|
||||
}
|
||||
|
||||
func writeExportBaseResponse(
|
||||
w http.ResponseWriter,
|
||||
id int64,
|
||||
instances []blueprintInstance,
|
||||
placeables []blueprintPlaceable,
|
||||
pentashields []blueprintPentashield,
|
||||
) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="base_%d.json"`, id))
|
||||
jsonOK(w, blueprintFile{
|
||||
Instances: instances,
|
||||
Placeables: placeables,
|
||||
Pentashields: pentashields,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Export a base as a downloadable blueprint JSON file
|
||||
// @Tags bases
|
||||
// @Produce application/octet-stream
|
||||
// @Param id path int true "Base (building) ID"
|
||||
// @Success 200 {file} string "Base blueprint JSON file"
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/bases/{id}/export [get]
|
||||
func handleExportBase(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseBasePathID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 500)
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
rawInstances, err := queryBaseExportInstances(ctx, id)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if len(rawInstances) == 0 {
|
||||
jsonErr(w, fmt.Errorf("building %d not found or empty", id), 404)
|
||||
return
|
||||
}
|
||||
|
||||
cx, cy, cz := calculateBaseCentroid(rawInstances)
|
||||
instances := buildBlueprintInstances(rawInstances, cx, cy, cz)
|
||||
|
||||
rawPlaceables, err := queryBaseExportPlaceables(ctx, rawInstances[0].ownerEntityID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
placeables, pentashields := buildBlueprintPlaceables(rawPlaceables, cx, cy, cz)
|
||||
writeExportBaseResponse(w, id, instances, placeables, pentashields)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nearlyEqual(a, b, epsilon float64) bool {
|
||||
return math.Abs(a-b) <= epsilon
|
||||
}
|
||||
|
||||
func TestParseBasePathID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantID int64
|
||||
wantError string
|
||||
}{
|
||||
{name: "valid", input: "123", wantID: 123},
|
||||
{name: "invalid", input: "abc", wantError: "invalid id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseBasePathID(tt.input)
|
||||
if tt.wantError != "" {
|
||||
if err == nil || err.Error() != tt.wantError {
|
||||
t.Fatalf("expected error %q, got %v", tt.wantError, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.wantID {
|
||||
t.Fatalf("expected ID %d, got %d", tt.wantID, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBaseCentroid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raws := []rawBaseInstance{
|
||||
{transform: []float32{10, 20, 30, 0, 0, 0, 1}},
|
||||
{transform: []float32{14, 24, 34, 0, 0, 0, 1}},
|
||||
}
|
||||
|
||||
cx, cy, cz := calculateBaseCentroid(raws)
|
||||
if cx != 12 || cy != 22 || cz != 32 {
|
||||
t.Fatalf("unexpected centroid: (%v, %v, %v)", cx, cy, cz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlueprintInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raws := []rawBaseInstance{
|
||||
{
|
||||
buildingType: "A",
|
||||
transform: []float32{10, 20, 30, 0, 0, 0, 1},
|
||||
},
|
||||
{
|
||||
buildingType: "B",
|
||||
transform: []float32{14, 24, 34, 0, 0, 0.70710677, 0.70710677},
|
||||
},
|
||||
}
|
||||
|
||||
cx, cy, cz := calculateBaseCentroid(raws)
|
||||
got := buildBlueprintInstances(raws, cx, cy, cz)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 instances, got %d", len(got))
|
||||
}
|
||||
|
||||
if got[0].BuildingType != "A" || got[1].BuildingType != "B" {
|
||||
t.Fatalf("unexpected building types: %#v", got)
|
||||
}
|
||||
|
||||
if got[0].X != -2 || got[0].Y != -2 || got[0].Z != -2 {
|
||||
t.Fatalf("unexpected first offsets: %+v", got[0])
|
||||
}
|
||||
if !nearlyEqual(got[0].Rotation, 0, 0.001) {
|
||||
t.Fatalf("unexpected first rotation: %v", got[0].Rotation)
|
||||
}
|
||||
if !nearlyEqual(got[1].Rotation, 90, 0.01) {
|
||||
t.Fatalf("unexpected second rotation: %v", got[1].Rotation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertExportPlaceable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw rawBasePlaceable
|
||||
cx float64
|
||||
cy float64
|
||||
cz float64
|
||||
placeableID int
|
||||
wantInclude bool
|
||||
wantHasPenta bool
|
||||
wantPlaceableX float64
|
||||
}{
|
||||
{
|
||||
name: "skip totem",
|
||||
raw: rawBasePlaceable{buildingType: "Totem_Placeable"},
|
||||
wantInclude: false,
|
||||
},
|
||||
{
|
||||
name: "skip invalid transform",
|
||||
raw: rawBasePlaceable{
|
||||
buildingType: "SomeType_Placeable",
|
||||
location: "invalid",
|
||||
rotation: "(0,0,0,1)",
|
||||
},
|
||||
wantInclude: false,
|
||||
},
|
||||
{
|
||||
name: "normal placeable",
|
||||
raw: rawBasePlaceable{
|
||||
buildingType: "SomeType_Placeable",
|
||||
location: "(10,20,30)",
|
||||
rotation: "(0,0,0,1)",
|
||||
},
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
cz: 3,
|
||||
wantInclude: true,
|
||||
wantHasPenta: false,
|
||||
wantPlaceableX: 9,
|
||||
},
|
||||
{
|
||||
name: "pentashield with scale",
|
||||
raw: rawBasePlaceable{
|
||||
buildingType: "MyPentashieldSurface_Placeable",
|
||||
location: "(10,20,30)",
|
||||
rotation: "(0,0,0,1)",
|
||||
properties: map[string]any{
|
||||
"MyPentashieldSurface_C": map[string]any{
|
||||
"m_Scale": []any{3.0, 4.0, 5.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
cz: 3,
|
||||
placeableID: 7,
|
||||
wantInclude: true,
|
||||
wantHasPenta: true,
|
||||
wantPlaceableX: 9,
|
||||
},
|
||||
{
|
||||
name: "pentashield without scale skipped",
|
||||
raw: rawBasePlaceable{
|
||||
buildingType: "MyPentashieldSurface_Placeable",
|
||||
location: "(10,20,30)",
|
||||
rotation: "(0,0,0,1)",
|
||||
properties: map[string]any{
|
||||
"MyPentashieldSurface_C": map[string]any{},
|
||||
},
|
||||
},
|
||||
wantInclude: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
placeable, pentashield, include := convertExportPlaceable(tt.raw, tt.cx, tt.cy, tt.cz, tt.placeableID)
|
||||
if include != tt.wantInclude {
|
||||
t.Fatalf("expected include=%v, got %v", tt.wantInclude, include)
|
||||
}
|
||||
if !include {
|
||||
return
|
||||
}
|
||||
if placeable.X != tt.wantPlaceableX {
|
||||
t.Fatalf("unexpected placeable X: %v", placeable.X)
|
||||
}
|
||||
if (pentashield != nil) != tt.wantHasPenta {
|
||||
t.Fatalf("expected pentashield=%v, got %v", tt.wantHasPenta, pentashield != nil)
|
||||
}
|
||||
if pentashield != nil {
|
||||
if pentashield.PlaceableID != tt.placeableID {
|
||||
t.Fatalf("expected pentashield placeable id %d, got %d", tt.placeableID, pentashield.PlaceableID)
|
||||
}
|
||||
if pentashield.Scale != [3]int{3, 4, 5} {
|
||||
t.Fatalf("unexpected pentashield scale: %#v", pentashield.Scale)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlueprintPlaceables_AssignsPentashieldIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raws := []rawBasePlaceable{
|
||||
{buildingType: "Totem_Placeable"},
|
||||
{
|
||||
buildingType: "Normal_Placeable",
|
||||
location: "(1,1,1)",
|
||||
rotation: "(0,0,0,1)",
|
||||
},
|
||||
{
|
||||
buildingType: "ShieldPentashieldSurface_Placeable",
|
||||
location: "(2,2,2)",
|
||||
rotation: "(0,0,0,1)",
|
||||
properties: map[string]any{
|
||||
"ShieldPentashieldSurface_C": map[string]any{
|
||||
"m_Scale": []any{1.0, 2.0, 3.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
placeables, pentashields := buildBlueprintPlaceables(raws, 0, 0, 0)
|
||||
if len(placeables) != 2 {
|
||||
t.Fatalf("expected 2 placeables, got %d", len(placeables))
|
||||
}
|
||||
if len(pentashields) != 1 {
|
||||
t.Fatalf("expected 1 pentashield, got %d", len(pentashields))
|
||||
}
|
||||
if pentashields[0].PlaceableID != 1 {
|
||||
t.Fatalf("expected pentashield PlaceableID=1, got %d", pentashields[0].PlaceableID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type backupFile struct {
|
||||
Name string `json:"name"`
|
||||
SizeB int64 `json:"size_bytes"`
|
||||
Modified string `json:"modified"`
|
||||
HasYAML bool `json:"has_yaml"`
|
||||
}
|
||||
|
||||
var bgCmdAllowlist = map[string]bool{
|
||||
"start": true, "stop": true, "restart": true,
|
||||
"update": true, "backup": true,
|
||||
// restore handled separately via handleBGRestore
|
||||
}
|
||||
|
||||
// @Summary Get battlegroup and server status from the control plane
|
||||
// @Tags battlegroup
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/status [get]
|
||||
func handleBGStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if globalControl == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
status, err := globalControl.GetStatus(r.Context(), globalExecutor)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"battlegroup": map[string]string{
|
||||
"name": status.Name,
|
||||
"title": status.Title,
|
||||
"phase": status.Phase,
|
||||
"database": status.Database,
|
||||
}, "servers": status.Servers})
|
||||
}
|
||||
|
||||
func safeIdx(s []string, i int) string {
|
||||
if i < len(s) {
|
||||
return s[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// @Summary Execute a battlegroup lifecycle command via the control plane
|
||||
// @Tags battlegroup
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Command: start, stop, restart, update, or backup"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/exec [post]
|
||||
func handleBGExec(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if !bgCmdAllowlist[req.Cmd] {
|
||||
jsonErr(w, fmt.Errorf("unknown command %q", req.Cmd), 400)
|
||||
return
|
||||
}
|
||||
if globalControl == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
out, err := globalControl.ExecCommand(r.Context(), globalExecutor, req.Cmd)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("exec: %w — output: %s", err, out), 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"output": out})
|
||||
}
|
||||
|
||||
// @Summary List battlegroup pods/processes and their namespace
|
||||
// @Tags battlegroup
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/pods [get]
|
||||
func handleBGPods(w http.ResponseWriter, r *http.Request) {
|
||||
if globalControl == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
procs, ns, err := globalControl.ListProcesses(r.Context(), globalExecutor)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
// Return raw lines for backward compat with the frontend which renders them as-is.
|
||||
var lines []string
|
||||
for _, p := range procs {
|
||||
lines = append(lines, p.Name)
|
||||
}
|
||||
jsonOK(w, map[string]any{"pods": lines, "namespace": ns})
|
||||
}
|
||||
|
||||
func activeBackupDir() (string, error) {
|
||||
if backupDir != "" {
|
||||
return backupDir, nil
|
||||
}
|
||||
if loadedConfig.BackupDir != "" {
|
||||
return loadedConfig.BackupDir, nil
|
||||
}
|
||||
ns := firstNonEmpty(controlNS, loadedConfig.ControlNamespace, globalPodNS)
|
||||
bg := strings.TrimPrefix(ns, "funcom-seabass-")
|
||||
if globalControl != nil && globalControl.Name() == "local" && ns != "" && globalExecutor != nil {
|
||||
pod, err := discoverK8sBackupPod(ns)
|
||||
if err == nil && pod != "" && bg != "" {
|
||||
return fmt.Sprintf("k8s://%s/%s/home/dune/artifacts/database-dumps/%s", ns, pod, bg), nil
|
||||
}
|
||||
}
|
||||
if bg != "" {
|
||||
// Legacy kubectl/host default.
|
||||
return fmt.Sprintf("/funcom/artifacts/database-dumps/%s", bg), nil
|
||||
}
|
||||
return "", fmt.Errorf("backup_dir not configured and no battlegroup namespace discovered")
|
||||
}
|
||||
|
||||
func parseK8sBackupDir(dir string) (ns, pod, inPodDir string, ok bool) {
|
||||
const prefix = "k8s://"
|
||||
if !strings.HasPrefix(dir, prefix) {
|
||||
return "", "", "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(dir, prefix)
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
ns, pod, inPodDir = parts[0], parts[1], "/"+strings.TrimLeft(parts[2], "/")
|
||||
return ns, pod, inPodDir, true
|
||||
}
|
||||
|
||||
func discoverK8sBackupPod(ns string) (string, error) {
|
||||
if globalExecutor == nil {
|
||||
return "", fmt.Errorf("not connected")
|
||||
}
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep -- '-sg-' | head -1",
|
||||
kctl, shellQuote(ns),
|
||||
))
|
||||
if err == nil && strings.TrimSpace(out) != "" {
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
out, err = globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s get pods -n %s --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | grep bgd | head -1",
|
||||
kctl, shellQuote(ns),
|
||||
))
|
||||
if err == nil && strings.TrimSpace(out) != "" {
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
return "", fmt.Errorf("could not discover backup pod in namespace %s", ns)
|
||||
}
|
||||
|
||||
func ensureBackupDir(dir string) error {
|
||||
if globalExecutor == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- mkdir -p %s 2>&1",
|
||||
kctl, shellQuote(ns), shellQuote(pod), shellQuote(inPodDir),
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure k8s backup dir: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"mkdir -p %s 2>/dev/null || sudo mkdir -p %s 2>&1",
|
||||
shellQuote(dir), shellQuote(dir),
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure backup dir: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listBackupDir(dir string) (string, string, error) {
|
||||
if globalExecutor == nil {
|
||||
return "", "", fmt.Errorf("not connected")
|
||||
}
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
listCmd := fmt.Sprintf(`ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`, inPodDir)
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- sh -lc %s 2>&1",
|
||||
kctl, shellQuote(ns), shellQuote(pod), shellQuote(listCmd),
|
||||
))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("list backups: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
yamlCmd := fmt.Sprintf(`ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`, inPodDir)
|
||||
yamlOut, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- sh -lc %s 2>&1",
|
||||
kctl, shellQuote(ns), shellQuote(pod), shellQuote(yamlCmd),
|
||||
))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("list backup metadata: %w (%s)", err, strings.TrimSpace(yamlOut))
|
||||
}
|
||||
return out, yamlOut, nil
|
||||
}
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
`ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`,
|
||||
dir))
|
||||
if err != nil {
|
||||
out, err = globalExecutor.Exec(fmt.Sprintf(
|
||||
`sudo ls -lt %s/ 2>/dev/null | awk '/\.backup$/{print $NF"|"$5"|"$6" "$7" "$8}'`,
|
||||
dir))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("list backups: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
yamlOut, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
`ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`,
|
||||
dir))
|
||||
if err != nil {
|
||||
yamlOut, err = globalExecutor.Exec(fmt.Sprintf(
|
||||
`sudo ls %s/*.backup.yaml 2>/dev/null | xargs -r -I{} basename {} .yaml`,
|
||||
dir))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("list backup metadata: %w (%s)", err, strings.TrimSpace(yamlOut))
|
||||
}
|
||||
}
|
||||
return out, yamlOut, nil
|
||||
}
|
||||
|
||||
func backupFileExists(dir, name string) bool {
|
||||
if globalExecutor == nil {
|
||||
return false
|
||||
}
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
|
||||
out, _ := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s exec -n %s %s -- sh -lc %s 2>/dev/null",
|
||||
kctl, shellQuote(ns), shellQuote(pod),
|
||||
shellQuote(fmt.Sprintf("test -f %s && echo yes || echo no", shellQuote(remotePath))),
|
||||
))
|
||||
return strings.TrimSpace(out) == "yes"
|
||||
}
|
||||
path := strings.TrimRight(dir, "/") + "/" + name
|
||||
out, _ := globalExecutor.Exec(fmt.Sprintf("test -f %s && echo yes || echo no", shellQuote(path)))
|
||||
if strings.TrimSpace(out) == "yes" {
|
||||
return true
|
||||
}
|
||||
out, _ = globalExecutor.Exec(fmt.Sprintf("sudo test -f %s && echo yes || echo no", shellQuote(path)))
|
||||
return strings.TrimSpace(out) == "yes"
|
||||
}
|
||||
|
||||
func backupReadCmd(dir, name string) string {
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
|
||||
return fmt.Sprintf("%s exec -n %s %s -- cat %s", kctl, shellQuote(ns), shellQuote(pod), shellQuote(remotePath))
|
||||
}
|
||||
path := strings.TrimRight(dir, "/") + "/" + name
|
||||
return fmt.Sprintf("cat %s 2>/dev/null || sudo cat %s", shellQuote(path), shellQuote(path))
|
||||
}
|
||||
|
||||
func writeBackupFile(dir, name string, src io.Reader) error {
|
||||
if globalExecutor == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
if err := ensureBackupDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
tmp := fmt.Sprintf("/tmp/dune-admin-backup-%d.tmp", time.Now().UnixNano())
|
||||
if err := globalExecutor.WriteFile(tmp, src); err != nil {
|
||||
return fmt.Errorf("stage upload: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = globalExecutor.Exec(fmt.Sprintf("rm -f %s 2>/dev/null || sudo rm -f %s 2>/dev/null || true",
|
||||
shellQuote(tmp), shellQuote(tmp)))
|
||||
}()
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
remotePath := strings.TrimRight(inPodDir, "/") + "/" + name
|
||||
out, err := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s cp %s %s/%s:%s 2>&1",
|
||||
kctl, shellQuote(tmp), shellQuote(ns), shellQuote(pod), shellQuote(remotePath),
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy to k8s pod: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cleanDir := filepath.Clean(dir)
|
||||
destPath := filepath.Join(cleanDir, name)
|
||||
if !strings.HasPrefix(destPath, cleanDir+string(filepath.Separator)) {
|
||||
return fmt.Errorf("backup entry %q escapes target directory", name)
|
||||
}
|
||||
return globalExecutor.WriteFile(destPath, src)
|
||||
}
|
||||
|
||||
// @Summary List available database backup files in the backup directory
|
||||
// @Tags battlegroup
|
||||
// @Produce json
|
||||
// @Success 200 {object} []backupFile
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/backup-files [get]
|
||||
func handleBGBackupFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
dir, err := activeBackupDir()
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if err := ensureBackupDir(dir); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out, yamlOut, err := listBackupDir(dir)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
hasYAML := make(map[string]bool)
|
||||
for _, n := range strings.Split(strings.TrimSpace(yamlOut), "\n") {
|
||||
if n != "" {
|
||||
hasYAML[strings.TrimSpace(n)] = true
|
||||
}
|
||||
}
|
||||
var files []backupFile
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
p := strings.SplitN(line, "|", 3)
|
||||
if len(p) < 3 {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.ParseInt(p[1], 10, 64)
|
||||
name := p[0]
|
||||
files = append(files, backupFile{Name: name, SizeB: size, Modified: p[2], HasYAML: hasYAML[name]})
|
||||
}
|
||||
if files == nil {
|
||||
files = []backupFile{}
|
||||
}
|
||||
jsonOK(w, files)
|
||||
}
|
||||
|
||||
// @Summary Download a backup file (and its YAML metadata) as a zip archive
|
||||
// @Tags battlegroup
|
||||
// @Produce application/zip
|
||||
// @Param file query string true "Backup filename (must end in .backup)"
|
||||
// @Success 200 {file} binary
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/backup-files/download [get]
|
||||
func handleBGBackupDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
filename := r.URL.Query().Get("file")
|
||||
if filename == "" || strings.ContainsAny(filename, "/\\") || !strings.HasSuffix(filename, ".backup") {
|
||||
jsonErr(w, fmt.Errorf("invalid filename"), 400)
|
||||
return
|
||||
}
|
||||
baseName := strings.TrimSuffix(filename, ".backup")
|
||||
dir, err := activeBackupDir()
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, baseName))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
for _, ext := range []string{".backup", ".backup.yaml"} {
|
||||
name := baseName + ext
|
||||
if !backupFileExists(dir, name) {
|
||||
continue
|
||||
}
|
||||
fw, err := zw.Create(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := globalExecutor.PipeToWriter(backupReadCmd(dir, name), fw); err != nil {
|
||||
fmt.Printf("zip entry %s: %v\n", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
fmt.Printf("zip close: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Restore the database from a named backup file via the control plane
|
||||
// @Tags battlegroup
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Backup filename (must end in .backup)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/restore [post]
|
||||
func handleBGRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if globalControl == nil || globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
File string `json:"file"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.File == "" || strings.ContainsAny(req.File, "/\\") || !strings.HasSuffix(req.File, ".backup") {
|
||||
jsonErr(w, fmt.Errorf("invalid filename"), 400)
|
||||
return
|
||||
}
|
||||
out, err := restoreViaControl(r.Context(), req.File)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("restore failed: %w\n%s", err, out), 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"output": out})
|
||||
}
|
||||
|
||||
func allowedBackupArchiveEntry(entryName string) (string, bool) {
|
||||
name := filepath.Base(entryName)
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasSuffix(name, ".backup") || strings.HasSuffix(name, ".backup.yaml") {
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func writeBackupArchiveEntries(dir string, zr *zip.Reader) (string, error) {
|
||||
var backupName string
|
||||
for _, zf := range zr.File {
|
||||
name, ok := allowedBackupArchiveEntry(zf.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := writeBackupFile(dir, name, rc); err != nil {
|
||||
_ = rc.Close()
|
||||
return "", fmt.Errorf("upload failed for %s: %w", name, err)
|
||||
}
|
||||
if err := rc.Close(); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, ".backup") {
|
||||
backupName = name
|
||||
}
|
||||
}
|
||||
return backupName, nil
|
||||
}
|
||||
|
||||
func uploadBackupArchive(dir string, file multipart.File) (string, int, error) {
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", 400, fmt.Errorf("read zip: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", 400, fmt.Errorf("invalid zip: %w", err)
|
||||
}
|
||||
backupName, err := writeBackupArchiveEntries(dir, zr)
|
||||
if err != nil {
|
||||
return "", 500, err
|
||||
}
|
||||
if backupName == "" {
|
||||
return "", 400, fmt.Errorf("zip contains no .backup file")
|
||||
}
|
||||
return backupName, 200, nil
|
||||
}
|
||||
|
||||
func isDirectBackupUpload(filename string) bool {
|
||||
return strings.HasSuffix(filename, ".backup") && !strings.ContainsAny(filename, "/\\")
|
||||
}
|
||||
|
||||
// @Summary Upload a backup file (.backup or .zip) to the backup directory
|
||||
// @Tags battlegroup
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param backup formData file true "Backup file (.backup or .zip)"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/battlegroup/backup-files/upload [post]
|
||||
func handleBGBackupUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<30)
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
jsonErr(w, fmt.Errorf("parse form: %w", err), 400)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("backup")
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("no file: %w", err), 400)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
filename := header.Filename
|
||||
dir, err := activeBackupDir()
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
if err := ensureBackupDir(dir); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(filename, ".zip") {
|
||||
backupName, status, err := uploadBackupArchive(dir, file)
|
||||
if err != nil {
|
||||
jsonErr(w, err, status)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"name": backupName})
|
||||
return
|
||||
}
|
||||
|
||||
if isDirectBackupUpload(filename) {
|
||||
if err := writeBackupFile(dir, filename, file); err != nil {
|
||||
jsonErr(w, fmt.Errorf("upload failed: %w", err), 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"name": filename})
|
||||
return
|
||||
}
|
||||
|
||||
jsonErr(w, fmt.Errorf("file must be .backup or .zip"), 400)
|
||||
}
|
||||
|
||||
// restoreViaControl runs a restore command appropriate for the active control plane.
|
||||
// Called by handleBGRestore — kept separate so the restore logic per-provider
|
||||
// can be extended without touching the HTTP handler.
|
||||
func restoreViaControl(ctx context.Context, filename string) (string, error) {
|
||||
// kubectl uses the battlegroup.sh import script.
|
||||
// TODO: NEVER run battlegroup.sh with sudo — see ExecCommand in control_kubectl.go.
|
||||
if globalControl != nil && globalControl.Name() == "kubectl" {
|
||||
return globalExecutor.Exec(fmt.Sprintf(
|
||||
`echo yes | ~/.dune/download/scripts/battlegroup.sh import %s 2>&1`,
|
||||
shellQuote(filename)))
|
||||
}
|
||||
// docker / local: pg_restore from the backup directory.
|
||||
dir, err := activeBackupDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimRight(dir, "/") + "/" + filename
|
||||
if ns, pod, inPodDir, ok := parseK8sBackupDir(dir); ok {
|
||||
kctl := kubectlCLI(globalExecutor)
|
||||
tmp := fmt.Sprintf("/tmp/dune-admin-restore-%d.backup", time.Now().UnixNano())
|
||||
remotePath := strings.TrimRight(inPodDir, "/") + "/" + filename
|
||||
copyOut, copyErr := globalExecutor.Exec(fmt.Sprintf(
|
||||
"%s cp %s/%s:%s %s 2>&1",
|
||||
kctl, shellQuote(ns), shellQuote(pod), shellQuote(remotePath), shellQuote(tmp),
|
||||
))
|
||||
if copyErr != nil {
|
||||
return copyOut, fmt.Errorf("copy backup to local restore path: %w", copyErr)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = globalExecutor.Exec(fmt.Sprintf("rm -f %s 2>/dev/null || sudo rm -f %s 2>/dev/null || true",
|
||||
shellQuote(tmp), shellQuote(tmp)))
|
||||
}()
|
||||
path = tmp
|
||||
}
|
||||
return globalExecutor.Exec(fmt.Sprintf(
|
||||
`PGPASSWORD=%s pg_restore --no-password --clean --if-exists -h %s -p %d -U %s -d %s %s 2>&1`,
|
||||
shellQuote(dbPass), dbHost, dbPort, dbUser, dbName, shellQuote(path)))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testMultipartFile struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (f testMultipartFile) Close() error { return nil }
|
||||
|
||||
func TestAllowedBackupArchiveEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entryName string
|
||||
wantName string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "backup", entryName: "save.backup", wantName: "save.backup", wantOK: true},
|
||||
{name: "backup-yaml", entryName: "save.backup.yaml", wantName: "save.backup.yaml", wantOK: true},
|
||||
{name: "nested-path", entryName: "dir/sub/save.backup", wantName: "save.backup", wantOK: true},
|
||||
{name: "non-backup", entryName: "notes.txt", wantOK: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotName, gotOK := allowedBackupArchiveEntry(tt.entryName)
|
||||
if gotOK != tt.wantOK {
|
||||
t.Fatalf("expected ok=%v, got %v", tt.wantOK, gotOK)
|
||||
}
|
||||
if gotName != tt.wantName {
|
||||
t.Fatalf("expected name %q, got %q", tt.wantName, gotName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDirectBackupUpload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !isDirectBackupUpload("save.backup") {
|
||||
t.Fatal("expected plain .backup filename to be accepted")
|
||||
}
|
||||
if isDirectBackupUpload("save.zip") {
|
||||
t.Fatal("expected .zip to be rejected for direct backup upload")
|
||||
}
|
||||
if isDirectBackupUpload("../save.backup") {
|
||||
t.Fatal("expected path traversal to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBackupArchive_InvalidZip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
file := testMultipartFile{Reader: bytes.NewReader([]byte("not a zip"))}
|
||||
_, status, err := uploadBackupArchive("/unused", file)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid zip error")
|
||||
}
|
||||
if status != 400 {
|
||||
t.Fatalf("expected status 400, got %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBackupArchive_NoBackupFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("notes.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry: %v", err)
|
||||
}
|
||||
if _, err := w.Write([]byte("hello")); err != nil {
|
||||
t.Fatalf("write zip entry: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
|
||||
file := testMultipartFile{Reader: bytes.NewReader(buf.Bytes())}
|
||||
_, status, err := uploadBackupArchive("/unused", file)
|
||||
if err == nil || err.Error() != "zip contains no .backup file" {
|
||||
t.Fatalf("expected no-backup error, got %v", err)
|
||||
}
|
||||
if status != 400 {
|
||||
t.Fatalf("expected status 400, got %d", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// @Summary List all building blueprints
|
||||
// @Tags blueprints
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/blueprints [get]
|
||||
func handleListBlueprints(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdListBlueprints().(msgBlueprintList)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
rows := msg.rows
|
||||
if rows == nil {
|
||||
rows = []blueprintRow{}
|
||||
}
|
||||
jsonOK(w, rows)
|
||||
}
|
||||
|
||||
// @Summary Export a blueprint as a downloadable JSON file
|
||||
// @Tags blueprints
|
||||
// @Produce application/octet-stream
|
||||
// @Param id path int true "Blueprint ID"
|
||||
// @Success 200 {file} string "Blueprint JSON file"
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/blueprints/{id}/export [get]
|
||||
func handleExportBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid id"), 400)
|
||||
return
|
||||
}
|
||||
bf, err := fetchBlueprintData(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, blueprintFilename(bf.Name, id)))
|
||||
_ = json.NewEncoder(w).Encode(bf)
|
||||
}
|
||||
|
||||
// blueprintFilename returns the suggested download filename: the in-game name
|
||||
// if present (sanitized), otherwise blueprint_<id>.json.
|
||||
func blueprintFilename(name string, id int64) string {
|
||||
clean := sanitizeFilename(name)
|
||||
if clean == "" {
|
||||
return fmt.Sprintf("blueprint_%d.json", id)
|
||||
}
|
||||
return clean + ".json"
|
||||
}
|
||||
|
||||
// sanitizeFilename strips characters that are unsafe in filenames or
|
||||
// Content-Disposition values across common filesystems.
|
||||
func sanitizeFilename(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r < 0x20, r == 0x7f:
|
||||
// drop control chars
|
||||
case r == '/', r == '\\', r == ':', r == '*', r == '?', r == '"', r == '<', r == '>', r == '|':
|
||||
b.WriteRune('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// @Summary Import a blueprint JSON file into a player's inventory
|
||||
// @Tags blueprints
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param blueprint formData file true "Blueprint JSON file"
|
||||
// @Param player_id formData int true "Player pawn ID to receive the blueprint"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/blueprints/import [post]
|
||||
func handleImportBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
playerIDStr := r.FormValue("player_id")
|
||||
playerID, err := strconv.ParseInt(playerIDStr, 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid player_id"), 400)
|
||||
return
|
||||
}
|
||||
f, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("file required"), 400)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var bf blueprintFile
|
||||
if err := json.NewDecoder(f).Decode(&bf); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid blueprint JSON: %w", err), 400)
|
||||
return
|
||||
}
|
||||
if len(bf.Instances) == 0 && len(bf.Placeables) == 0 {
|
||||
jsonErr(w, fmt.Errorf("blueprint has no instances or placeables"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
msg, ok := importBlueprintData(r.Context(), playerID, bf).(msgMutate)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": msg.ok})
|
||||
}
|
||||
|
||||
// structuralBuildingTypes lists building_type values that game-saved blueprints
|
||||
// commonly mark with provides_stability=true (foundations, pillars, columns).
|
||||
// Used only as a fallback when importing legacy JSON that doesn't carry the
|
||||
// per-instance flag; the game's structural solver actually picks a subset of
|
||||
// these per build, so re-exported files always carry the exact bool.
|
||||
var structuralBuildingTypes = map[string]bool{
|
||||
"Atreides_Outpost_Column": true,
|
||||
"Atreides_Outpost_Column_Corner": true,
|
||||
"Atreides_Outpost_Foundation": true,
|
||||
"Atreides_Outpost_Foundation_Round_Corner": true,
|
||||
"Atreides_Outpost_Foundation_Wedge": true,
|
||||
"Atreides_Outpost_Pillar_Bottom": true,
|
||||
"Atreides_Outpost_Pillar_Middle": true,
|
||||
"Atreides_Outpost_Pillar_Top": true,
|
||||
"Choam_Level2_Column": true,
|
||||
"Choam_Level2_Foundation": true,
|
||||
"Choam_Level2_Pillar_Bottom": true,
|
||||
"Choam_Shelter_Column_Corner_New": true,
|
||||
"Choam_Shelter_Column_New": true,
|
||||
"Harkonnen_Outpost_Column": true,
|
||||
"Harkonnen_Outpost_Foundation": true,
|
||||
"MTX_Neut_DesertMechanic_Center_Column": true,
|
||||
"MTX_Neut_DesertMechanic_Corner_Column": true,
|
||||
"MTX_Neut_DesertMechanic_Foundation": true,
|
||||
"MTX_Smug_Foundation": true,
|
||||
}
|
||||
|
||||
func isStructuralBuilding(buildingType string) bool {
|
||||
return structuralBuildingTypes[buildingType]
|
||||
}
|
||||
|
||||
func fetchBlueprintName(ctx context.Context, blueprintID int64) string {
|
||||
var name string
|
||||
_ = globalDB.QueryRow(ctx, `
|
||||
SELECT COALESCE(i.stats->'FBuildingBlueprintItemStats'->1->>'BuildingBlueprintName', '')
|
||||
FROM dune.building_blueprints bb
|
||||
JOIN dune.items i ON i.id = bb.item_id
|
||||
WHERE bb.id = $1`, blueprintID).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func buildBlueprintInstance(iid int, buildingType string, transform []float32, stability bool) (blueprintInstance, bool) {
|
||||
if len(transform) < 4 {
|
||||
return blueprintInstance{}, false
|
||||
}
|
||||
return blueprintInstance{
|
||||
InstanceID: &iid,
|
||||
BuildingType: buildingType,
|
||||
X: float64(transform[0]),
|
||||
Y: float64(transform[1]),
|
||||
Z: float64(transform[2]),
|
||||
Rotation: float64(transform[3]),
|
||||
ProvidesStability: &stability,
|
||||
}, true
|
||||
}
|
||||
|
||||
func fetchBlueprintInstances(ctx context.Context, blueprintID int64) ([]blueprintInstance, error) {
|
||||
rows, err := globalDB.Query(ctx, `
|
||||
SELECT instance_id, building_type, transform, provides_stability
|
||||
FROM dune.building_blueprint_instances
|
||||
WHERE building_blueprint_id = $1
|
||||
ORDER BY instance_id`, blueprintID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []blueprintInstance
|
||||
for rows.Next() {
|
||||
var iid int
|
||||
var buildingType string
|
||||
var transform []float32
|
||||
var stability bool
|
||||
if err := rows.Scan(&iid, &buildingType, &transform, &stability); err != nil {
|
||||
continue
|
||||
}
|
||||
instance, ok := buildBlueprintInstance(iid, buildingType, transform, stability)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read instances: %w", err)
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func buildBlueprintPlaceable(pid int, buildingType string, transform []float32) (blueprintPlaceable, bool) {
|
||||
if len(transform) < 6 {
|
||||
return blueprintPlaceable{}, false
|
||||
}
|
||||
return blueprintPlaceable{
|
||||
PlaceableID: &pid,
|
||||
BuildingType: buildingType,
|
||||
X: float64(transform[0]),
|
||||
Y: float64(transform[1]),
|
||||
Z: float64(transform[2]),
|
||||
RX: float64(transform[3]),
|
||||
RY: float64(transform[4]),
|
||||
RZ: float64(transform[5]),
|
||||
}, true
|
||||
}
|
||||
|
||||
func fetchBlueprintPlaceables(ctx context.Context, blueprintID int64) ([]blueprintPlaceable, error) {
|
||||
rows, err := globalDB.Query(ctx, `
|
||||
SELECT placeable_id, building_type, transform
|
||||
FROM dune.building_blueprint_placeables
|
||||
WHERE building_blueprint_id = $1
|
||||
ORDER BY placeable_id`, blueprintID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query placeables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var placeables []blueprintPlaceable
|
||||
for rows.Next() {
|
||||
var pid int
|
||||
var buildingType string
|
||||
var transform []float32
|
||||
if err := rows.Scan(&pid, &buildingType, &transform); err != nil {
|
||||
continue
|
||||
}
|
||||
placeable, ok := buildBlueprintPlaceable(pid, buildingType, transform)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
placeables = append(placeables, placeable)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read placeables: %w", err)
|
||||
}
|
||||
return placeables, nil
|
||||
}
|
||||
|
||||
func buildBlueprintPentashield(pid int, scale []int16) (blueprintPentashield, bool) {
|
||||
if len(scale) < 3 {
|
||||
return blueprintPentashield{}, false
|
||||
}
|
||||
return blueprintPentashield{
|
||||
PlaceableID: pid,
|
||||
Scale: [3]int{int(scale[0]), int(scale[1]), int(scale[2])},
|
||||
}, true
|
||||
}
|
||||
|
||||
func fetchBlueprintPentashields(ctx context.Context, blueprintID int64) ([]blueprintPentashield, error) {
|
||||
rows, err := globalDB.Query(ctx, `
|
||||
SELECT placeable_id, scale
|
||||
FROM dune.building_blueprint_pentashields
|
||||
WHERE building_blueprint_id = $1
|
||||
ORDER BY placeable_id`, blueprintID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query pentashields: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pentashields []blueprintPentashield
|
||||
for rows.Next() {
|
||||
var pid int
|
||||
var scale []int16
|
||||
if err := rows.Scan(&pid, &scale); err != nil {
|
||||
continue
|
||||
}
|
||||
pentashield, ok := buildBlueprintPentashield(pid, scale)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pentashields = append(pentashields, pentashield)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read pentashields: %w", err)
|
||||
}
|
||||
return pentashields, nil
|
||||
}
|
||||
|
||||
// fetchBlueprintData fetches blueprint instances, placeables, and pentashields
|
||||
// from the DB and returns a blueprintFile ready for JSON serialization.
|
||||
func fetchBlueprintData(ctx context.Context, blueprintID int64) (blueprintFile, error) {
|
||||
if globalDB == nil {
|
||||
return blueprintFile{}, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
name := fetchBlueprintName(ctx, blueprintID)
|
||||
instances, err := fetchBlueprintInstances(ctx, blueprintID)
|
||||
if err != nil {
|
||||
return blueprintFile{}, err
|
||||
}
|
||||
placeables, err := fetchBlueprintPlaceables(ctx, blueprintID)
|
||||
if err != nil {
|
||||
return blueprintFile{}, err
|
||||
}
|
||||
pentashields, err := fetchBlueprintPentashields(ctx, blueprintID)
|
||||
if err != nil {
|
||||
return blueprintFile{}, err
|
||||
}
|
||||
|
||||
return blueprintFile{
|
||||
Name: name,
|
||||
Instances: instances,
|
||||
Placeables: placeables,
|
||||
Pentashields: pentashields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const blueprintImportBatchSize = 50
|
||||
|
||||
const blueprintPlaceholderStats = `{"FCustomizationStats":[[], {}],"FBuildingBlueprintItemStats":[[], {"PlayerBlueprintId":"!!bbp#0"}],"FItemStackAndDurabilityStats":[[], {"DecayedMaxDurability":0.0}]}`
|
||||
|
||||
func findBackpackInventoryID(ctx context.Context, tx pgx.Tx, playerPawnID int64) (int64, error) {
|
||||
var invID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM dune.inventories
|
||||
WHERE actor_id = $1 AND inventory_type = 0
|
||||
LIMIT 1`, playerPawnID).Scan(&invID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find inventory: %w", err)
|
||||
}
|
||||
return invID, nil
|
||||
}
|
||||
|
||||
func nextInventoryPosition(ctx context.Context, tx pgx.Tx, inventoryID int64) int64 {
|
||||
var nextPos int64
|
||||
_ = tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(position_index), -1) + 1
|
||||
FROM dune.items WHERE inventory_id = $1`, inventoryID).Scan(&nextPos)
|
||||
return nextPos
|
||||
}
|
||||
|
||||
func createBlueprintItem(ctx context.Context, tx pgx.Tx, inventoryID, position int64) (int64, error) {
|
||||
var itemID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO dune.items
|
||||
(inventory_id, stack_size, position_index, template_id, quality_level, stats)
|
||||
VALUES ($1, 1, $2, 'BuildingBlueprint_CopyDevice', 0, $3::jsonb)
|
||||
RETURNING id`,
|
||||
inventoryID, position, blueprintPlaceholderStats).Scan(&itemID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create item: %w", err)
|
||||
}
|
||||
return itemID, nil
|
||||
}
|
||||
|
||||
func createBlueprintRecord(ctx context.Context, tx pgx.Tx, itemID int64) (int64, error) {
|
||||
var blueprintID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO dune.building_blueprints (item_id, player_id, building_blueprint_map)
|
||||
VALUES ($1, null, '')
|
||||
RETURNING id`, itemID).Scan(&blueprintID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create blueprint: %w", err)
|
||||
}
|
||||
return blueprintID, nil
|
||||
}
|
||||
|
||||
func blueprintItemStatsJSON(blueprintID int64, name string) string {
|
||||
nameJSON := ""
|
||||
if name != "" {
|
||||
nameJSON = fmt.Sprintf(`,"BuildingBlueprintName":%q`, name)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"FCustomizationStats":[[], {}],"FBuildingBlueprintItemStats":[[], {"PlayerBlueprintId":"!!bbp#%d"%s}],"FItemStackAndDurabilityStats":[[], {"DecayedMaxDurability":0.0}]}`,
|
||||
blueprintID, nameJSON)
|
||||
}
|
||||
|
||||
func updateBlueprintItemStats(ctx context.Context, tx pgx.Tx, itemID, blueprintID int64, name string) error {
|
||||
if _, err := tx.Exec(ctx, `UPDATE dune.items SET stats = $1::jsonb WHERE id = $2`,
|
||||
blueprintItemStatsJSON(blueprintID, name), itemID); err != nil {
|
||||
return fmt.Errorf("update item stats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveBlueprintImportInstance(start, offset int, inst blueprintInstance) (instanceID int, transform string, stability bool) {
|
||||
transform = fmt.Sprintf("{%g,%g,%g,%g}",
|
||||
float32(inst.X), float32(inst.Y), float32(inst.Z), float32(inst.Rotation))
|
||||
instanceID = start + offset + 1
|
||||
if inst.InstanceID != nil {
|
||||
instanceID = *inst.InstanceID
|
||||
}
|
||||
stability = isStructuralBuilding(inst.BuildingType)
|
||||
if inst.ProvidesStability != nil {
|
||||
stability = *inst.ProvidesStability
|
||||
}
|
||||
return instanceID, transform, stability
|
||||
}
|
||||
|
||||
func insertBlueprintInstances(ctx context.Context, tx pgx.Tx, blueprintID int64, instances []blueprintInstance) error {
|
||||
for start := 0; start < len(instances); start += blueprintImportBatchSize {
|
||||
end := start + blueprintImportBatchSize
|
||||
if end > len(instances) {
|
||||
end = len(instances)
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for i, inst := range instances[start:end] {
|
||||
instanceID, transform, stability := resolveBlueprintImportInstance(start, i, inst)
|
||||
batch.Queue(`
|
||||
INSERT INTO dune.building_blueprint_instances
|
||||
(building_blueprint_id, instance_id, building_type, transform, hologram, provides_stability, health)
|
||||
VALUES ($1, $2, $3, $4::real[], true, $5, 0)`,
|
||||
blueprintID, instanceID, inst.BuildingType, transform, stability)
|
||||
}
|
||||
br := tx.SendBatch(ctx, batch)
|
||||
for i := start; i < end; i++ {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
_ = br.Close()
|
||||
return fmt.Errorf("insert instance %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
_ = br.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveBlueprintImportPlaceable(start, offset int, pl blueprintPlaceable) (placeableID int, transform string) {
|
||||
transform = fmt.Sprintf("{%g,%g,%g,%g,%g,%g}",
|
||||
float32(pl.X), float32(pl.Y), float32(pl.Z),
|
||||
float32(pl.RX), float32(pl.RY), float32(pl.RZ))
|
||||
placeableID = start + offset + 1
|
||||
if pl.PlaceableID != nil {
|
||||
placeableID = *pl.PlaceableID
|
||||
}
|
||||
return placeableID, transform
|
||||
}
|
||||
|
||||
func insertBlueprintPlaceables(ctx context.Context, tx pgx.Tx, blueprintID int64, placeables []blueprintPlaceable) error {
|
||||
for start := 0; start < len(placeables); start += blueprintImportBatchSize {
|
||||
end := start + blueprintImportBatchSize
|
||||
if end > len(placeables) {
|
||||
end = len(placeables)
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for i, pl := range placeables[start:end] {
|
||||
placeableID, transform := resolveBlueprintImportPlaceable(start, i, pl)
|
||||
batch.Queue(`
|
||||
INSERT INTO dune.building_blueprint_placeables
|
||||
(building_blueprint_id, placeable_id, building_type, transform, hologram)
|
||||
VALUES ($1, $2, $3, $4::real[], true)`,
|
||||
blueprintID, placeableID, pl.BuildingType, transform)
|
||||
}
|
||||
br := tx.SendBatch(ctx, batch)
|
||||
for i := start; i < end; i++ {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
_ = br.Close()
|
||||
return fmt.Errorf("insert placeable %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
_ = br.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertBlueprintPentashields(ctx context.Context, tx pgx.Tx, blueprintID int64, pentashields []blueprintPentashield) error {
|
||||
for _, ps := range pentashields {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO dune.building_blueprint_pentashields
|
||||
(building_blueprint_id, placeable_id, scale)
|
||||
VALUES ($1, $2, ARRAY[$3,$4,$5]::smallint[])`,
|
||||
blueprintID, ps.PlaceableID,
|
||||
int16(ps.Scale[0]), int16(ps.Scale[1]), int16(ps.Scale[2])); err != nil {
|
||||
return fmt.Errorf("insert pentashield %d: %w", ps.PlaceableID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// importBlueprintData imports a blueprintFile into the DB for the given player pawn ID.
|
||||
func importBlueprintData(ctx context.Context, playerPawnID int64, bf blueprintFile) Msg {
|
||||
if globalDB == nil {
|
||||
return msgMutate{err: fmt.Errorf("not connected")}
|
||||
}
|
||||
|
||||
// Player must be offline.
|
||||
if err := checkPlayerOffline(ctx, playerPawnID); err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
tx, err := globalDB.Begin(ctx)
|
||||
if err != nil {
|
||||
return msgMutate{err: fmt.Errorf("begin tx: %w", err)}
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
invID, err := findBackpackInventoryID(ctx, tx, playerPawnID)
|
||||
if err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
itemID, err := createBlueprintItem(ctx, tx, invID, nextInventoryPosition(ctx, tx, invID))
|
||||
if err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
blueprintID, err := createBlueprintRecord(ctx, tx, itemID)
|
||||
if err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
// Update item stats with real blueprint ID and name (no PlayerBaseBackupId — crashes the game).
|
||||
if err := updateBlueprintItemStats(ctx, tx, itemID, blueprintID, bf.Name); err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
// Insert instances in batches of 50.
|
||||
// Per-row instance_id and provides_stability come from the JSON when present
|
||||
// (fresh exports always include them). Legacy files without these fields fall
|
||||
// back to 1-based sequential ids and a structural-type stability lookup —
|
||||
// matching the indexing scheme used by every existing blueprint in the DB
|
||||
// that the source pentashield placeable_id references assume.
|
||||
if err := insertBlueprintInstances(ctx, tx, blueprintID, bf.Instances); err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
// Insert placeables in batches of 50.
|
||||
if err := insertBlueprintPlaceables(ctx, tx, blueprintID, bf.Placeables); err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
// Insert pentashield scale data.
|
||||
if err := insertBlueprintPentashields(ctx, tx, blueprintID, bf.Pentashields); err != nil {
|
||||
return msgMutate{err: err}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return msgMutate{err: fmt.Errorf("commit: %w", err)}
|
||||
}
|
||||
|
||||
return msgMutate{ok: fmt.Sprintf(
|
||||
"Imported %d pieces + %d placeables + %d pentashields → blueprint #%d (item %d) in player inventory",
|
||||
len(bf.Instances), len(bf.Placeables), len(bf.Pentashields), blueprintID, itemID)}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildBlueprintInstance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
instance, ok := buildBlueprintInstance(7, "TypeA", []float32{1, 2, 3, 4}, true)
|
||||
if !ok {
|
||||
t.Fatalf("expected valid transform to produce an instance")
|
||||
}
|
||||
if instance.InstanceID == nil || *instance.InstanceID != 7 {
|
||||
t.Fatalf("unexpected instance id: %+v", instance.InstanceID)
|
||||
}
|
||||
if instance.BuildingType != "TypeA" || instance.X != 1 || instance.Y != 2 || instance.Z != 3 || instance.Rotation != 4 {
|
||||
t.Fatalf("unexpected instance payload: %+v", instance)
|
||||
}
|
||||
if instance.ProvidesStability == nil || !*instance.ProvidesStability {
|
||||
t.Fatalf("expected stability=true pointer in instance")
|
||||
}
|
||||
|
||||
if _, ok := buildBlueprintInstance(1, "TypeB", []float32{1, 2, 3}, false); ok {
|
||||
t.Fatalf("expected short transform to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlueprintPlaceable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
placeable, ok := buildBlueprintPlaceable(9, "TypeP", []float32{1, 2, 3, 4, 5, 6})
|
||||
if !ok {
|
||||
t.Fatalf("expected valid transform to produce a placeable")
|
||||
}
|
||||
if placeable.PlaceableID == nil || *placeable.PlaceableID != 9 {
|
||||
t.Fatalf("unexpected placeable id: %+v", placeable.PlaceableID)
|
||||
}
|
||||
if placeable.BuildingType != "TypeP" || placeable.X != 1 || placeable.Y != 2 || placeable.Z != 3 || placeable.RX != 4 || placeable.RY != 5 || placeable.RZ != 6 {
|
||||
t.Fatalf("unexpected placeable payload: %+v", placeable)
|
||||
}
|
||||
|
||||
if _, ok := buildBlueprintPlaceable(1, "TypeBad", []float32{1, 2, 3, 4, 5}); ok {
|
||||
t.Fatalf("expected short placeable transform to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlueprintPentashield(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pentashield, ok := buildBlueprintPentashield(11, []int16{10, 20, 30})
|
||||
if !ok {
|
||||
t.Fatalf("expected valid scale to produce pentashield")
|
||||
}
|
||||
if pentashield.PlaceableID != 11 || pentashield.Scale != [3]int{10, 20, 30} {
|
||||
t.Fatalf("unexpected pentashield payload: %+v", pentashield)
|
||||
}
|
||||
|
||||
if _, ok := buildBlueprintPentashield(1, []int16{1, 2}); ok {
|
||||
t.Fatalf("expected short pentashield scale to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestBlueprintItemStatsJSON(t *testing.T) {
|
||||
withName := blueprintItemStatsJSON(123, "My Blueprint")
|
||||
if !strings.Contains(withName, `"PlayerBlueprintId":"!!bbp#123"`) {
|
||||
t.Fatalf("missing blueprint id in stats JSON: %s", withName)
|
||||
}
|
||||
if !strings.Contains(withName, `"BuildingBlueprintName":"My Blueprint"`) {
|
||||
t.Fatalf("missing blueprint name in stats JSON: %s", withName)
|
||||
}
|
||||
|
||||
withoutName := blueprintItemStatsJSON(77, "")
|
||||
if strings.Contains(withoutName, "BuildingBlueprintName") {
|
||||
t.Fatalf("expected no blueprint name when empty, got: %s", withoutName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBlueprintImportInstance(t *testing.T) {
|
||||
inst := blueprintInstance{
|
||||
BuildingType: "Atreides_Outpost_Foundation",
|
||||
X: 1,
|
||||
Y: 2,
|
||||
Z: 3,
|
||||
Rotation: 90,
|
||||
}
|
||||
id, transform, stability := resolveBlueprintImportInstance(50, 2, inst)
|
||||
if id != 53 {
|
||||
t.Fatalf("expected fallback instance id 53, got %d", id)
|
||||
}
|
||||
if transform != "{1,2,3,90}" {
|
||||
t.Fatalf("unexpected transform: %q", transform)
|
||||
}
|
||||
if !stability {
|
||||
t.Fatal("expected structural building fallback to set stability=true")
|
||||
}
|
||||
|
||||
customID := 900
|
||||
inst.InstanceID = intPtr(customID)
|
||||
inst.ProvidesStability = boolPtr(false)
|
||||
id, _, stability = resolveBlueprintImportInstance(0, 0, inst)
|
||||
if id != customID {
|
||||
t.Fatalf("expected explicit instance id %d, got %d", customID, id)
|
||||
}
|
||||
if stability {
|
||||
t.Fatal("expected explicit ProvidesStability override to win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBlueprintImportPlaceable(t *testing.T) {
|
||||
pl := blueprintPlaceable{
|
||||
BuildingType: "SomePlaceable",
|
||||
X: 4,
|
||||
Y: 5,
|
||||
Z: 6,
|
||||
RX: 1,
|
||||
RY: 2,
|
||||
RZ: 3,
|
||||
}
|
||||
id, transform := resolveBlueprintImportPlaceable(10, 1, pl)
|
||||
if id != 12 {
|
||||
t.Fatalf("expected fallback placeable id 12, got %d", id)
|
||||
}
|
||||
if transform != "{4,5,6,1,2,3}" {
|
||||
t.Fatalf("unexpected transform: %q", transform)
|
||||
}
|
||||
|
||||
customID := 321
|
||||
pl.PlaceableID = intPtr(customID)
|
||||
id, _ = resolveBlueprintImportPlaceable(0, 0, pl)
|
||||
if id != customID {
|
||||
t.Fatalf("expected explicit placeable id %d, got %d", customID, id)
|
||||
}
|
||||
}
|
||||
264
docs/reference-repos/icehunter/cmd/dune-admin/handlers_config.go
Normal file
264
docs/reference-repos/icehunter/cmd/dune-admin/handlers_config.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const masked = "••••••••"
|
||||
|
||||
// handleGetConfig returns the current config with all secret fields masked.
|
||||
//
|
||||
// @Summary Get current runtime configuration (secrets masked)
|
||||
// @Tags config
|
||||
// @Produce json
|
||||
// @Success 200 {object} appConfig
|
||||
// @Router /api/v1/config [get]
|
||||
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := os.ReadFile(configPath())
|
||||
if err != nil {
|
||||
jsonOK(w, buildCurrentConfig())
|
||||
return
|
||||
}
|
||||
var cfg appConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
jsonErr(w, fmt.Errorf("parse config: %w", err), 500)
|
||||
return
|
||||
}
|
||||
maskSecrets(&cfg)
|
||||
jsonOK(w, cfg)
|
||||
}
|
||||
|
||||
// maskSecrets replaces all secret fields with the display placeholder.
|
||||
func maskSecrets(cfg *appConfig) {
|
||||
if cfg.DBPass != "" {
|
||||
cfg.DBPass = masked
|
||||
}
|
||||
if cfg.BrokerPass != "" {
|
||||
cfg.BrokerPass = masked
|
||||
}
|
||||
if cfg.BrokerJWTSecret != "" {
|
||||
cfg.BrokerJWTSecret = masked
|
||||
}
|
||||
if cfg.MarketBotRemoteToken != "" {
|
||||
cfg.MarketBotRemoteToken = masked
|
||||
}
|
||||
if cfg.AmpAPIPass != "" {
|
||||
cfg.AmpAPIPass = masked
|
||||
}
|
||||
}
|
||||
|
||||
// preserveMaskedSecrets restores real secret values when the client sent back
|
||||
// the display placeholder. Falls back to loadedConfig when the file is
|
||||
// unreadable so in-memory secrets survive a mid-session config file move.
|
||||
func preserveMaskedSecrets(
|
||||
cfg *appConfig,
|
||||
readFile func(string) ([]byte, error),
|
||||
path string,
|
||||
) {
|
||||
needsRestore := cfg.DBPass == masked ||
|
||||
cfg.BrokerPass == masked ||
|
||||
cfg.BrokerJWTSecret == masked ||
|
||||
cfg.MarketBotRemoteToken == masked ||
|
||||
cfg.AmpAPIPass == masked
|
||||
|
||||
if !needsRestore {
|
||||
return
|
||||
}
|
||||
|
||||
old := loadedConfig
|
||||
if data, err := readFile(path); err == nil {
|
||||
_ = yaml.Unmarshal(data, &old)
|
||||
}
|
||||
// dbPass global may differ from loadedConfig when set from env var
|
||||
if old.DBPass == "" {
|
||||
old.DBPass = dbPass
|
||||
}
|
||||
|
||||
if cfg.DBPass == masked {
|
||||
cfg.DBPass = old.DBPass
|
||||
}
|
||||
if cfg.BrokerPass == masked {
|
||||
cfg.BrokerPass = old.BrokerPass
|
||||
}
|
||||
if cfg.BrokerJWTSecret == masked {
|
||||
cfg.BrokerJWTSecret = old.BrokerJWTSecret
|
||||
}
|
||||
if cfg.MarketBotRemoteToken == masked {
|
||||
cfg.MarketBotRemoteToken = old.MarketBotRemoteToken
|
||||
}
|
||||
if cfg.AmpAPIPass == masked {
|
||||
cfg.AmpAPIPass = old.AmpAPIPass
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigFile(cfg appConfig) error {
|
||||
if err := os.MkdirAll(configDir(), 0700); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(configPath(), data, 0600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopEmbeddedMarketBot cancels the running embedded bot (if any) and clears
|
||||
// embeddedBot and globalBotCancel. Call this before resetRuntimeConnections so
|
||||
// the bot releases its reference to the old (about-to-be-closed) globalDB pool.
|
||||
func stopEmbeddedMarketBot() {
|
||||
if embeddedBot == nil {
|
||||
return
|
||||
}
|
||||
if globalBotCancel != nil {
|
||||
globalBotCancel()
|
||||
globalBotCancel = nil
|
||||
}
|
||||
embeddedBot = nil
|
||||
}
|
||||
|
||||
func resetRuntimeConnections() {
|
||||
if globalDB != nil {
|
||||
globalDB.Close()
|
||||
globalDB = nil
|
||||
}
|
||||
if globalExecutor != nil {
|
||||
globalExecutor.Close()
|
||||
globalExecutor = nil
|
||||
}
|
||||
globalSSH = nil
|
||||
globalControl = nil
|
||||
}
|
||||
|
||||
// @Summary Save configuration and reconnect
|
||||
// @Tags config
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param config body appConfig true "Updated configuration"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/config [post]
|
||||
func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg appConfig
|
||||
if err := decode(r, &cfg); err != nil {
|
||||
jsonErr(w, fmt.Errorf("decode: %w", err), 400)
|
||||
return
|
||||
}
|
||||
|
||||
preserveMaskedSecrets(&cfg, os.ReadFile, configPath())
|
||||
|
||||
if err := writeConfigFile(cfg); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
applyConfig(cfg)
|
||||
|
||||
// Stop the running bot (if any) before closing the DB pool.
|
||||
// A running bot holds a reference to globalDB; if we close the pool first
|
||||
// the bot's next tick will use a closed pool and panic or error.
|
||||
stopEmbeddedMarketBot()
|
||||
|
||||
resetRuntimeConnections()
|
||||
|
||||
// Reconnect is best-effort — config is already written to disk.
|
||||
// If reconnect fails (e.g. SSH not yet reachable), the file is still
|
||||
// saved and will take effect on the next restart or manual reconnect.
|
||||
if err := connectAll(); err != nil {
|
||||
log.Printf("handleSaveConfig: reconnect after save: %v", err)
|
||||
}
|
||||
|
||||
// Apply the market bot config AFTER connectAll so the bot gets the
|
||||
// freshly-established globalDB rather than the old (closed) pool.
|
||||
// applyMarketBotConfig will restart the bot (if enabled) with the new pool.
|
||||
applyMarketBotConfig(cfg)
|
||||
handleStatus(w, r)
|
||||
}
|
||||
|
||||
// buildCurrentConfig constructs an appConfig from the current global vars.
|
||||
func buildCurrentConfig() appConfig {
|
||||
return appConfig{
|
||||
SSHHost: sshHost,
|
||||
SSHUser: sshUser,
|
||||
SSHKey: sshKeyPath,
|
||||
DBHost: dbHost,
|
||||
DBPort: dbPort,
|
||||
DBUser: dbUser,
|
||||
DBPass: masked,
|
||||
DBName: dbName,
|
||||
DBSchema: dbSchema,
|
||||
Control: controlPlane,
|
||||
ControlNamespace: controlNS,
|
||||
BrokerGameAddr: brokerGameAddr,
|
||||
BrokerAdminAddr: brokerAdminAddr,
|
||||
BrokerTLS: brokerTLS,
|
||||
BackupDir: backupDir,
|
||||
ListenAddr: listenAddr,
|
||||
ScripCurrency: scripCurrencyID,
|
||||
}
|
||||
}
|
||||
|
||||
// applyMarketBotConfig stops or starts the embedded market bot to match the
|
||||
// new config. Called after applyConfig so loadedConfig is already updated.
|
||||
func applyMarketBotConfig(cfg appConfig) {
|
||||
wantEnabled := marketBotEnabled(cfg)
|
||||
botRunning := embeddedBot != nil
|
||||
|
||||
if botRunning && !wantEnabled {
|
||||
log.Printf("config: market_bot_enabled set to false — stopping embedded bot")
|
||||
if globalBotCancel != nil {
|
||||
globalBotCancel()
|
||||
globalBotCancel = nil
|
||||
}
|
||||
embeddedBot = nil
|
||||
}
|
||||
|
||||
if !botRunning && wantEnabled {
|
||||
log.Printf("config: market_bot_enabled set to true — starting embedded bot")
|
||||
if cancel := startEmbeddedMarketBotIfEnabled(cfg); cancel != nil {
|
||||
globalBotCancel = cancel
|
||||
}
|
||||
}
|
||||
|
||||
// Update remote proxy from new config.
|
||||
if cfg.MarketBotRemoteURL != "" {
|
||||
remoteBotProxy = newRemoteBotClient(cfg.MarketBotRemoteURL, cfg.MarketBotRemoteToken)
|
||||
} else {
|
||||
remoteBotProxy = nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyConfig pushes a saved appConfig back into the runtime globals so that
|
||||
// connectAll() picks up the new values without requiring a process restart.
|
||||
func applyConfig(cfg appConfig) {
|
||||
sshHost = cfg.SSHHost
|
||||
sshUser = cfg.SSHUser
|
||||
if cfg.SSHKey != "" {
|
||||
sshKeyPath = cfg.SSHKey
|
||||
}
|
||||
dbHost = cfg.DBHost
|
||||
if cfg.DBPort != 0 {
|
||||
dbPort = cfg.DBPort
|
||||
}
|
||||
dbUser = cfg.DBUser
|
||||
dbPass = cfg.DBPass
|
||||
dbName = cfg.DBName
|
||||
dbSchema = cfg.DBSchema
|
||||
controlPlane = cfg.Control
|
||||
controlNS = cfg.ControlNamespace
|
||||
brokerGameAddr = cfg.BrokerGameAddr
|
||||
brokerAdminAddr = cfg.BrokerAdminAddr
|
||||
brokerTLS = cfg.BrokerTLS
|
||||
brokerUser = cfg.BrokerUser
|
||||
brokerPass = cfg.BrokerPass
|
||||
backupDir = cfg.BackupDir
|
||||
loadedConfig = cfg
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"dune-admin/internal/marketbot"
|
||||
)
|
||||
|
||||
// TestApplyMarketBotConfig_StopClearsBot verifies that when wantEnabled=false,
|
||||
// applyMarketBotConfig cancels the running bot and sets embeddedBot to nil.
|
||||
func TestApplyMarketBotConfig_StopClearsBot(t *testing.T) {
|
||||
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
|
||||
origBot := embeddedBot
|
||||
origCancel := globalBotCancel
|
||||
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
|
||||
|
||||
cancelled := false
|
||||
globalBotCancel = func() { cancelled = true }
|
||||
// Provide a non-nil embeddedBot instance so the running-check passes.
|
||||
embeddedBot = new(marketbot.Instance)
|
||||
|
||||
disabled := false
|
||||
cfg := appConfig{MarketBotEnabled: &disabled}
|
||||
applyMarketBotConfig(cfg)
|
||||
|
||||
if embeddedBot != nil {
|
||||
t.Error("embeddedBot should be nil after disabling")
|
||||
}
|
||||
if globalBotCancel != nil {
|
||||
t.Error("globalBotCancel should be nil after disabling")
|
||||
}
|
||||
if !cancelled {
|
||||
t.Error("cancel function should have been called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMarketBotConfig_StartRequiresDB verifies that applyMarketBotConfig
|
||||
// does NOT attempt to start the embedded bot when globalDB is nil. This
|
||||
// enforces the ordering contract: applyMarketBotConfig must only be called
|
||||
// AFTER connectAll() has established globalDB.
|
||||
func TestApplyMarketBotConfig_StartRequiresDB(t *testing.T) {
|
||||
// Not parallel: mutates package-level embeddedBot / globalDB.
|
||||
origBot := embeddedBot
|
||||
origCancel := globalBotCancel
|
||||
origDB := globalDB
|
||||
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel; globalDB = origDB })
|
||||
|
||||
embeddedBot = nil
|
||||
globalBotCancel = nil
|
||||
globalDB = nil // simulate pre-connectAll state
|
||||
|
||||
enabled := true
|
||||
cfg := appConfig{MarketBotEnabled: &enabled}
|
||||
applyMarketBotConfig(cfg)
|
||||
|
||||
// With globalDB nil, startEmbeddedMarketBotIfEnabled should fail and
|
||||
// embeddedBot should remain nil rather than holding a broken instance.
|
||||
if embeddedBot != nil {
|
||||
t.Error("embeddedBot should remain nil when globalDB is nil (connectAll not yet called)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopEmbeddedMarketBot_CancelsAndClearsGlobals verifies that
|
||||
// stopEmbeddedMarketBot cancels the running bot's goroutines and clears both
|
||||
// embeddedBot and globalBotCancel so the old (closed) DB pool is released.
|
||||
// This is the prerequisite step before resetRuntimeConnections in handleSaveConfig.
|
||||
func TestStopEmbeddedMarketBot_CancelsAndClearsGlobals(t *testing.T) {
|
||||
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
|
||||
origBot := embeddedBot
|
||||
origCancel := globalBotCancel
|
||||
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
|
||||
|
||||
cancelled := false
|
||||
globalBotCancel = func() { cancelled = true }
|
||||
embeddedBot = new(marketbot.Instance)
|
||||
|
||||
stopEmbeddedMarketBot()
|
||||
|
||||
if !cancelled {
|
||||
t.Error("stopEmbeddedMarketBot should call globalBotCancel")
|
||||
}
|
||||
if embeddedBot != nil {
|
||||
t.Error("stopEmbeddedMarketBot should set embeddedBot = nil")
|
||||
}
|
||||
if globalBotCancel != nil {
|
||||
t.Error("stopEmbeddedMarketBot should set globalBotCancel = nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopEmbeddedMarketBot_NoopWhenNotRunning verifies that stopEmbeddedMarketBot
|
||||
// is safe to call when no bot is running (nil embeddedBot).
|
||||
func TestStopEmbeddedMarketBot_NoopWhenNotRunning(t *testing.T) {
|
||||
// Not parallel: mutates package-level embeddedBot / globalBotCancel.
|
||||
origBot := embeddedBot
|
||||
origCancel := globalBotCancel
|
||||
t.Cleanup(func() { embeddedBot = origBot; globalBotCancel = origCancel })
|
||||
|
||||
embeddedBot = nil
|
||||
globalBotCancel = nil
|
||||
|
||||
// Should not panic.
|
||||
stopEmbeddedMarketBot()
|
||||
|
||||
if embeddedBot != nil {
|
||||
t.Error("embeddedBot should remain nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyConfig_SetsBrokerCredentials verifies that applyConfig copies broker
|
||||
// credentials into the package-level globals so hot-apply works without restart.
|
||||
func TestApplyConfig_SetsBrokerCredentials(t *testing.T) {
|
||||
// Not parallel: mutates package-level globals.
|
||||
origUser := brokerUser
|
||||
origPass := brokerPass
|
||||
origLoaded := loadedConfig
|
||||
t.Cleanup(func() {
|
||||
brokerUser = origUser
|
||||
brokerPass = origPass
|
||||
loadedConfig = origLoaded
|
||||
})
|
||||
|
||||
cfg := appConfig{
|
||||
BrokerUser: "cap_user",
|
||||
BrokerPass: "cap_pass",
|
||||
BrokerJWTSecret: "jwt_secret",
|
||||
}
|
||||
applyConfig(cfg)
|
||||
|
||||
if brokerUser != "cap_user" {
|
||||
t.Errorf("brokerUser = %q, want cap_user", brokerUser)
|
||||
}
|
||||
if brokerPass != "cap_pass" {
|
||||
t.Errorf("brokerPass = %q, want cap_pass", brokerPass)
|
||||
}
|
||||
// BrokerJWTSecret is read from loadedConfig in buildCaptureJWT; confirm it is set there.
|
||||
if loadedConfig.BrokerJWTSecret != "jwt_secret" {
|
||||
t.Errorf("loadedConfig.BrokerJWTSecret = %q, want jwt_secret", loadedConfig.BrokerJWTSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// PreserveMaskedDBPass exercises the preserveMaskedSecrets function for the
|
||||
// DBPass field specifically. Not parallel because subtests mutate loadedConfig.
|
||||
func TestPreserveMaskedDBPass(t *testing.T) {
|
||||
t.Run("keeps explicit password", func(t *testing.T) {
|
||||
cfg := appConfig{DBPass: "new-pass"}
|
||||
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
|
||||
t.Fatalf("readFile should not be called for explicit password")
|
||||
return nil, nil
|
||||
}, "/tmp/unused")
|
||||
if cfg.DBPass != "new-pass" {
|
||||
t.Fatalf("expected explicit password to stay unchanged, got %q", cfg.DBPass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses existing config password from file", func(t *testing.T) {
|
||||
cfg := appConfig{DBPass: "••••••••"}
|
||||
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
|
||||
return []byte("db_pass: stored-pass\n"), nil
|
||||
}, "/tmp/config.yaml")
|
||||
if cfg.DBPass != "stored-pass" {
|
||||
t.Fatalf("expected stored password from config file, got %q", cfg.DBPass)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to loadedConfig when file missing", func(t *testing.T) {
|
||||
orig := loadedConfig
|
||||
loadedConfig = appConfig{DBPass: "in-memory-pass"}
|
||||
t.Cleanup(func() { loadedConfig = orig })
|
||||
|
||||
cfg := appConfig{DBPass: "••••••••"}
|
||||
preserveMaskedSecrets(&cfg, func(string) ([]byte, error) {
|
||||
return nil, errors.New("no file")
|
||||
}, "/tmp/missing.yaml")
|
||||
if cfg.DBPass != "in-memory-pass" {
|
||||
t.Fatalf("expected in-memory fallback password, got %q", cfg.DBPass)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// allowedDataFiles is the exact set of filenames the /api/v1/data/{file}
|
||||
// endpoint will serve. It acts as the path-traversal guard: only these
|
||||
// well-known filenames are ever passed to the filesystem.
|
||||
var allowedDataFiles = map[string]bool{
|
||||
"item-data.json": true,
|
||||
"tags-data.json": true,
|
||||
"quality-data.json": true,
|
||||
"packs.json": true,
|
||||
"gameplayTags.json": true,
|
||||
"skillModules.json": true,
|
||||
"vehicles.json": true,
|
||||
"cheatScripts.json": true,
|
||||
}
|
||||
|
||||
// resolveDataFilePathFn is the file-path resolver used by handleGetDataFile.
|
||||
// Replaced in tests to inject a temp directory without touching the real filesystem.
|
||||
var resolveDataFilePathFn = resolveDataFilePath
|
||||
|
||||
// handleGetDataFile serves the named JSON data file as raw bytes.
|
||||
// The frontend calls this first; if the file is absent the frontend falls
|
||||
// back to the CDN. A 404 here is normal and expected.
|
||||
func handleGetDataFile(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("file")
|
||||
if !allowedDataFiles[name] {
|
||||
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
path := resolveDataFilePathFn(name)
|
||||
if path == "" {
|
||||
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path) // #nosec G304 -- allowlisted filename only; no user input reaches the path
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── allowlist enforcement ─────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleGetDataFile_NonAllowlistedReturns404(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
}{
|
||||
{"path traversal", "../config.yaml"},
|
||||
{"unknown json", "secrets.json"},
|
||||
{"dot-env", ".env"},
|
||||
{"go source", "main.go"},
|
||||
{"empty string", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/"+tt.filename, nil)
|
||||
req.SetPathValue("file", tt.filename)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetDataFile(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 for %q, got %d", tt.filename, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── file-absent path ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleGetDataFile_AllowlistedFileAbsent(t *testing.T) {
|
||||
// Not parallel: mutates resolveDataFilePathFn.
|
||||
orig := resolveDataFilePathFn
|
||||
resolveDataFilePathFn = func(string) string { return "" }
|
||||
t.Cleanup(func() { resolveDataFilePathFn = orig })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/item-data.json", nil)
|
||||
req.SetPathValue("file", "item-data.json")
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetDataFile(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── file-present path ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleGetDataFile_AllowlistedFilePresent(t *testing.T) {
|
||||
// Not parallel: mutates resolveDataFilePathFn.
|
||||
tmpDir := t.TempDir()
|
||||
content := []byte(`{"items":{}}`)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "item-data.json"), content, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := resolveDataFilePathFn
|
||||
resolveDataFilePathFn = func(name string) string { return filepath.Join(tmpDir, name) }
|
||||
t.Cleanup(func() { resolveDataFilePathFn = orig })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/item-data.json", nil)
|
||||
req.SetPathValue("file", "item-data.json")
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetDataFile(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("want Content-Type application/json, got %q", ct)
|
||||
}
|
||||
if got := rec.Body.Bytes(); string(got) != string(content) {
|
||||
t.Fatalf("want body %q, got %q", content, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetDataFile_AllEightFilesServed verifies every file in the allowlist
|
||||
// passes through the allowlist check and is served raw when present.
|
||||
func TestHandleGetDataFile_AllEightFilesServed(t *testing.T) {
|
||||
// Not parallel: mutates resolveDataFilePathFn.
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
wantFiles := []string{
|
||||
"item-data.json",
|
||||
"tags-data.json",
|
||||
"quality-data.json",
|
||||
"packs.json",
|
||||
"gameplayTags.json",
|
||||
"skillModules.json",
|
||||
"vehicles.json",
|
||||
"cheatScripts.json",
|
||||
}
|
||||
payload := []byte(`["sentinel"]`)
|
||||
for _, f := range wantFiles {
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, f), payload, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
orig := resolveDataFilePathFn
|
||||
resolveDataFilePathFn = func(name string) string { return filepath.Join(tmpDir, name) }
|
||||
t.Cleanup(func() { resolveDataFilePathFn = orig })
|
||||
|
||||
for _, f := range wantFiles {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/"+f, nil)
|
||||
req.SetPathValue("file", f)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetDataFile(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: want 200, got %d (body: %s)", f, rec.Code, rec.Body.String())
|
||||
continue
|
||||
}
|
||||
if got := rec.Body.String(); got != string(payload) {
|
||||
t.Errorf("%s: want body %q, got %q", f, payload, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── firstExistingPath ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestFirstExistingPath_ReturnsFirstMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
// Only dir2 has the file — should skip dir1 and return dir2.
|
||||
if err := os.WriteFile(filepath.Join(dir2, "data.json"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := firstExistingPath([]string{
|
||||
filepath.Join(dir1, "data.json"),
|
||||
filepath.Join(dir2, "data.json"),
|
||||
})
|
||||
want := filepath.Join(dir2, "data.json")
|
||||
if got != want {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstExistingPath_PrefersEarlierCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
// Both dirs have the file — should return dir1 (first in list).
|
||||
for _, d := range []string{dir1, dir2} {
|
||||
if err := os.WriteFile(filepath.Join(d, "data.json"), []byte("{}"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := firstExistingPath([]string{
|
||||
filepath.Join(dir1, "data.json"),
|
||||
filepath.Join(dir2, "data.json"),
|
||||
})
|
||||
want := filepath.Join(dir1, "data.json")
|
||||
if got != want {
|
||||
t.Fatalf("want %q (first match), got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstExistingPath_NoneExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
got := firstExistingPath([]string{
|
||||
filepath.Join(dir, "absent1.json"),
|
||||
filepath.Join(dir, "absent2.json"),
|
||||
})
|
||||
if got != "" {
|
||||
t.Fatalf("want empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstExistingPath_EmptyCandidateList(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := firstExistingPath(nil)
|
||||
if got != "" {
|
||||
t.Fatalf("want empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
sqlLineComment = regexp.MustCompile(`--[^\n]*`)
|
||||
sqlBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||
sqlReadOnlyRe = regexp.MustCompile(`^(select|explain|show|with)[\s(]`)
|
||||
)
|
||||
|
||||
func isReadOnlySQL(sql string) bool {
|
||||
s := sqlBlockComment.ReplaceAllString(sql, " ")
|
||||
s = sqlLineComment.ReplaceAllString(s, " ")
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
return sqlReadOnlyRe.MatchString(s)
|
||||
}
|
||||
|
||||
// @Summary List all tables in the dune schema
|
||||
// @Tags database
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/database/tables [get]
|
||||
func handleDBTables(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdFetchTables().(msgTables)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
type tableOut struct {
|
||||
Name string `json:"name"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
}
|
||||
rows := make([]tableOut, 0, len(msg.rows))
|
||||
for _, r := range msg.rows {
|
||||
rows = append(rows, tableOut(r))
|
||||
}
|
||||
jsonOK(w, rows)
|
||||
}
|
||||
|
||||
// @Summary Describe columns of a table
|
||||
// @Tags database
|
||||
// @Produce json
|
||||
// @Param table query string true "Table name"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/database/describe [get]
|
||||
func handleDBDescribe(w http.ResponseWriter, r *http.Request) {
|
||||
table := r.URL.Query().Get("table")
|
||||
if table == "" {
|
||||
jsonErr(w, fmt.Errorf("table required"), 400)
|
||||
return
|
||||
}
|
||||
msg, ok := cmdDescribeTable(table)().(msgDescribe)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
type colOut struct {
|
||||
Name string `json:"name"`
|
||||
DataType string `json:"data_type"`
|
||||
Nullable string `json:"nullable"`
|
||||
}
|
||||
cols := make([]colOut, 0, len(msg.cols))
|
||||
for _, c := range msg.cols {
|
||||
cols = append(cols, colOut(c))
|
||||
}
|
||||
jsonOK(w, map[string]any{"table": msg.table, "columns": cols})
|
||||
}
|
||||
|
||||
// @Summary Return sample rows from a table
|
||||
// @Tags database
|
||||
// @Produce json
|
||||
// @Param table query string true "Table name"
|
||||
// @Param limit query int false "Number of rows to return (default 20, max 500)"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/database/sample [get]
|
||||
func handleDBSample(w http.ResponseWriter, r *http.Request) {
|
||||
table := r.URL.Query().Get("table")
|
||||
if table == "" {
|
||||
jsonErr(w, fmt.Errorf("table required"), 400)
|
||||
return
|
||||
}
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
msg, ok := cmdSampleTable(table, limit)().(msgSample)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"table": msg.table,
|
||||
"headers": msg.headers,
|
||||
"rows": msg.rows,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Search for a term across all table columns
|
||||
// @Tags database
|
||||
// @Produce json
|
||||
// @Param term query string true "Search term"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/database/search [get]
|
||||
func handleDBSearch(w http.ResponseWriter, r *http.Request) {
|
||||
term := r.URL.Query().Get("term")
|
||||
if term == "" {
|
||||
jsonErr(w, fmt.Errorf("term required"), 400)
|
||||
return
|
||||
}
|
||||
msg, ok := cmdSearchColumns(term)().(msgSearchCols)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"headers": msg.headers,
|
||||
"rows": msg.rows,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Execute a read-only SQL query (SELECT/EXPLAIN/SHOW only)
|
||||
// @Tags database
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "SQL query" SchemaExample({"sql": "SELECT 1"})
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/database/sql [post]
|
||||
func handleDBSQL(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
var req struct {
|
||||
SQL string `json:"sql"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.SQL == "" {
|
||||
jsonErr(w, fmt.Errorf("sql required"), 400)
|
||||
return
|
||||
}
|
||||
if !isReadOnlySQL(req.SQL) {
|
||||
jsonErr(w, fmt.Errorf("only SELECT, EXPLAIN, and SHOW statements are allowed"), 400)
|
||||
return
|
||||
}
|
||||
msg, ok := cmdRunSQL(req.SQL)().(msgSQL)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"headers": msg.headers,
|
||||
"rows": msg.rows,
|
||||
"truncated": msg.truncated,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dbBackupProviderOrErr guards the globals and asserts the control plane supports
|
||||
// native DB backups, writing the appropriate error response if not.
|
||||
func dbBackupProviderOrErr(w http.ResponseWriter) (dbBackupProvider, bool) {
|
||||
if globalControl == nil || globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("control plane not connected"), http.StatusServiceUnavailable)
|
||||
return nil, false
|
||||
}
|
||||
prov, ok := globalControl.(dbBackupProvider)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("database backups are not supported by the %q control plane", globalControl.Name()),
|
||||
http.StatusNotImplemented)
|
||||
return nil, false
|
||||
}
|
||||
return prov, true
|
||||
}
|
||||
|
||||
// gameServersRunning reports whether any game-server processes are live, used as
|
||||
// the "battlegroup is stopped" guard for the destructive restore.
|
||||
func gameServersRunning(ctx context.Context) (bool, error) {
|
||||
st, err := globalControl.GetStatus(ctx, globalExecutor)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(st.Servers) > 0, nil
|
||||
}
|
||||
|
||||
// verifyDumpFile sanity-checks that a freshly written backup is a non-empty
|
||||
// pg_dump custom-format archive (magic "PGDMP"), so a silent failure (exit 0 but
|
||||
// empty output) doesn't masquerade as a good backup.
|
||||
func verifyDumpFile(path string) error {
|
||||
f, err := os.Open(path) // #nosec G304 G703 -- path is dbBackupDir() + a timestamped name we generated
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
hdr := make([]byte, 5)
|
||||
n, _ := io.ReadFull(f, hdr)
|
||||
if n < 5 || string(hdr[:5]) != "PGDMP" {
|
||||
return fmt.Errorf("not a pg_dump custom-format archive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary List database backups
|
||||
// @Tags db-backups
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/db-backups [get]
|
||||
func handleDBBackupList(w http.ResponseWriter, _ *http.Request) {
|
||||
files, err := listDBBackups()
|
||||
if err != nil {
|
||||
log.Printf("handleDBBackupList: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not list backups"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"backups": files})
|
||||
}
|
||||
|
||||
// @Summary Take a database backup now
|
||||
// @Tags db-backups
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 501 {object} map[string]string
|
||||
// @Router /api/v1/db-backups [post]
|
||||
func handleDBBackupCreate(w http.ResponseWriter, _ *http.Request) {
|
||||
prov, ok := dbBackupProviderOrErr(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dir, err := dbBackupDir()
|
||||
if err != nil {
|
||||
log.Printf("handleDBBackupCreate: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not prepare backup dir"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := dbBackupFilename(time.Now())
|
||||
dest := filepath.Join(dir, name)
|
||||
out, err := prov.BackupDatabase(globalExecutor, dbBackupConn(), dest)
|
||||
if err != nil {
|
||||
log.Printf("handleDBBackupCreate: %v (%s)", err, out)
|
||||
jsonErr(w, fmt.Errorf("backup failed"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := verifyDumpFile(dest); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
log.Printf("handleDBBackupCreate: invalid dump: %v", err)
|
||||
jsonErr(w, fmt.Errorf("backup produced no valid archive"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var size int64
|
||||
if info, statErr := os.Stat(dest); statErr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
jsonOK(w, map[string]any{"ok": "backup created", "name": name, "size_bytes": size})
|
||||
}
|
||||
|
||||
// @Summary Download a database backup
|
||||
// @Tags db-backups
|
||||
// @Produce octet-stream
|
||||
// @Param file query string true "backup filename"
|
||||
// @Router /api/v1/db-backups/download [get]
|
||||
func handleDBBackupDownload(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("file")
|
||||
if err := validateBackupName(name); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dir, err := dbBackupDir()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("backup dir unavailable"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(filepath.Join(dir, name)) // #nosec G304 G703 -- name validated by validateBackupName (no separators/..)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("backup not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
log.Printf("handleDBBackupDownload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Delete a database backup
|
||||
// @Tags db-backups
|
||||
// @Produce json
|
||||
// @Param file query string true "backup filename"
|
||||
// @Router /api/v1/db-backups [delete]
|
||||
func handleDBBackupDelete(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("file")
|
||||
if err := validateBackupName(name); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := deleteDBBackup(name); err != nil {
|
||||
log.Printf("handleDBBackupDelete: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not delete backup"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "backup deleted"})
|
||||
}
|
||||
|
||||
// @Summary Restore the database from a backup (DESTRUCTIVE — battlegroup must be stopped)
|
||||
// @Tags db-backups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/db-backups/restore [post]
|
||||
func handleDBBackupRestore(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
File string `json:"file"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !body.Confirm {
|
||||
jsonErr(w, fmt.Errorf("restore requires confirm=true"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBackupName(body.File); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
prov, ok := dbBackupProviderOrErr(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Destructive-op guard: refuse while the game is live — pg_restore --clean
|
||||
// over a running server would corrupt in-flight state.
|
||||
running, err := gameServersRunning(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("handleDBBackupRestore: status check: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not verify the battlegroup is stopped"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if running {
|
||||
jsonErr(w, fmt.Errorf("stop the battlegroup before restoring — game servers are running"),
|
||||
http.StatusConflict)
|
||||
return
|
||||
}
|
||||
dir, err := dbBackupDir()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("backup dir unavailable"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
src := filepath.Join(dir, body.File)
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
jsonErr(w, fmt.Errorf("backup not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
out, err := prov.RestoreDatabase(globalExecutor, dbBackupConn(), src)
|
||||
if err != nil {
|
||||
log.Printf("handleDBBackupRestore: %v (%s)", err, out)
|
||||
jsonErr(w, fmt.Errorf("restore failed"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
invalidateAllJourneyCache() // the database was replaced under us
|
||||
jsonOK(w, map[string]string{"ok": "database restored", "output": out})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHandleDBBackupRestore_RequiresConfirm verifies the destructive restore
|
||||
// endpoint rejects a request without confirm=true before doing anything else.
|
||||
func TestHandleDBBackupRestore_RequiresConfirm(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/db-backups/restore",
|
||||
strings.NewReader(`{"file":"dune-x.dump","confirm":false}`))
|
||||
handleDBBackupRestore(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("restore without confirm: code = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDBBackupCreate_NoControl verifies a 503 when no control plane is
|
||||
// connected (globals nil).
|
||||
func TestHandleDBBackupCreate_NoControl(t *testing.T) {
|
||||
prevC, prevE := globalControl, globalExecutor
|
||||
t.Cleanup(func() { globalControl, globalExecutor = prevC, prevE })
|
||||
globalControl, globalExecutor = nil, nil
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handleDBBackupCreate(rec, httptest.NewRequest(http.MethodPost, "/api/v1/db-backups", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("create with no control: code = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDBBackupDownload_BadName verifies path-traversal / bad names are rejected.
|
||||
func TestHandleDBBackupDownload_BadName(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handleDBBackupDownload(rec, httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/db-backups/download?file=../../etc/passwd", nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("download traversal: code = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// directorConfigStore is implemented by control planes that expose the
|
||||
// Battlegroup Director config file (AMP). Reads return the file path + content;
|
||||
// writes rewrite it. Under AMP the target is $STATE/director_config.ini, which
|
||||
// prestart.sh seeds from the Funcom template only when absent and then copies
|
||||
// into the runtime conf dir on EVERY start ("so director picks up any admin
|
||||
// edits") — so edits persist and take effect on the next instance restart.
|
||||
type directorConfigStore interface {
|
||||
readDirectorConfig(exec Executor) (path, content string, err error)
|
||||
writeDirectorConfig(exec Executor, content string) (path string, err error)
|
||||
}
|
||||
|
||||
// directorReadOnlySections are infrastructure wiring: launched values are
|
||||
// overridden by env/CLI in start-director.sh (Database_*, --RMQ*Hostname) and/or
|
||||
// contain secrets, so editing them in the ini has no effect — surfaced read-only.
|
||||
var directorReadOnlySections = map[string]bool{
|
||||
"Database": true, "RMQAdmin": true, "RMQGame": true,
|
||||
}
|
||||
|
||||
func isDirectorSecretKey(key string) bool {
|
||||
k := strings.ToLower(key)
|
||||
return strings.Contains(k, "password") || strings.Contains(k, "secret") || strings.Contains(k, "token")
|
||||
}
|
||||
|
||||
type directorKV struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
type directorSection struct {
|
||||
Name string `json:"name"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
Lines []directorKV `json:"lines"`
|
||||
}
|
||||
|
||||
// parseDirectorINI parses director_config.ini into ordered sections, splitting
|
||||
// each "key = value ;; comment" line into its parts. Section headers look like
|
||||
// "[ Battlegroup ]". Whole-line comments (';', '#') and blanks are skipped.
|
||||
// Secret values are blanked so they never reach the client.
|
||||
func parseDirectorINI(content string) []directorSection {
|
||||
sections := []directorSection{}
|
||||
cur := -1
|
||||
for _, raw := range strings.Split(content, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.TrimSpace(line[1 : len(line)-1])
|
||||
sections = append(sections, directorSection{Name: name, ReadOnly: directorReadOnlySections[name], Lines: []directorKV{}})
|
||||
cur = len(sections) - 1
|
||||
continue
|
||||
}
|
||||
if cur < 0 {
|
||||
continue
|
||||
}
|
||||
eq := strings.Index(line, "=")
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
kv := splitDirectorLine(line, eq)
|
||||
sections[cur].Lines = append(sections[cur].Lines, kv)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
// inlineCommentStart returns the index of the first inline comment delimiter
|
||||
// in s, or -1 if none. Recognises ";;" (double-semicolon), " ; " (single
|
||||
// semicolon padded with spaces), and " : " (colon padded with spaces).
|
||||
func inlineCommentStart(s string) int {
|
||||
best := -1
|
||||
for _, delim := range []string{";;", " ; ", " : "} {
|
||||
if c := strings.Index(s, delim); c >= 0 && (best < 0 || c < best) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func splitDirectorLine(line string, eq int) directorKV {
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
value, comment := line[eq+1:], ""
|
||||
if c := inlineCommentStart(value); c >= 0 {
|
||||
comment = strings.TrimSpace(strings.TrimLeft(value[c:], ";: "))
|
||||
value = value[:c]
|
||||
}
|
||||
kv := directorKV{Key: key, Value: strings.TrimSpace(value), Comment: comment, Secret: isDirectorSecretKey(key)}
|
||||
if kv.Secret {
|
||||
kv.Value = ""
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// rewriteDirectorLine replaces the value in a "key=value [comment]" line while
|
||||
// preserving any inline comment and its original delimiter verbatim.
|
||||
func rewriteDirectorLine(raw string, eq int, newVal string) string {
|
||||
afterEq := raw[eq+1:]
|
||||
comment := ""
|
||||
if c := inlineCommentStart(afterEq); c >= 0 {
|
||||
start := c
|
||||
if start > 0 && afterEq[start-1] == ' ' {
|
||||
start--
|
||||
}
|
||||
comment = afterEq[start:]
|
||||
}
|
||||
return raw[:eq+1] + newVal + comment
|
||||
}
|
||||
|
||||
// applyDirectorEdits rewrites the file, replacing the value of edited keys within
|
||||
// their section while preserving the key part, inline comments, ordering,
|
||||
// and all non-edited lines. edits is section -> key -> new value. Read-only
|
||||
// sections and secret keys are never written (double-guarded).
|
||||
func applyDirectorEdits(content string, edits map[string]map[string]string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
curSec := ""
|
||||
for i, raw := range lines {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
||||
curSec = strings.TrimSpace(trimmed[1 : len(trimmed)-1])
|
||||
continue
|
||||
}
|
||||
secEdits, ok := edits[curSec]
|
||||
if !ok || directorReadOnlySections[curSec] || trimmed == "" ||
|
||||
strings.HasPrefix(trimmed, ";") || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.Index(raw, "=")
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(raw[:eq])
|
||||
newVal, has := secEdits[key]
|
||||
if !has || isDirectorSecretKey(key) {
|
||||
continue
|
||||
}
|
||||
lines[i] = rewriteDirectorLine(raw, eq, newVal)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func directorStore() (directorConfigStore, bool) {
|
||||
if globalControl == nil || globalExecutor == nil {
|
||||
return nil, false
|
||||
}
|
||||
store, ok := globalControl.(directorConfigStore)
|
||||
return store, ok
|
||||
}
|
||||
|
||||
// @Summary Read the Battlegroup Director config (AMP only)
|
||||
// @Tags director
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 501 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/director-config [get]
|
||||
func handleGetDirectorConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
if globalControl == nil || globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
store, ok := directorStore()
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("director config is only available on the AMP control plane"), http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
path, content, err := store.readDirectorConfig(globalExecutor)
|
||||
if err != nil {
|
||||
log.Printf("handleGetDirectorConfig: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not read director config"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"path": path, "sections": parseDirectorINI(content)})
|
||||
}
|
||||
|
||||
// @Summary Update the Battlegroup Director config (AMP only)
|
||||
// @Tags director
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 501 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/director-config [put]
|
||||
func handleUpdateDirectorConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if globalControl == nil || globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
store, ok := directorStore()
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("director config is only available on the AMP control plane"), http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Updates map[string]map[string]string `json:"updates"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Updates) == 0 {
|
||||
jsonErr(w, fmt.Errorf("no updates provided"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, content, err := store.readDirectorConfig(globalExecutor)
|
||||
if err != nil {
|
||||
log.Printf("handleUpdateDirectorConfig: read: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not read director config"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path, err := store.writeDirectorConfig(globalExecutor, applyDirectorEdits(content, body.Updates))
|
||||
if err != nil {
|
||||
log.Printf("handleUpdateDirectorConfig: write: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not write director config"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "director config updated — restart the server to apply", "path": path})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleDirectorINI = `[ Database ]
|
||||
address=localhost
|
||||
password=seabass
|
||||
|
||||
[ Battlegroup ]
|
||||
DbFetchInterval=5 ;; seconds between fetch
|
||||
ForceIsWorldClosed=false ;; override db value
|
||||
|
||||
[ InstancingModes ]
|
||||
Survival_1=Dimension
|
||||
DeepDesert_1=ClassicalInstancing
|
||||
`
|
||||
|
||||
// inlineCommentStart tests
|
||||
func TestInlineCommentStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
s string
|
||||
want int
|
||||
}{
|
||||
{"false ;; some comment", 6}, // ;; starts at 6 (after "false ")
|
||||
{"false ; some comment", 5}, // " ; " starts at 5 (the leading space)
|
||||
{"false : some comment", 5}, // " : " starts at 5 (the leading space)
|
||||
{"value", -1}, // no comment
|
||||
{"5", -1}, // no comment
|
||||
{"ClassicalInstancing", -1}, // no comment
|
||||
{" ;; leading space delim", 1}, // ;; starts at 1 (after the leading space)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := inlineCommentStart(tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("inlineCommentStart(%q) = %d, want %d", tt.s, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitDirectorLine tests for alternative comment delimiters
|
||||
func TestSplitDirectorLine_Delimiters(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
line string
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantComment string
|
||||
}{
|
||||
{
|
||||
// existing double-semicolon style
|
||||
line: "DbFetchInterval=5 ;; seconds between fetch",
|
||||
wantKey: "DbFetchInterval",
|
||||
wantValue: "5",
|
||||
wantComment: "seconds between fetch",
|
||||
},
|
||||
{
|
||||
// single semicolon with spaces (Funcom style)
|
||||
line: "KeepPartiesTogether=false ; Remove when PlayerHardCap is changed to > 1",
|
||||
wantKey: "KeepPartiesTogether",
|
||||
wantValue: "false",
|
||||
wantComment: "Remove when PlayerHardCap is changed to > 1",
|
||||
},
|
||||
{
|
||||
// colon delimiter
|
||||
line: "KeepPartiesTogether=false : Remove when PlayerHardCap is changed to > 1",
|
||||
wantKey: "KeepPartiesTogether",
|
||||
wantValue: "false",
|
||||
wantComment: "Remove when PlayerHardCap is changed to > 1",
|
||||
},
|
||||
{
|
||||
// no comment
|
||||
line: "Survival_1=Dimension",
|
||||
wantKey: "Survival_1",
|
||||
wantValue: "Dimension",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
eq := strings.Index(tt.line, "=")
|
||||
kv := splitDirectorLine(tt.line, eq)
|
||||
if kv.Key != tt.wantKey || kv.Value != tt.wantValue || kv.Comment != tt.wantComment {
|
||||
t.Errorf("splitDirectorLine(%q) = {Key:%q Value:%q Comment:%q}, want {Key:%q Value:%q Comment:%q}",
|
||||
tt.line, kv.Key, kv.Value, kv.Comment, tt.wantKey, tt.wantValue, tt.wantComment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyDirectorEdits must preserve the original comment delimiter verbatim
|
||||
func TestApplyDirectorEdits_Delimiters(t *testing.T) {
|
||||
t.Parallel()
|
||||
ini := `[ Battlegroup ]
|
||||
KeepPartiesTogether=false : Remove when PlayerHardCap is changed to > 1
|
||||
MaxPlayersPerParty=10 ; max players
|
||||
NoComment=old
|
||||
`
|
||||
edits := map[string]map[string]string{
|
||||
"Battlegroup": {
|
||||
"KeepPartiesTogether": "true",
|
||||
"MaxPlayersPerParty": "20",
|
||||
"NoComment": "new",
|
||||
},
|
||||
}
|
||||
out := applyDirectorEdits(ini, edits)
|
||||
|
||||
if !strings.Contains(out, "KeepPartiesTogether=true : Remove when PlayerHardCap is changed to > 1") {
|
||||
t.Errorf("colon delimiter not preserved:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "MaxPlayersPerParty=20 ; max players") {
|
||||
t.Errorf("semicolon delimiter not preserved:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "NoComment=new") {
|
||||
t.Errorf("no-comment edit not applied:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func findDirectorSection(secs []directorSection, name string) *directorSection {
|
||||
for i := range secs {
|
||||
if secs[i].Name == name {
|
||||
return &secs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findDirectorKV(sec *directorSection, key string) *directorKV {
|
||||
if sec == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range sec.Lines {
|
||||
if sec.Lines[i].Key == key {
|
||||
return &sec.Lines[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestParseDirectorINI(t *testing.T) {
|
||||
t.Parallel()
|
||||
secs := parseDirectorINI(sampleDirectorINI)
|
||||
|
||||
db := findDirectorSection(secs, "Database")
|
||||
if db == nil || !db.ReadOnly {
|
||||
t.Fatalf("Database section missing or not read-only: %+v", db)
|
||||
}
|
||||
if pw := findDirectorKV(db, "password"); pw == nil || !pw.Secret || pw.Value != "" {
|
||||
t.Errorf("password should be secret + blanked, got %+v", pw)
|
||||
}
|
||||
|
||||
bg := findDirectorSection(secs, "Battlegroup")
|
||||
if bg == nil || bg.ReadOnly {
|
||||
t.Fatalf("Battlegroup section missing or wrongly read-only")
|
||||
}
|
||||
fetch := findDirectorKV(bg, "DbFetchInterval")
|
||||
if fetch == nil || fetch.Value != "5" || fetch.Comment != "seconds between fetch" {
|
||||
t.Errorf("DbFetchInterval parse wrong: %+v", fetch)
|
||||
}
|
||||
|
||||
im := findDirectorSection(secs, "InstancingModes")
|
||||
if dd := findDirectorKV(im, "DeepDesert_1"); dd == nil || dd.Value != "ClassicalInstancing" {
|
||||
t.Errorf("InstancingModes DeepDesert_1 parse wrong: %+v", dd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDirectorEdits(t *testing.T) {
|
||||
t.Parallel()
|
||||
edits := map[string]map[string]string{
|
||||
"Battlegroup": {"DbFetchInterval": "10"},
|
||||
"InstancingModes": {"DeepDesert_1": "Dimension"},
|
||||
"Database": {"address": "evil-host", "password": "hacked"}, // read-only + secret → ignored
|
||||
}
|
||||
out := applyDirectorEdits(sampleDirectorINI, edits)
|
||||
|
||||
if !strings.Contains(out, "DbFetchInterval=10 ;; seconds between fetch") {
|
||||
t.Errorf("edited value/comment not preserved:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "DeepDesert_1=Dimension") || strings.Contains(out, "DeepDesert_1=ClassicalInstancing") {
|
||||
t.Errorf("InstancingModes edit not applied:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=localhost") || strings.Contains(out, "evil-host") {
|
||||
t.Errorf("read-only Database section must NOT be edited:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "password=seabass") || strings.Contains(out, "hacked") {
|
||||
t.Errorf("secret/read-only key must NOT be edited:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetDirectorConfig_NotConnected(t *testing.T) {
|
||||
origC, origE := globalControl, globalExecutor
|
||||
globalControl, globalExecutor = nil, nil
|
||||
defer func() { globalControl, globalExecutor = origC, origE }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/director-config", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handleGetDirectorConfig(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// givePacksConfigResponse is the shape of the GET and PUT /give-packs/config
|
||||
// endpoints. The whole pack library is transferred in one payload.
|
||||
type givePacksConfigResponse struct {
|
||||
Packs []givePack `json:"packs"`
|
||||
}
|
||||
|
||||
// handleGetGivePacksConfig returns the current operator-configured pack library.
|
||||
// When the store has no row (first boot after seeding was skipped or failed),
|
||||
// it returns an empty list rather than erroring.
|
||||
func handleGetGivePacksConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
if givePacksStoreDB == nil {
|
||||
jsonErr(w, fmt.Errorf("give-packs store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, packsJSON, ok, err := givePacksStoreDB.loadConfig()
|
||||
if err != nil {
|
||||
log.Printf("handleGetGivePacksConfig: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
packs := make([]givePack, 0)
|
||||
if ok && packsJSON != "" && packsJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(packsJSON), &packs); err != nil {
|
||||
log.Printf("handleGetGivePacksConfig: unmarshal packs: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonOK(w, givePacksConfigResponse{Packs: packs})
|
||||
}
|
||||
|
||||
// handlePutGivePacksConfig replaces the operator's pack library. Validates the
|
||||
// incoming packs and persists with base_packs_loaded=true so startup never
|
||||
// re-seeds (requirement: deleting all packs must stay empty).
|
||||
func handlePutGivePacksConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if givePacksStoreDB == nil {
|
||||
jsonErr(w, fmt.Errorf("give-packs store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req givePacksConfigResponse
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Packs == nil {
|
||||
req.Packs = []givePack{}
|
||||
}
|
||||
if err := validateGivePacks(req.Packs); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
packsJSON, err := json.Marshal(req.Packs)
|
||||
if err != nil {
|
||||
log.Printf("handlePutGivePacksConfig: marshal: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Always persist with base_packs_loaded=true — this is a deliberate operator
|
||||
// action, including an explicit empty list.
|
||||
if err := givePacksStoreDB.saveConfig(string(packsJSON), true); err != nil {
|
||||
log.Printf("handlePutGivePacksConfig: save: %v", err)
|
||||
jsonErr(w, fmt.Errorf("failed to save packs"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, givePacksConfigResponse{Packs: req.Packs})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── handleGetGivePacksConfig ─────────────────────────────────────────────────
|
||||
|
||||
func TestHandleGetGivePacksConfig_NilStore503(t *testing.T) {
|
||||
givePacksStoreDB = nil
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetGivePacksConfig_EmptyStore(t *testing.T) {
|
||||
setupGivePacksStore(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp givePacksConfigResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Packs == nil {
|
||||
t.Error("expected non-nil packs slice (empty), got nil")
|
||||
}
|
||||
if len(resp.Packs) != 0 {
|
||||
t.Errorf("expected empty packs on fresh store, got %d", len(resp.Packs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetGivePacksConfig_ReturnsSavedPacks(t *testing.T) {
|
||||
s := setupGivePacksStore(t)
|
||||
|
||||
packs := []givePack{
|
||||
{ID: "starter-t1", Name: "T1", Category: "Starter", Tier: 1, Items: []welcomePackageItem{
|
||||
{Template: "Ammo", Qty: 500, Quality: 0},
|
||||
}},
|
||||
}
|
||||
packsJSON, _ := json.Marshal(packs)
|
||||
if err := s.saveConfig(string(packsJSON), true); err != nil {
|
||||
t.Fatalf("saveConfig: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp givePacksConfigResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Packs) != 1 {
|
||||
t.Fatalf("expected 1 pack, got %d", len(resp.Packs))
|
||||
}
|
||||
if resp.Packs[0].ID != "starter-t1" {
|
||||
t.Errorf("expected id=starter-t1, got %q", resp.Packs[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── handlePutGivePacksConfig ─────────────────────────────────────────────────
|
||||
|
||||
func TestHandlePutGivePacksConfig_NilStore503(t *testing.T) {
|
||||
givePacksStoreDB = nil
|
||||
|
||||
body, _ := json.Marshal(givePacksConfigResponse{Packs: []givePack{}})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePutGivePacksConfig_BadBody400(t *testing.T) {
|
||||
setupGivePacksStore(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader([]byte("not-json")))
|
||||
rec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePutGivePacksConfig_ValidationError400(t *testing.T) {
|
||||
setupGivePacksStore(t)
|
||||
|
||||
// Duplicate id → validation fails.
|
||||
badPacks := givePacksConfigResponse{Packs: []givePack{
|
||||
{ID: "dup", Name: "A", Category: "X", Tier: 1, Items: []welcomePackageItem{{Template: "A", Qty: 1, Quality: 0}}},
|
||||
{ID: "dup", Name: "B", Category: "Y", Tier: 2, Items: []welcomePackageItem{{Template: "B", Qty: 1, Quality: 0}}},
|
||||
}}
|
||||
body, _ := json.Marshal(badPacks)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePutGivePacksConfig_PersistsAndReturns(t *testing.T) {
|
||||
setupGivePacksStore(t)
|
||||
|
||||
packs := givePacksConfigResponse{Packs: []givePack{
|
||||
{ID: "buggy-t6", Name: "T6", Category: "Buggy", Tier: 6, Items: []welcomePackageItem{
|
||||
{Template: "BuggyBoost_6", Qty: 1, Quality: 0},
|
||||
}},
|
||||
}}
|
||||
body, _ := json.Marshal(packs)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp givePacksConfigResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode PUT response: %v", err)
|
||||
}
|
||||
if len(resp.Packs) != 1 || resp.Packs[0].ID != "buggy-t6" {
|
||||
t.Fatalf("unexpected response packs: %+v", resp.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePutGivePacksConfig_RoundTrip(t *testing.T) {
|
||||
setupGivePacksStore(t)
|
||||
|
||||
putPacks := givePacksConfigResponse{Packs: []givePack{
|
||||
{ID: "scout-t3", Name: "T3", Category: "Scout", Tier: 3, Items: []welcomePackageItem{
|
||||
{Template: "ScoutPart_3", Qty: 2, Quality: 0},
|
||||
}},
|
||||
}}
|
||||
body, _ := json.Marshal(putPacks)
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(body))
|
||||
putRec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(putRec, putReq)
|
||||
if putRec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT: want 200, got %d: %s", putRec.Code, putRec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
handleGetGivePacksConfig(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET: want 200, got %d: %s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
var got givePacksConfigResponse
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode GET: %v", err)
|
||||
}
|
||||
if len(got.Packs) != 1 || got.Packs[0].ID != "scout-t3" {
|
||||
t.Fatalf("GET after PUT returned wrong packs: %+v", got.Packs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePutGivePacksConfig_EmptyPacksNoReSeed(t *testing.T) {
|
||||
// Deleting all packs (empty PUT) must NOT trigger re-seed on the next GET.
|
||||
s := setupGivePacksStore(t)
|
||||
|
||||
// Pre-seed with some data.
|
||||
if err := s.saveConfig(`[{"id":"x","name":"X","category":"X","tier":1,"items":[]}]`, true); err != nil {
|
||||
t.Fatalf("pre-seed: %v", err)
|
||||
}
|
||||
|
||||
// PUT empty packs.
|
||||
emptyBody, _ := json.Marshal(givePacksConfigResponse{Packs: []givePack{}})
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/give-packs/config", bytes.NewReader(emptyBody))
|
||||
putRec := httptest.NewRecorder()
|
||||
handlePutGivePacksConfig(putRec, putReq)
|
||||
if putRec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT empty: want 200, got %d: %s", putRec.Code, putRec.Body.String())
|
||||
}
|
||||
|
||||
// GET must return empty, no re-seed.
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/give-packs/config", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
handleGetGivePacksConfig(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET: want 200, got %d: %s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
var got givePacksConfigResponse
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode GET: %v", err)
|
||||
}
|
||||
if len(got.Packs) != 0 {
|
||||
t.Fatalf("expected 0 packs after empty PUT, got %d (re-seed must NOT happen)", len(got.Packs))
|
||||
}
|
||||
}
|
||||
192
docs/reference-repos/icehunter/cmd/dune-admin/handlers_guilds.go
Normal file
192
docs/reference-repos/icehunter/cmd/dune-admin/handlers_guilds.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var errEmptyGuildName = errors.New("guild name must not be empty")
|
||||
|
||||
// @Summary List all guilds with member count + faction name
|
||||
// @Tags guilds
|
||||
// @Produce json
|
||||
// @Success 200 {array} guildSummary
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/guilds [get]
|
||||
func handleListGuilds(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
guilds, err := cmdFetchGuilds(r.Context(), globalDB)
|
||||
if err != nil {
|
||||
log.Printf("handleListGuilds: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, guilds)
|
||||
}
|
||||
|
||||
// @Summary Get one guild with its members and pending invites
|
||||
// @Tags guilds
|
||||
// @Produce json
|
||||
// @Param id path int true "Guild ID"
|
||||
// @Success 200 {object} guildDetail
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/guilds/{id} [get]
|
||||
func handleGetGuild(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
detail, err := cmdFetchGuildDetail(r.Context(), globalDB, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errGuildNotFound) {
|
||||
jsonErr(w, fmt.Errorf("guild not found"), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("handleGetGuild: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, detail)
|
||||
}
|
||||
|
||||
// applyGuildUpdate applies the provided (optional) name/description edits. Returns
|
||||
// sentinel errors (errEmptyGuildName / errGuildNameTaken / errGuildNotFound) that
|
||||
// the handler maps to HTTP statuses.
|
||||
func applyGuildUpdate(r *http.Request, id int64, name, desc *string) error {
|
||||
if desc != nil {
|
||||
if err := cmdEditGuildDescription(r.Context(), globalDB, id, *desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if name != nil {
|
||||
n := strings.TrimSpace(*name)
|
||||
if n == "" {
|
||||
return errEmptyGuildName
|
||||
}
|
||||
if err := cmdEditGuildName(r.Context(), globalDB, id, n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeGuildUpdateErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, errEmptyGuildName):
|
||||
jsonErr(w, fmt.Errorf("guild name must not be empty"), http.StatusBadRequest)
|
||||
case errors.Is(err, errGuildNameTaken):
|
||||
jsonErr(w, fmt.Errorf("guild name already taken"), http.StatusConflict)
|
||||
case errors.Is(err, errGuildNotFound):
|
||||
jsonErr(w, fmt.Errorf("guild not found"), http.StatusNotFound)
|
||||
default:
|
||||
log.Printf("handleUpdateGuild: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Edit a guild's name and/or description
|
||||
// @Tags guilds
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Guild ID"
|
||||
// @Success 200 {object} guildDetail
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/guilds/{id} [patch]
|
||||
func handleUpdateGuild(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == nil && body.Description == nil {
|
||||
jsonErr(w, fmt.Errorf("nothing to update"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := applyGuildUpdate(r, id, body.Name, body.Description); err != nil {
|
||||
writeGuildUpdateErr(w, err)
|
||||
return
|
||||
}
|
||||
detail, err := cmdFetchGuildDetail(r.Context(), globalDB, id)
|
||||
if err != nil {
|
||||
writeGuildUpdateErr(w, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, detail)
|
||||
}
|
||||
|
||||
// @Summary Set a guild member's role (50 = member, 100 = admin)
|
||||
// @Tags guilds
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Guild ID"
|
||||
// @Param pid path int true "Member player (actor) ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/guilds/{id}/members/{pid}/role [put]
|
||||
func handleSetGuildMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid guild id"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid player id"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role int16 `json:"role"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Role != guildRoleMember && body.Role != guildRoleAdmin {
|
||||
jsonErr(w, fmt.Errorf("role must be %d (member) or %d (admin)", guildRoleMember, guildRoleAdmin), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := cmdSetGuildMemberRole(r.Context(), globalDB, id, pid, body.Role); err != nil {
|
||||
// The game procs raise on invalid transitions (e.g. demoting the sitting
|
||||
// admin). Surface a hint; log the detail.
|
||||
log.Printf("handleSetGuildMemberRole: %v", err)
|
||||
jsonErr(w, fmt.Errorf("role change rejected — to change the admin, promote another member to admin first"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "role updated"})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGuildMemberDisplayName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
charName string
|
||||
actorID int64
|
||||
want string
|
||||
}{
|
||||
{"resolved name passes through", "Paul Atreides", 123, "Paul Atreides"},
|
||||
{"empty name falls back to actor id", "", 456, "Actor 456"},
|
||||
{"whitespace-only name falls back", " ", 789, "Actor 789"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := guildMemberDisplayName(tt.charName, tt.actorID); got != tt.want {
|
||||
t.Fatalf("guildMemberDisplayName(%q, %d) = %q, want %q", tt.charName, tt.actorID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListGuilds_DBNil(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handleListGuilds(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetGuild_DBNil(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds/42", nil)
|
||||
req.SetPathValue("id", "42")
|
||||
rr := httptest.NewRecorder()
|
||||
handleGetGuild(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the project convention (see handlers_stats_test.go): the globalDB
|
||||
// nil-guard is checked before the id parse, so a bad id with no DB returns 503,
|
||||
// not 400.
|
||||
func TestHandleGetGuild_InvalidID_DBNilFirst(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/guilds/not-a-number", nil)
|
||||
req.SetPathValue("id", "not-a-number")
|
||||
rr := httptest.NewRecorder()
|
||||
handleGetGuild(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503 (db nil checked before id parse), got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuildRoleSetProc(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Setting a member to admin (100) must route through promote_guild_member
|
||||
// (which transfers the single admin slot); any lower role uses
|
||||
// demote_guild_member (which guards against demoting the current admin).
|
||||
cases := map[int16]string{
|
||||
guildRoleAdmin: "promote_guild_member",
|
||||
guildRoleMember: "demote_guild_member",
|
||||
75: "demote_guild_member",
|
||||
}
|
||||
for role, want := range cases {
|
||||
if got := guildRoleSetProc(role); got != want {
|
||||
t.Errorf("guildRoleSetProc(%d) = %q, want %q", role, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateGuild_DBNil(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/guilds/1", nil)
|
||||
req.SetPathValue("id", "1")
|
||||
rr := httptest.NewRecorder()
|
||||
handleUpdateGuild(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetGuildMemberRole_DBNil(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/guilds/1/members/2/role", nil)
|
||||
req.SetPathValue("id", "1")
|
||||
req.SetPathValue("pid", "2")
|
||||
rr := httptest.NewRecorder()
|
||||
handleSetGuildMemberRole(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// @Summary Landsraad overview — latest term, decree catalogue, and task board
|
||||
// @Tags landsraad
|
||||
// @Produce json
|
||||
// @Success 200 {object} landsraadOverview
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/landsraad [get]
|
||||
func handleGetLandsraad(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
ov, err := cmdFetchLandsraad(r.Context(), globalDB)
|
||||
if err != nil {
|
||||
log.Printf("handleGetLandsraad: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, ov)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLandsraadHouseName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct{ name, in, want string }{
|
||||
{"strips DA_House prefix", "DA_HouseHagal", "Hagal"},
|
||||
{"strips prefix Moritani", "DA_HouseMoritani", "Moritani"},
|
||||
{"unprefixed passes through", "Corrino", "Corrino"},
|
||||
{"empty stays empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := landsraadHouseName(tt.in); got != tt.want {
|
||||
t.Fatalf("landsraadHouseName(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetLandsraad_DBNil(t *testing.T) {
|
||||
orig := globalDB
|
||||
globalDB = nil
|
||||
defer func() { globalDB = orig }()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/landsraad", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handleGetLandsraad(rr, req)
|
||||
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveLocation looks up a named location from the editable store (or the
|
||||
// compile-time cheatLocations fallback when the store is unavailable).
|
||||
// Returns an error suitable for a 400 response if the name is unknown.
|
||||
func resolveLocation(name string) (teleportLocation, error) {
|
||||
if globalLocationStore != nil {
|
||||
locs, err := globalLocationStore.list()
|
||||
if err == nil {
|
||||
for _, l := range locs {
|
||||
if l.Name == name {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
return teleportLocation{}, fmt.Errorf("unknown location: %s", name)
|
||||
}
|
||||
}
|
||||
// Fallback to compile-time seeds.
|
||||
for _, l := range cheatLocations {
|
||||
if l.Name == name {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
return teleportLocation{}, fmt.Errorf("unknown location: %s", name)
|
||||
}
|
||||
|
||||
// globalLocationStore holds the open SQLite location store. Set once in
|
||||
// main.go alongside globalSessionDB; nil when the store failed to open.
|
||||
var globalLocationStore *locationStore
|
||||
|
||||
// @Summary List all saved teleport/spawn locations
|
||||
// @Tags locations
|
||||
// @Produce json
|
||||
// @Success 200 {array} teleportLocation
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/locations [get]
|
||||
// GET /api/v1/locations
|
||||
func handleListLocations(w http.ResponseWriter, _ *http.Request) {
|
||||
if globalLocationStore == nil {
|
||||
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
locs, err := globalLocationStore.list()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("list locations: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, locs)
|
||||
}
|
||||
|
||||
// @Summary Add or update a named teleport/spawn location
|
||||
// @Tags locations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "name, x, y, z"
|
||||
// @Success 200 {array} teleportLocation
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/locations [post]
|
||||
// POST /api/v1/locations
|
||||
func handleUpsertLocation(w http.ResponseWriter, r *http.Request) {
|
||||
if globalLocationStore == nil {
|
||||
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
jsonErr(w, fmt.Errorf("name required"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := globalLocationStore.upsert(req.Name, req.X, req.Y, req.Z); err != nil {
|
||||
jsonErr(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
locs, err := globalLocationStore.list()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("list after upsert: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, locs)
|
||||
}
|
||||
|
||||
// @Summary Rename an existing location
|
||||
// @Tags locations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "old_name, new_name"
|
||||
// @Success 200 {array} teleportLocation
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/locations [put]
|
||||
// PUT /api/v1/locations
|
||||
func handleRenameLocation(w http.ResponseWriter, r *http.Request) {
|
||||
if globalLocationStore == nil {
|
||||
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OldName string `json:"old_name"`
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.OldName = strings.TrimSpace(req.OldName)
|
||||
req.NewName = strings.TrimSpace(req.NewName)
|
||||
if req.OldName == "" || req.NewName == "" {
|
||||
jsonErr(w, fmt.Errorf("old_name and new_name required"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := globalLocationStore.rename(req.OldName, req.NewName); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
jsonErr(w, err, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonErr(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
locs, err := globalLocationStore.list()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("list after rename: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, locs)
|
||||
}
|
||||
|
||||
// @Summary Delete a named location
|
||||
// @Tags locations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "name"
|
||||
// @Success 200 {array} teleportLocation
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/locations [delete]
|
||||
// DELETE /api/v1/locations
|
||||
func handleDeleteLocation(w http.ResponseWriter, r *http.Request) {
|
||||
if globalLocationStore == nil {
|
||||
jsonErr(w, fmt.Errorf("location store not available"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
jsonErr(w, fmt.Errorf("name required"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := globalLocationStore.delete(req.Name); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
jsonErr(w, err, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
jsonErr(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
locs, err := globalLocationStore.list()
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("list after delete: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, locs)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupLocationStore sets globalLocationStore to a fresh in-memory store and
|
||||
// restores nil on cleanup. NOT parallel — mutates package global.
|
||||
func setupLocationStore(t *testing.T) *locationStore {
|
||||
t.Helper()
|
||||
s := openMemLocationStore(t)
|
||||
globalLocationStore = s
|
||||
t.Cleanup(func() { globalLocationStore = nil })
|
||||
return s
|
||||
}
|
||||
|
||||
// ── nil-guard tests (globalLocationStore == nil) ─────────────────────────────
|
||||
|
||||
func TestHandleListLocations_NilStore(t *testing.T) {
|
||||
globalLocationStore = nil
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/locations", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleListLocations(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertLocation_NilStore(t *testing.T) {
|
||||
globalLocationStore = nil
|
||||
body, _ := json.Marshal(map[string]any{"name": "X", "x": 1.0, "y": 2.0, "z": 3.0})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleUpsertLocation(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenameLocation_NilStore(t *testing.T) {
|
||||
globalLocationStore = nil
|
||||
body, _ := json.Marshal(map[string]string{"old_name": "A", "new_name": "B"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRenameLocation(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteLocation_NilStore(t *testing.T) {
|
||||
globalLocationStore = nil
|
||||
body, _ := json.Marshal(map[string]string{"name": "X"})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleDeleteLocation(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("want 503, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── list ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleListLocations_ReturnsSeededLocations(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/locations", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleListLocations(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var locs []teleportLocation
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &locs); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(locs) != len(cheatLocations) {
|
||||
t.Fatalf("want %d locations, got %d", len(cheatLocations), len(locs))
|
||||
}
|
||||
}
|
||||
|
||||
// ── upsert ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleUpsertLocation_AddsNew(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]any{"name": "NewPlace", "x": 1.1, "y": 2.2, "z": 3.3})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleUpsertLocation(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Confirm it appears in list.
|
||||
locs, _ := globalLocationStore.list()
|
||||
var found bool
|
||||
for _, l := range locs {
|
||||
if l.Name == "NewPlace" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("upserted location not in store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertLocation_RejectsMissingName(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]any{"x": 1.0, "y": 2.0, "z": 3.0})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleUpsertLocation(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpsertLocation_RejectsBadJSON(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/locations", bytes.NewReader([]byte("{")))
|
||||
rec := httptest.NewRecorder()
|
||||
handleUpsertLocation(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── rename ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleRenameLocation_Success(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]string{"old_name": "Windsack", "new_name": "Windsack2"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRenameLocation(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
locs, _ := globalLocationStore.list()
|
||||
var found bool
|
||||
for _, l := range locs {
|
||||
if l.Name == "Windsack2" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("renamed location not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenameLocation_RejectsMissingFields(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
}{
|
||||
{"missing old_name", map[string]string{"new_name": "B"}},
|
||||
{"missing new_name", map[string]string{"old_name": "Windsack"}},
|
||||
{"both empty", map[string]string{"old_name": "", "new_name": ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b, _ := json.Marshal(tt.body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRenameLocation(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRenameLocation_UnknownNameReturns404(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]string{"old_name": "NoSuch", "new_name": "Else"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRenameLocation(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandleDeleteLocation_Success(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]string{"name": "Windsack"})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleDeleteLocation(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
locs, _ := globalLocationStore.list()
|
||||
for _, l := range locs {
|
||||
if l.Name == "Windsack" {
|
||||
t.Fatal("deleted location still present")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteLocation_RejectsMissingName(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleDeleteLocation(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeleteLocation_UnknownNameReturns404(t *testing.T) {
|
||||
setupLocationStore(t)
|
||||
body, _ := json.Marshal(map[string]string{"name": "NoSuch"})
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/locations", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleDeleteLocation(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
130
docs/reference-repos/icehunter/cmd/dune-admin/handlers_logs.go
Normal file
130
docs/reference-repos/icehunter/cmd/dune-admin/handlers_logs.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return originAllowedForRequest(r)
|
||||
},
|
||||
}
|
||||
|
||||
// logPod is a discovered kubernetes pod available for log streaming.
|
||||
type logPod struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var k8sNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$`)
|
||||
|
||||
func isValidK8sName(name string) bool {
|
||||
return len(name) > 0 && len(name) <= 253 && k8sNameRe.MatchString(name)
|
||||
}
|
||||
|
||||
// @Summary List available log sources
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Success 200 {array} logPod
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/logs/pods [get]
|
||||
func handleLogPods(w http.ResponseWriter, r *http.Request) {
|
||||
if globalControl == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), 503)
|
||||
return
|
||||
}
|
||||
sources, err := globalControl.ListLogSources(r.Context(), globalExecutor)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
// Convert to logPod for frontend compat.
|
||||
var pods []logPod
|
||||
for _, s := range sources {
|
||||
pods = append(pods, logPod(s))
|
||||
}
|
||||
if pods == nil {
|
||||
pods = []logPod{}
|
||||
}
|
||||
jsonOK(w, pods)
|
||||
}
|
||||
|
||||
// @Summary Stream log via WebSocket
|
||||
// @Tags logs
|
||||
// @Produce text/plain
|
||||
// @Param ns query string true "Namespace or log source"
|
||||
// @Param pod query string true "Pod or log file name"
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/logs/stream [get]
|
||||
func handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
ns := r.URL.Query().Get("ns")
|
||||
pod := r.URL.Query().Get("pod")
|
||||
if ns == "" || pod == "" {
|
||||
http.Error(w, "ns and pod required", 400)
|
||||
return
|
||||
}
|
||||
if isValidK8sName(ns) && isValidK8sName(pod) {
|
||||
// K8s names validated — safe for kubectl.
|
||||
} else if strings.ContainsAny(ns+pod, ";|&`$(){}\\") {
|
||||
http.Error(w, "invalid characters in ns or pod", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if globalControl == nil {
|
||||
http.Error(w, "not connected", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetWriteDeadline(time.Time{})
|
||||
|
||||
ch, cancel, err := globalControl.StreamLog(r.Context(), globalExecutor, ns, pod)
|
||||
if err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, []byte("error: "+err.Error())) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
for line := range ch {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
return strings.Split(strings.TrimSpace(s), "\n")
|
||||
}
|
||||
|
||||
// @Summary Fetch the cheat detection log
|
||||
// @Tags logs
|
||||
// @Produce json
|
||||
// @Success 200 {array} cheatEntry
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/logs/cheats [get]
|
||||
func handleGetCheatLog(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdFetchCheatLog()().(msgCheatLog)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
rows := msg.rows
|
||||
if rows == nil {
|
||||
rows = []cheatEntry{}
|
||||
}
|
||||
jsonOK(w, rows)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleGetMapMarkers returns the Live Map markers (players + vehicles, plus
|
||||
// bases in Phase 2b) for the requested map. The ?map= input is validated before
|
||||
// the DB is touched, so bad input fails fast with 400 and a valid map with no DB
|
||||
// connection surfaces 503.
|
||||
//
|
||||
// @Summary Live Map markers for a map
|
||||
// @Tags map
|
||||
// @Produce json
|
||||
// @Param map query string true "Map key (HaggaBasin | DeepDesert)"
|
||||
// @Success 200 {array} mapMarker
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/map/markers [get]
|
||||
func handleGetMapMarkers(w http.ResponseWriter, r *http.Request) {
|
||||
mapKey := r.URL.Query().Get("map")
|
||||
if err := validateMapKey(mapKey); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
markers, err := cmdFetchMapMarkers(r.Context(), globalDB, mapKey)
|
||||
if err != nil {
|
||||
log.Printf("handleGetMapMarkers: %v", err)
|
||||
jsonErr(w, fmt.Errorf("internal error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, markers)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// handleGetMapMarkers validates the ?map= input before touching the DB, so bad
|
||||
// input fails fast with 400 and a valid map with no DB connection surfaces 503.
|
||||
// globalDB is nil in unit tests (connectAll is never called), which lets us
|
||||
// exercise the input + guard paths without a database. Not parallel: it reads
|
||||
// the globalDB package global.
|
||||
func TestHandleGetMapMarkers_Input(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "missing map param", query: "", wantStatus: http.StatusBadRequest},
|
||||
{name: "unsupported map", query: "?map=Atlantis", wantStatus: http.StatusBadRequest},
|
||||
{name: "valid map, db down", query: "?map=HaggaBasin", wantStatus: http.StatusServiceUnavailable},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/map/markers"+tt.query, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handleGetMapMarkers(rec, req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
266
docs/reference-repos/icehunter/cmd/dune-admin/handlers_market.go
Normal file
266
docs/reference-repos/icehunter/cmd/dune-admin/handlers_market.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type marketItemsFilter struct {
|
||||
search string
|
||||
category string
|
||||
tier *int
|
||||
rarity string
|
||||
owner string
|
||||
}
|
||||
|
||||
func buildMarketItemsFilter(r *http.Request) marketItemsFilter {
|
||||
q := r.URL.Query()
|
||||
filter := marketItemsFilter{
|
||||
search: strings.ToLower(q.Get("search")),
|
||||
category: q.Get("category"),
|
||||
rarity: strings.ToLower(q.Get("rarity")),
|
||||
owner: q.Get("owner"),
|
||||
}
|
||||
if tierStr := q.Get("tier"); tierStr != "" {
|
||||
if tier, err := strconv.Atoi(tierStr); err == nil {
|
||||
filter.tier = &tier
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func marketItemMatchesFilter(it marketItem, filter marketItemsFilter) bool {
|
||||
if filter.search != "" {
|
||||
if !strings.Contains(strings.ToLower(it.DisplayName), filter.search) &&
|
||||
!strings.Contains(strings.ToLower(it.TemplateID), filter.search) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filter.category != "" && !strings.HasPrefix(it.Category, filter.category) {
|
||||
return false
|
||||
}
|
||||
if filter.tier != nil && it.Tier != *filter.tier {
|
||||
return false
|
||||
}
|
||||
if filter.rarity != "" && !strings.EqualFold(it.Rarity, filter.rarity) {
|
||||
return false
|
||||
}
|
||||
if filter.owner == "bot" && it.BotStock == 0 {
|
||||
return false
|
||||
}
|
||||
if filter.owner == "player" && (it.TotalStock-it.BotStock) == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func filterMarketItems(items []marketItem, filter marketItemsFilter) []marketItem {
|
||||
filtered := make([]marketItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if marketItemMatchesFilter(it, filter) {
|
||||
filtered = append(filtered, it)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func marketItemsPagination(r *http.Request, total int) (start, end, page, limit int) {
|
||||
q := r.URL.Query()
|
||||
limit = 100
|
||||
page = 0
|
||||
if parsedLimit, err := strconv.Atoi(q.Get("limit")); err == nil && parsedLimit > 0 && parsedLimit <= 500 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
if parsedPage, err := strconv.Atoi(q.Get("page")); err == nil && parsedPage > 0 {
|
||||
page = parsedPage
|
||||
}
|
||||
start = page * limit
|
||||
end = start + limit
|
||||
if start >= total {
|
||||
start = total
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return start, end, page, limit
|
||||
}
|
||||
|
||||
// handleMarketItems returns all active exchange listings aggregated by template ID.
|
||||
// Query params: search, category, tier, rarity, owner (bot|player|all), page, limit.
|
||||
// @Summary List market items aggregated by template ID
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Param search query string false "Filter by display name or template ID"
|
||||
// @Param category query string false "Filter by category prefix"
|
||||
// @Param tier query int false "Filter by item tier"
|
||||
// @Param rarity query string false "Filter by rarity"
|
||||
// @Param owner query string false "Filter by owner type (bot|player|all)"
|
||||
// @Param page query int false "Page number (0-based)"
|
||||
// @Param limit query int false "Page size (default 100, max 500)"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/market/items [get]
|
||||
func handleMarketItems(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdFetchMarketItems().(msgMarketItems)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
items := msg.rows
|
||||
if items == nil {
|
||||
items = []marketItem{}
|
||||
}
|
||||
|
||||
filter := buildMarketItemsFilter(r)
|
||||
filtered := filterMarketItems(items, filter)
|
||||
start, end, page, limit := marketItemsPagination(r, len(filtered))
|
||||
|
||||
jsonOK(w, map[string]any{
|
||||
"items": filtered[start:end],
|
||||
"total": len(filtered),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMarketListings returns all active individual listings, optionally for one template.
|
||||
// Query param: template_id, owner (bot|player|all), sort (price|quality).
|
||||
// @Summary List individual active market listings
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/market/listings [get]
|
||||
func handleMarketListings(w http.ResponseWriter, r *http.Request) {
|
||||
templateID := r.URL.Query().Get("template_id")
|
||||
msg, ok := cmdFetchMarketListings(templateID).(msgMarketListings)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
|
||||
listings := msg.rows
|
||||
if listings == nil {
|
||||
listings = []marketListing{}
|
||||
}
|
||||
|
||||
if owner := r.URL.Query().Get("owner"); owner == "bot" || owner == "player" {
|
||||
filtered := listings[:0]
|
||||
for _, l := range listings {
|
||||
if l.OwnerType == owner {
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
}
|
||||
listings = filtered
|
||||
}
|
||||
|
||||
jsonOK(w, listings)
|
||||
}
|
||||
|
||||
// handleMarketSales returns recent fulfilled sales (players buying from the bot).
|
||||
// @Summary List recent fulfilled market sales
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/market/sales [get]
|
||||
func handleMarketSales(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdFetchMarketSales().(msgMarketSales)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
sales := msg.rows
|
||||
if sales == nil {
|
||||
sales = []marketSale{}
|
||||
}
|
||||
jsonOK(w, sales)
|
||||
}
|
||||
|
||||
// handleMarketStats returns aggregate market statistics (admin-only by convention).
|
||||
// @Summary Return aggregate market statistics
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/market/stats [get]
|
||||
func handleMarketStats(w http.ResponseWriter, r *http.Request) {
|
||||
msg, ok := cmdFetchMarketStats().(msgMarketStats)
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("internal error"), 500)
|
||||
return
|
||||
}
|
||||
if msg.err != nil {
|
||||
jsonErr(w, msg.err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, msg.stats)
|
||||
}
|
||||
|
||||
// handleMarketCategories returns the category tree derived from item-data.json.
|
||||
// Schematic items are reclassified under "schematics/" to surface as their own group.
|
||||
// @Summary List distinct item categories from the item catalog
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /api/v1/market/categories [get]
|
||||
func handleMarketCategories(w http.ResponseWriter, r *http.Request) {
|
||||
seen := map[string]bool{}
|
||||
var categories []string
|
||||
for templateID, rule := range itemData.Items {
|
||||
if rule.Category == "" {
|
||||
continue
|
||||
}
|
||||
cat := schematicCategory(templateID, rule.Category, rule.IsSchematic)
|
||||
if !seen[cat] {
|
||||
seen[cat] = true
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
}
|
||||
jsonOK(w, categories)
|
||||
}
|
||||
|
||||
// handleMarketCatalog returns a flat list of all known items (template_id + display_name)
|
||||
// for use in autocomplete UIs such as the disabled-items manager.
|
||||
// @Summary List all known item templates with display names
|
||||
// @Tags market
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /api/v1/market/catalog [get]
|
||||
func handleMarketCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
type entry struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var items []entry
|
||||
for tmpl, rule := range itemData.Items {
|
||||
name := rule.Name
|
||||
if name == "" {
|
||||
name = tmpl
|
||||
}
|
||||
seen[strings.ToLower(tmpl)] = true
|
||||
items = append(items, entry{TemplateID: tmpl, DisplayName: name})
|
||||
}
|
||||
for tmpl, name := range itemData.Names {
|
||||
if !seen[strings.ToLower(tmpl)] {
|
||||
items = append(items, entry{TemplateID: tmpl, DisplayName: name})
|
||||
}
|
||||
}
|
||||
jsonOK(w, items)
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ── remote bot proxy ─────────────────────────────────────────────────────────
|
||||
|
||||
// remoteBotClient proxies /api/v1/market-bot/* calls to a standalone market
|
||||
// bot's HTTP API (internal/marketbot.APIServer). Used when market_bot_enabled
|
||||
// is false but market_bot_remote_url is set.
|
||||
type remoteBotClient struct {
|
||||
baseURL string // trailing slash stripped
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newRemoteBotClient(rawURL, token string) *remoteBotClient {
|
||||
return &remoteBotClient{
|
||||
baseURL: strings.TrimRight(rawURL, "/"),
|
||||
token: token,
|
||||
client: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// do executes a request against the remote bot, copies the response status and
|
||||
// body back to w, and returns true on success.
|
||||
func (r *remoteBotClient) do(w http.ResponseWriter, method, path string, body io.Reader) bool {
|
||||
u := r.baseURL + path
|
||||
req, err := http.NewRequest(method, u, body)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("remote bot: build request: %w", err), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if r.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("remote bot unreachable: %w", err), http.StatusBadGateway)
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
return resp.StatusCode < 300
|
||||
}
|
||||
|
||||
// wsURL converts the base HTTP URL to a WebSocket URL.
|
||||
func (r *remoteBotClient) wsURL(path string) string {
|
||||
return strings.NewReplacer("https://", "wss://", "http://", "ws://").
|
||||
Replace(r.baseURL) + path
|
||||
}
|
||||
|
||||
// ── status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// @Summary Get market bot running status and mode
|
||||
// @Tags market-bot
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/market-bot/status [get]
|
||||
func handleMarketBotStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if embeddedBot != nil {
|
||||
snap := embeddedBot.StatusSnapshot()
|
||||
m, _ := json.Marshal(snap)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(m, &out)
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
running := embeddedBot.Enabled()
|
||||
out["running"] = running
|
||||
out["enabled"] = running
|
||||
out["mode"] = "embedded"
|
||||
out["configured"] = true
|
||||
jsonOK(w, out)
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
// Fetch status from remote and augment with mode field.
|
||||
u := remoteBotProxy.baseURL + "/status"
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("remote bot: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if remoteBotProxy.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+remoteBotProxy.token)
|
||||
}
|
||||
resp, err := remoteBotProxy.client.Do(req)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("remote bot unreachable: %w", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
jsonErr(w, fmt.Errorf("remote bot: decode: %w", err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out["mode"] = "remote"
|
||||
out["running"] = true
|
||||
out["enabled"] = true
|
||||
out["configured"] = true
|
||||
jsonOK(w, out)
|
||||
return
|
||||
}
|
||||
errMsg := "market bot not configured; set market_bot_enabled: true or market_bot_remote_url"
|
||||
if embeddedBotConfigured {
|
||||
errMsg = "market bot configured but not running; check server logs"
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"running": false,
|
||||
"enabled": false,
|
||||
"mode": "none",
|
||||
"configured": embeddedBotConfigured,
|
||||
"error": errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// @Summary Get market bot configuration
|
||||
// @Tags market-bot
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/market-bot/config [get]
|
||||
|
||||
// @Summary Update market bot configuration
|
||||
// @Tags market-bot
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Configuration patch"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/market-bot/config [put]
|
||||
func handleMarketBotConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if embeddedBot != nil {
|
||||
handleEmbeddedBotConfig(w, r)
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
remoteBotProxy.do(w, http.MethodGet, "/config", nil)
|
||||
case http.MethodPut:
|
||||
remoteBotProxy.do(w, http.MethodPut, "/config", r.Body)
|
||||
default:
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
jsonErr(w, fmt.Errorf("market bot not configured"), http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func handleEmbeddedBotConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := embeddedBot.ConfigJSON()
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data) //nolint:errcheck
|
||||
case http.MethodPut:
|
||||
var patch map[string]json.RawMessage
|
||||
if err := decode(r, &patch); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if err := embeddedBot.ApplyConfig(patch); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
data, err := embeddedBot.ConfigJSON()
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data) //nolint:errcheck
|
||||
default:
|
||||
jsonErr(w, fmt.Errorf("method not allowed"), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── lifecycle exec ────────────────────────────────────────────────────────────
|
||||
|
||||
var botCmdAllowlist = map[string]bool{
|
||||
"start": true, "stop": true, "restart": true,
|
||||
}
|
||||
|
||||
// @Summary Execute a lifecycle command on the market bot (start/stop/restart)
|
||||
// @Tags market-bot
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Command: start, stop, or restart"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/market-bot/exec [post]
|
||||
func handleMarketBotExec(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if !botCmdAllowlist[req.Cmd] {
|
||||
jsonErr(w, fmt.Errorf("unknown command %q", req.Cmd), 400)
|
||||
return
|
||||
}
|
||||
|
||||
if embeddedBot != nil {
|
||||
output := "ok"
|
||||
switch req.Cmd {
|
||||
case "start":
|
||||
embeddedBot.Resume()
|
||||
output = "resumed"
|
||||
case "stop":
|
||||
embeddedBot.Pause()
|
||||
output = "paused"
|
||||
case "restart":
|
||||
if err := embeddedBot.Restart(r.Context()); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
output = "restarted"
|
||||
}
|
||||
jsonOK(w, map[string]string{"output": output})
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
body, _ := json.Marshal(map[string]string{"cmd": req.Cmd})
|
||||
remoteBotProxy.do(w, http.MethodPost, "/exec", strings.NewReader(string(body)))
|
||||
return
|
||||
}
|
||||
jsonErr(w, fmt.Errorf("market bot not configured"), http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// ── cleanup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// @Summary Trigger market bot listing cleanup
|
||||
// @Tags market-bot
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]int64
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/market-bot/cleanup [post]
|
||||
func handleMarketBotCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
if embeddedBot != nil {
|
||||
orders, items, err := embeddedBot.CleanupListings(r.Context())
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]int64{
|
||||
"orders_deleted": orders,
|
||||
"items_deleted": items,
|
||||
})
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
remoteBotProxy.do(w, http.MethodPost, "/cleanup", nil)
|
||||
return
|
||||
}
|
||||
jsonErr(w, fmt.Errorf("market bot not configured"), http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
// ── log streaming ─────────────────────────────────────────────────────────────
|
||||
|
||||
// @Summary Check whether market bot log streaming is available
|
||||
// @Tags market-bot
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/market-bot/logs-ready [get]
|
||||
func handleMarketBotLogsReady(w http.ResponseWriter, _ *http.Request) {
|
||||
if embeddedBot != nil {
|
||||
jsonOK(w, map[string]any{"ready": true, "mode": "embedded"})
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
jsonOK(w, map[string]any{"ready": true, "mode": "remote"})
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{"ready": false, "mode": "none", "reason": "market bot not configured"})
|
||||
}
|
||||
|
||||
// @Summary Stream market bot log output via WebSocket
|
||||
// @Tags market-bot
|
||||
// @Produce text/plain
|
||||
// @Success 101 {string} string "Switching Protocols"
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/market-bot/logs [get]
|
||||
func handleMarketBotLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if embeddedBot != nil {
|
||||
streamEmbeddedBotLogs(w, r)
|
||||
return
|
||||
}
|
||||
if remoteBotProxy != nil {
|
||||
proxyBotLogsWS(w, r, remoteBotProxy)
|
||||
return
|
||||
}
|
||||
http.Error(w, "market bot not configured", http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
func streamEmbeddedBotLogs(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetWriteDeadline(time.Time{})
|
||||
|
||||
ch := embeddedBot.Sink.Subscribe()
|
||||
defer embeddedBot.Sink.Unsubscribe(ch)
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
||||
return
|
||||
}
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyBotLogsWS bridges the client WebSocket connection to the remote bot's
|
||||
// /logs WebSocket endpoint, relaying text frames in both directions.
|
||||
func proxyBotLogsWS(w http.ResponseWriter, r *http.Request, proxy *remoteBotClient) {
|
||||
clientConn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = clientConn.Close() }()
|
||||
|
||||
hdr := http.Header{}
|
||||
if proxy.token != "" {
|
||||
hdr.Set("Authorization", "Bearer "+proxy.token)
|
||||
}
|
||||
remoteConn, _, err := websocket.DefaultDialer.DialContext(r.Context(), proxy.wsURL("/logs"), hdr)
|
||||
if err != nil {
|
||||
log.Printf("remote bot ws dial: %v", err)
|
||||
_ = clientConn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "remote unavailable"))
|
||||
return
|
||||
}
|
||||
defer func() { _ = remoteConn.Close() }()
|
||||
|
||||
bridgeWSConns(r.Context(), clientConn, remoteConn)
|
||||
}
|
||||
|
||||
// bridgeWSConns relays frames between two WebSocket connections until either
|
||||
// closes or the context is cancelled.
|
||||
func bridgeWSConns(ctx context.Context, a, b *websocket.Conn) {
|
||||
done := make(chan struct{}, 2)
|
||||
relay := func(src, dst *websocket.Conn) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
for {
|
||||
mt, msg, err := src.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := dst.WriteMessage(mt, msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
go relay(b, a) // remote → client
|
||||
go relay(a, b) // client → remote
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newRemoteFakeServer creates a fake remote bot HTTP server and returns a
|
||||
// remoteBotClient pointed at it, plus a cleanup function.
|
||||
func newRemoteFakeServer(t *testing.T, mux *http.ServeMux) (*remoteBotClient, func()) {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(mux)
|
||||
client := newRemoteBotClient(ts.URL, "fake-token")
|
||||
// Override the HTTP client to use the test server's client so redirects work.
|
||||
client.client = ts.Client()
|
||||
return client, ts.Close
|
||||
}
|
||||
|
||||
func TestHandleMarketBotStatus_NeitherConfigured(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d", w.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["running"] != false {
|
||||
t.Errorf("running should be false, got %v", body["running"])
|
||||
}
|
||||
if body["mode"] != "none" {
|
||||
t.Errorf("mode should be 'none', got %v", body["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotStatus_RemoteProxy(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer fake-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"uptime":"1m","listing_count":42}`)
|
||||
})
|
||||
|
||||
proxy, cleanup := newRemoteFakeServer(t, mux)
|
||||
defer cleanup()
|
||||
remoteBotProxy = proxy
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["mode"] != "remote" {
|
||||
t.Errorf("mode should be 'remote', got %v", body["mode"])
|
||||
}
|
||||
if body["running"] != true {
|
||||
t.Errorf("running should be true for reachable remote, got %v", body["running"])
|
||||
}
|
||||
if body["listing_count"].(float64) != 42 {
|
||||
t.Errorf("listing_count should be 42, got %v", body["listing_count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotConfig_RemoteGet(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /config", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"max_buys":99,"enabled":true}`)
|
||||
})
|
||||
|
||||
proxy, cleanup := newRemoteFakeServer(t, mux)
|
||||
defer cleanup()
|
||||
remoteBotProxy = proxy
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotConfig(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"max_buys":99`) {
|
||||
t.Errorf("unexpected body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotConfig_RemotePut(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
var receivedBody string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("PUT /config", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"ok"}`)
|
||||
})
|
||||
|
||||
proxy, cleanup := newRemoteFakeServer(t, mux)
|
||||
defer cleanup()
|
||||
remoteBotProxy = proxy
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/v1/market-bot/config",
|
||||
strings.NewReader(`{"max_buys":7}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotConfig(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(receivedBody, `"max_buys":7`) {
|
||||
t.Errorf("remote did not receive correct body: %s", receivedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotConfig_NeitherConfigured(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotConfig(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("want 503 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotExec_Remote(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
var receivedCmd string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /exec", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
receivedCmd = body["cmd"]
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"output":"resumed"}`)
|
||||
})
|
||||
|
||||
proxy, cleanup := newRemoteFakeServer(t, mux)
|
||||
defer cleanup()
|
||||
remoteBotProxy = proxy
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/market-bot/exec",
|
||||
strings.NewReader(`{"cmd":"start"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotExec(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if receivedCmd != "start" {
|
||||
t.Errorf("remote received cmd=%q want 'start'", receivedCmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotExec_UnknownCmd(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/market-bot/exec",
|
||||
strings.NewReader(`{"cmd":"nuke"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotExec(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown cmd: want 400 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotCleanup_Remote(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /cleanup", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"orders_deleted":5,"items_deleted":10}`)
|
||||
})
|
||||
|
||||
proxy, cleanup := newRemoteFakeServer(t, mux)
|
||||
defer cleanup()
|
||||
remoteBotProxy = proxy
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/market-bot/cleanup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotCleanup(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"orders_deleted":5`) {
|
||||
t.Errorf("unexpected body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotLogsReady_Remote(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
remoteBotProxy = newRemoteBotClient("http://irrelevant", "tok")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/logs-ready", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotLogsReady(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d", w.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["ready"] != true {
|
||||
t.Errorf("ready should be true, got %v", body["ready"])
|
||||
}
|
||||
if body["mode"] != "remote" {
|
||||
t.Errorf("mode should be 'remote', got %v", body["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotLogsReady_NeitherConfigured(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/logs-ready", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotLogsReady(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d", w.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["ready"] != false {
|
||||
t.Errorf("ready should be false when nothing configured, got %v", body["ready"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotStatus_RemoteUnreachable(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() { embeddedBot = origBot; remoteBotProxy = origProxy; embeddedBotConfigured = origCfg }()
|
||||
|
||||
remoteBotProxy = newRemoteBotClient("http://127.0.0.1:19999", "tok")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Errorf("unreachable remote: want 502 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotStatus_ConfiguredButDisabled(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = true // configured in YAML but not running
|
||||
defer func() {
|
||||
embeddedBot = origBot
|
||||
remoteBotProxy = origProxy
|
||||
embeddedBotConfigured = origCfg
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["mode"] != "none" {
|
||||
t.Errorf("mode: got %v want 'none'", body["mode"])
|
||||
}
|
||||
if body["configured"] != true {
|
||||
t.Errorf("configured: got %v want true", body["configured"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMarketBotStatus_NeitherConfiguredNorEnabled(t *testing.T) {
|
||||
origBot := embeddedBot
|
||||
origProxy := remoteBotProxy
|
||||
origCfg := embeddedBotConfigured
|
||||
embeddedBot = nil
|
||||
remoteBotProxy = nil
|
||||
embeddedBotConfigured = false
|
||||
defer func() {
|
||||
embeddedBot = origBot
|
||||
remoteBotProxy = origProxy
|
||||
embeddedBotConfigured = origCfg
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/market-bot/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleMarketBotStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["configured"] != false {
|
||||
t.Errorf("configured: got %v want false", body["configured"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildMarketItemsFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest("GET", "/api/v1/market/items?search=Spice&category=resources&tier=3&rarity=rare&owner=bot", nil)
|
||||
filter := buildMarketItemsFilter(r)
|
||||
|
||||
if filter.search != "spice" || filter.category != "resources" || filter.rarity != "rare" || filter.owner != "bot" {
|
||||
t.Fatalf("unexpected filter fields: %+v", filter)
|
||||
}
|
||||
if filter.tier == nil || *filter.tier != 3 {
|
||||
t.Fatalf("expected tier=3, got %+v", filter.tier)
|
||||
}
|
||||
|
||||
rInvalid := httptest.NewRequest("GET", "/api/v1/market/items?tier=not-a-number", nil)
|
||||
filter = buildMarketItemsFilter(rInvalid)
|
||||
if filter.tier != nil {
|
||||
t.Fatalf("expected invalid tier to be ignored, got %+v", filter.tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketItemMatchesFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
item := marketItem{
|
||||
TemplateID: "Dune.Spice.Raw",
|
||||
DisplayName: "Raw Spice",
|
||||
Category: "resources/spice",
|
||||
Tier: 3,
|
||||
Rarity: "Rare",
|
||||
TotalStock: 12,
|
||||
BotStock: 2,
|
||||
}
|
||||
|
||||
if !marketItemMatchesFilter(item, marketItemsFilter{search: "spice"}) {
|
||||
t.Fatal("expected search filter to match")
|
||||
}
|
||||
if marketItemMatchesFilter(item, marketItemsFilter{search: "water"}) {
|
||||
t.Fatal("expected non-matching search to fail")
|
||||
}
|
||||
if marketItemMatchesFilter(item, marketItemsFilter{category: "weapons"}) {
|
||||
t.Fatal("expected non-matching category to fail")
|
||||
}
|
||||
if marketItemMatchesFilter(item, marketItemsFilter{owner: "player", tier: intRef(4)}) {
|
||||
t.Fatal("expected tier mismatch to fail")
|
||||
}
|
||||
if !marketItemMatchesFilter(item, marketItemsFilter{owner: "player"}) {
|
||||
t.Fatal("expected player-owner filter to match when player stock exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMarketItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items := []marketItem{
|
||||
{TemplateID: "A", DisplayName: "Alpha", Category: "cat/a", TotalStock: 5, BotStock: 0},
|
||||
{TemplateID: "B", DisplayName: "Bravo", Category: "cat/b", TotalStock: 5, BotStock: 5},
|
||||
}
|
||||
filtered := filterMarketItems(items, marketItemsFilter{owner: "player"})
|
||||
if len(filtered) != 1 || filtered[0].TemplateID != "A" {
|
||||
t.Fatalf("unexpected filtered items: %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketItemsPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest("GET", "/api/v1/market/items?page=2&limit=3", nil)
|
||||
start, end, page, limit := marketItemsPagination(r, 8)
|
||||
if start != 6 || end != 8 || page != 2 || limit != 3 {
|
||||
t.Fatalf("unexpected pagination: start=%d end=%d page=%d limit=%d", start, end, page, limit)
|
||||
}
|
||||
|
||||
rDefault := httptest.NewRequest("GET", "/api/v1/market/items?page=-1&limit=9999", nil)
|
||||
start, end, page, limit = marketItemsPagination(rDefault, 4)
|
||||
if start != 0 || end != 4 || page != 0 || limit != 100 {
|
||||
t.Fatalf("unexpected default pagination: start=%d end=%d page=%d limit=%d", start, end, page, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func intRef(v int) *int {
|
||||
return &v
|
||||
}
|
||||
148
docs/reference-repos/icehunter/cmd/dune-admin/handlers_notify.go
Normal file
148
docs/reference-repos/icehunter/cmd/dune-admin/handlers_notify.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// ── mq-game publisher ─────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
mqGameConn *amqp.Connection
|
||||
mqGameCh *amqp.Channel
|
||||
mqGameMu sync.Mutex
|
||||
)
|
||||
|
||||
func mqGameChannel() (*amqp.Channel, error) {
|
||||
mqGameMu.Lock()
|
||||
defer mqGameMu.Unlock()
|
||||
|
||||
if mqGameCh != nil && !mqGameCh.IsClosed() {
|
||||
return mqGameCh, nil
|
||||
}
|
||||
if mqGameConn != nil && !mqGameConn.IsClosed() {
|
||||
_ = mqGameConn.Close()
|
||||
}
|
||||
|
||||
addr := brokerGameAddr
|
||||
if addr == "" {
|
||||
// Legacy fallback for existing K8s configs that predate broker_game_addr.
|
||||
addr = "10.43.48.246:5672"
|
||||
}
|
||||
|
||||
user, pass, err := brokerCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := dialAMQP(addr, user, pass, brokerTLS || addr == "10.43.48.246:5672")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mq-game connect: %w", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("mq-game channel: %w", err)
|
||||
}
|
||||
mqGameConn = conn
|
||||
mqGameCh = ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// publishNotification sends a CourierNotification to the mq-game notifications
|
||||
// exchange. routingKey controls which server queues receive it ("PlayerOnlineState",
|
||||
// "#" for broadcast, etc.). keywords controls what the game client does with it.
|
||||
func publishNotification(routingKey string, keywords []string, content string) error {
|
||||
ch, err := mqGameChannel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inner payload (content field of the courier).
|
||||
inner, _ := json.Marshal(map[string]any{
|
||||
"RoutingInfo": map[string]any{"Keywords": keywords},
|
||||
"content": content,
|
||||
"SenderId": 1,
|
||||
})
|
||||
|
||||
// Outer CourierNotification envelope.
|
||||
outer, _ := json.Marshal(map[string]any{
|
||||
"Type": "CourierNotification",
|
||||
"content": string(inner),
|
||||
})
|
||||
|
||||
err = ch.Publish(
|
||||
"notifications", // exchange
|
||||
routingKey, // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "Content",
|
||||
Body: outer,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
// Channel may have died — clear it so next call reconnects.
|
||||
mqGameMu.Lock()
|
||||
mqGameCh = nil
|
||||
mqGameMu.Unlock()
|
||||
return fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── HTTP handler ──────────────────────────────────────────────────────────────
|
||||
|
||||
// handleNotify publishes an in-game notification via mq-game.
|
||||
//
|
||||
// POST /api/v1/notify
|
||||
//
|
||||
// {
|
||||
// "routing_key": "PlayerOnlineState", // optional, default "#"
|
||||
// "keywords": ["PlayerOnlineState"], // optional, default ["AdminMessage"]
|
||||
// "content": "Hello World!"
|
||||
// }
|
||||
|
||||
// @Summary Publish an in-game notification via mq-game RabbitMQ exchange
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Routing key, keywords, and notification content"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/notify [post]
|
||||
func handleNotify(w http.ResponseWriter, r *http.Request) {
|
||||
if globalExecutor == nil {
|
||||
jsonErr(w, fmt.Errorf("not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
jsonErr(w, fmt.Errorf("content required"), 400)
|
||||
return
|
||||
}
|
||||
if req.RoutingKey == "" {
|
||||
req.RoutingKey = "PlayerOnlineState"
|
||||
}
|
||||
if len(req.Keywords) == 0 {
|
||||
req.Keywords = []string{"PlayerOnlineState"}
|
||||
}
|
||||
|
||||
if err := publishNotification(req.RoutingKey, req.Keywords, req.Content); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "notification sent"})
|
||||
}
|
||||
2132
docs/reference-repos/icehunter/cmd/dune-admin/handlers_players.go
Normal file
2132
docs/reference-repos/icehunter/cmd/dune-admin/handlers_players.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveGiveItemsOnlinePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
offline := func(context.Context, int64) error { return nil }
|
||||
online := func(context.Context, int64) error { return errors.New("online") }
|
||||
resolve := func(context.Context, int64) (string, error) { return "fls-123", nil }
|
||||
failResolve := func(context.Context, int64) (string, error) { return "", errors.New("boom") }
|
||||
|
||||
if on, fls := resolveGiveItemsOnlinePath(context.Background(), 0, online, resolve); on || fls != "" {
|
||||
t.Fatalf("expected playerID=0 to force DB path, got on=%v fls=%q", on, fls)
|
||||
}
|
||||
if on, fls := resolveGiveItemsOnlinePath(context.Background(), 42, offline, resolve); on || fls != "" {
|
||||
t.Fatalf("expected offline player to use DB path, got on=%v fls=%q", on, fls)
|
||||
}
|
||||
if on, fls := resolveGiveItemsOnlinePath(context.Background(), 42, online, resolve); !on || fls != "fls-123" {
|
||||
t.Fatalf("expected online RMQ path with fls id, got on=%v fls=%q", on, fls)
|
||||
}
|
||||
if on, fls := resolveGiveItemsOnlinePath(context.Background(), 42, online, failResolve); on || fls != "" {
|
||||
t.Fatalf("expected FLS resolve failure to fall back to DB, got on=%v fls=%q", on, fls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGiveItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := giveItemsRequest{
|
||||
PlayerID: 11,
|
||||
Items: []giveItemInput{
|
||||
{Template: "A", Qty: 2, Quality: 0},
|
||||
{Template: "B", Qty: 1, Quality: 5},
|
||||
{Template: "C", Qty: 1, Quality: 0},
|
||||
},
|
||||
}
|
||||
|
||||
var rmqSent []string
|
||||
given, skipped := processGiveItems(context.Background(), req, true, "fls-123", giveItemsDeps{
|
||||
checkCapacity: func(_ context.Context, _ int64, template string, _ int64) error {
|
||||
if template == "C" {
|
||||
return errors.New("inventory full")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
rmqAdd: func(_ string, template string, _ int, _ float64) error {
|
||||
rmqSent = append(rmqSent, template)
|
||||
return nil
|
||||
},
|
||||
dbGive: func(_ int64, template string, _, _ int64) (msgMutate, bool) {
|
||||
if template != "B" {
|
||||
t.Fatalf("unexpected DB call for template %q", template)
|
||||
}
|
||||
return msgMutate{ok: "done"}, true
|
||||
},
|
||||
needsDBPath: func(string) bool { return false },
|
||||
})
|
||||
|
||||
if len(given) != 2 || given[0] != "A" || given[1] != "B" {
|
||||
t.Fatalf("unexpected given: %v", given)
|
||||
}
|
||||
if len(skipped) != 1 || skipped[0].Template != "C" || skipped[0].Reason != "inventory full" {
|
||||
t.Fatalf("unexpected skipped: %+v", skipped)
|
||||
}
|
||||
if len(rmqSent) != 1 || rmqSent[0] != "A" {
|
||||
t.Fatalf("unexpected RMQ calls: %v", rmqSent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGiveItems_DBFailureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := giveItemsRequest{
|
||||
PlayerID: 11,
|
||||
Items: []giveItemInput{
|
||||
{Template: "X", Qty: 1, Quality: 9},
|
||||
{Template: "Y", Qty: 1, Quality: 9},
|
||||
},
|
||||
}
|
||||
|
||||
given, skipped := processGiveItems(context.Background(), req, false, "", giveItemsDeps{
|
||||
checkCapacity: func(context.Context, int64, string, int64) error { return nil },
|
||||
rmqAdd: func(string, string, int, float64) error { return nil },
|
||||
dbGive: func(_ int64, template string, _, _ int64) (msgMutate, bool) {
|
||||
if template == "X" {
|
||||
return msgMutate{}, false
|
||||
}
|
||||
return msgMutate{err: errors.New("db failed")}, true
|
||||
},
|
||||
needsDBPath: func(string) bool { return false },
|
||||
})
|
||||
|
||||
if len(given) != 0 {
|
||||
t.Fatalf("expected no successful grants, got %v", given)
|
||||
}
|
||||
if len(skipped) != 2 {
|
||||
t.Fatalf("expected two skipped items, got %+v", skipped)
|
||||
}
|
||||
if skipped[0].Template != "X" || skipped[0].Reason != "internal error" {
|
||||
t.Fatalf("unexpected first skipped entry: %+v", skipped[0])
|
||||
}
|
||||
if skipped[1].Template != "Y" || skipped[1].Reason != "db failed" {
|
||||
t.Fatalf("unexpected second skipped entry: %+v", skipped[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGiveItems_SchematicUsesDBPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := giveItemsRequest{
|
||||
PlayerID: 11,
|
||||
Items: []giveItemInput{{Template: "SchematicPattern_Sword", Qty: 1, Quality: 0}},
|
||||
}
|
||||
|
||||
var rmqCalled bool
|
||||
var dbCalled bool
|
||||
processGiveItems(context.Background(), req, true, "fls-abc", giveItemsDeps{
|
||||
checkCapacity: func(context.Context, int64, string, int64) error { return nil },
|
||||
rmqAdd: func(string, string, int, float64) error {
|
||||
rmqCalled = true
|
||||
return nil
|
||||
},
|
||||
dbGive: func(_ int64, _ string, _, _ int64) (msgMutate, bool) {
|
||||
dbCalled = true
|
||||
return msgMutate{ok: "done"}, true
|
||||
},
|
||||
needsDBPath: func(template string) bool { return template == "SchematicPattern_Sword" },
|
||||
})
|
||||
|
||||
if rmqCalled {
|
||||
t.Fatal("schematic with quality=0 should not use RMQ path")
|
||||
}
|
||||
if !dbCalled {
|
||||
t.Fatal("schematic with quality=0 should use DB path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessGiveItems_AugmentUsesDBPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := giveItemsRequest{
|
||||
PlayerID: 11,
|
||||
Items: []giveItemInput{{Template: "Augment_ArmorPiercing", Qty: 1, Quality: 0}},
|
||||
}
|
||||
|
||||
var rmqCalled bool
|
||||
var dbCalled bool
|
||||
processGiveItems(context.Background(), req, true, "fls-abc", giveItemsDeps{
|
||||
checkCapacity: func(context.Context, int64, string, int64) error { return nil },
|
||||
rmqAdd: func(string, string, int, float64) error {
|
||||
rmqCalled = true
|
||||
return nil
|
||||
},
|
||||
dbGive: func(_ int64, _ string, _, _ int64) (msgMutate, bool) {
|
||||
dbCalled = true
|
||||
return msgMutate{ok: "done"}, true
|
||||
},
|
||||
needsDBPath: func(template string) bool { return template == "Augment_ArmorPiercing" },
|
||||
})
|
||||
|
||||
if rmqCalled {
|
||||
t.Fatal("augment item with quality=0 should not use RMQ path")
|
||||
}
|
||||
if !dbCalled {
|
||||
t.Fatal("augment item with quality=0 should use DB path")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleGetPlayerSummary guards on globalDB before doing any work, so with no
|
||||
// database connection (globalDB is nil in unit tests — connectAll is never
|
||||
// called) it must surface 503. Not parallel: it reads the globalDB package
|
||||
// global. Mirrors TestHandleGetMapMarkers_Input.
|
||||
func TestHandleGetPlayerSummary_Guard(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/players/summary", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handleGetPlayerSummary(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, http.StatusServiceUnavailable, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// fillActivityTrend is the pure, testable core of the dashboard's activity
|
||||
// trend: given a sparse day->count map it returns a contiguous, ascending daily
|
||||
// series of length `days` ending on `today` (UTC), zero-filling inactive days
|
||||
// so the chart shows gaps as 0 rather than collapsing them. `today` is injected
|
||||
// so the test is deterministic (no time.Now).
|
||||
func TestFillActivityTrend(t *testing.T) {
|
||||
t.Parallel()
|
||||
today := time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("zero-fills missing days, ascending, ends today", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
counts := map[string]int64{"2026-06-04": 3, "2026-06-06": 5}
|
||||
got := fillActivityTrend(3, today, counts)
|
||||
want := []activityPoint{
|
||||
{Day: "2026-06-04", Count: 3},
|
||||
{Day: "2026-06-05", Count: 0},
|
||||
{Day: "2026-06-06", Count: 5},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("point[%d] = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores counts outside the window", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
counts := map[string]int64{"2026-05-01": 99, "2026-06-06": 1}
|
||||
got := fillActivityTrend(2, today, counts)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2 (%+v)", len(got), got)
|
||||
}
|
||||
if got[0] != (activityPoint{Day: "2026-06-05", Count: 0}) || got[1] != (activityPoint{Day: "2026-06-06", Count: 1}) {
|
||||
t.Fatalf("window = %+v, want [05=0, 06=1]", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("days < 1 is coerced to a single day (today)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := fillActivityTrend(0, today, nil)
|
||||
if len(got) != 1 || got[0].Day != "2026-06-06" || got[0].Count != 0 {
|
||||
t.Fatalf("got %+v, want a single zero point for today", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// averageLevel is the dashboard's "avg character level" (#130) — the mean of
|
||||
// per-character levels via xpToLevel (NOT the level of the mean XP, since the
|
||||
// XP→level curve is non-linear). 344440 XP = level 200 (the cap).
|
||||
func TestAverageLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
xps []int64
|
||||
want float64
|
||||
}{
|
||||
{name: "empty is zero", xps: nil, want: 0},
|
||||
{name: "single max-level char", xps: []int64{344440}, want: 200},
|
||||
{name: "averages levels not raw xp", xps: []int64{0, 344440}, want: 100},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := averageLevel(tt.xps); got != tt.want {
|
||||
t.Fatalf("averageLevel(%v) = %v, want %v", tt.xps, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// avgLevelsByFaction (#130 ext v2) — mean character level per faction via
|
||||
// xpToLevel; averages levels within each faction bucket, empty input → {}.
|
||||
func TestAvgLevelsByFaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := avgLevelsByFaction([]factionXP{
|
||||
{Faction: "Atreides", XP: 344440}, // level 200
|
||||
{Faction: "Atreides", XP: 0}, // level 0
|
||||
{Faction: "Unaligned", XP: 344440}, // level 200
|
||||
})
|
||||
if got["Atreides"] != 100 {
|
||||
t.Errorf("Atreides avg = %v, want 100", got["Atreides"])
|
||||
}
|
||||
if got["Unaligned"] != 200 {
|
||||
t.Errorf("Unaligned avg = %v, want 200", got["Unaligned"])
|
||||
}
|
||||
if len(avgLevelsByFaction(nil)) != 0 {
|
||||
t.Errorf("nil input: want empty map, got %v", avgLevelsByFaction(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// bucketFactionTrends (#130 ext v2c) aggregates per-account daily snapshots into
|
||||
// a per-day, per-faction series: Solaris summed, level averaged. Pure + testable.
|
||||
func TestBucketFactionTrends(t *testing.T) {
|
||||
t.Parallel()
|
||||
snaps := []daySnap{
|
||||
{AccountID: 1, Day: "2026-06-01", Solaris: 100, CharXP: 344440}, // Atreides, lvl 200
|
||||
{AccountID: 2, Day: "2026-06-01", Solaris: 50, CharXP: 0}, // Atreides, lvl 0
|
||||
{AccountID: 3, Day: "2026-06-01", Solaris: 30, CharXP: 344440}, // Unaligned, lvl 200
|
||||
{AccountID: 1, Day: "2026-06-02", Solaris: 200, CharXP: 344440}, // Atreides
|
||||
}
|
||||
acct := map[int64]string{1: "Atreides", 2: "Atreides", 3: "Unaligned"}
|
||||
|
||||
t.Run("solaris sums per day+faction, factions sorted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := bucketFactionTrends(snaps, acct, "solaris")
|
||||
if tr.Metric != "solaris" {
|
||||
t.Fatalf("metric = %q", tr.Metric)
|
||||
}
|
||||
if len(tr.Factions) != 2 || tr.Factions[0] != "Atreides" || tr.Factions[1] != "Unaligned" {
|
||||
t.Fatalf("factions = %v, want [Atreides Unaligned]", tr.Factions)
|
||||
}
|
||||
if len(tr.Points) != 2 {
|
||||
t.Fatalf("points = %d, want 2", len(tr.Points))
|
||||
}
|
||||
if tr.Points[0].Day != "2026-06-01" || tr.Points[0].Values["Atreides"] != 150 || tr.Points[0].Values["Unaligned"] != 30 {
|
||||
t.Fatalf("day1 = %+v, want Atreides 150 / Unaligned 30", tr.Points[0])
|
||||
}
|
||||
if tr.Points[1].Values["Atreides"] != 200 {
|
||||
t.Fatalf("day2 Atreides = %v, want 200", tr.Points[1].Values["Atreides"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("level averages per day+faction", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := bucketFactionTrends(snaps, acct, "level")
|
||||
if tr.Points[0].Values["Atreides"] != 100 { // avg(200, 0)
|
||||
t.Fatalf("day1 Atreides level = %v, want 100", tr.Points[0].Values["Atreides"])
|
||||
}
|
||||
if tr.Points[0].Values["Unaligned"] != 200 {
|
||||
t.Fatalf("day1 Unaligned level = %v, want 200", tr.Points[0].Values["Unaligned"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty input yields empty series", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := bucketFactionTrends(nil, acct, "solaris")
|
||||
if len(tr.Points) != 0 || len(tr.Factions) != 0 {
|
||||
t.Fatalf("empty: %+v", tr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// errTestSentinel is a shared sentinel used in handler tests that inject errors.
|
||||
var errTestSentinel = errors.New("injected test error")
|
||||
|
||||
// TestProcessTeleportCoords exercises the online/offline branching logic with
|
||||
// injected deps (no DB or broker), mirroring the processWhisper pattern.
|
||||
func TestProcessTeleportCoords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type result struct {
|
||||
path string
|
||||
flsID string
|
||||
x, y, z float64
|
||||
}
|
||||
|
||||
t.Run("online player uses RMQ path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var got result
|
||||
err := processTeleportCoords(teleportCoordsParams{
|
||||
flsID: "abc123",
|
||||
x: 100, y: 200, z: 300,
|
||||
isOnline: func(_ string) bool { return true },
|
||||
sendRMQ: func(id string, x, y, z float64) error { got = result{"rmq", id, x, y, z}; return nil },
|
||||
writeDB: func(id string, pid int64, x, y, z float64) error {
|
||||
t.Error("DB path must not be called for online player")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.path != "rmq" || got.flsID != "abc123" || got.x != 100 || got.y != 200 || got.z != 300 {
|
||||
t.Fatalf("wrong result: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("offline player uses DB path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var got result
|
||||
err := processTeleportCoords(teleportCoordsParams{
|
||||
flsID: "abc123",
|
||||
x: 100, y: 200, z: 300,
|
||||
partitionID: 7,
|
||||
isOnline: func(_ string) bool { return false },
|
||||
sendRMQ: func(id string, x, y, z float64) error {
|
||||
t.Error("RMQ must not be called for offline player")
|
||||
return nil
|
||||
},
|
||||
writeDB: func(id string, pid int64, x, y, z float64) error { got = result{"db", id, x, y, z}; return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.path != "db" || got.flsID != "abc123" {
|
||||
t.Fatalf("wrong result: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RMQ error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errTestSentinel
|
||||
err := processTeleportCoords(teleportCoordsParams{
|
||||
flsID: "abc123",
|
||||
x: 1, y: 2, z: 3,
|
||||
isOnline: func(_ string) bool { return true },
|
||||
sendRMQ: func(string, float64, float64, float64) error { return boom },
|
||||
writeDB: func(string, int64, float64, float64, float64) error { return nil },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from RMQ, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DB error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errTestSentinel
|
||||
err := processTeleportCoords(teleportCoordsParams{
|
||||
flsID: "abc123",
|
||||
x: 1, y: 2, z: 3,
|
||||
isOnline: func(_ string) bool { return false },
|
||||
sendRMQ: func(string, float64, float64, float64) error { return nil },
|
||||
writeDB: func(string, int64, float64, float64, float64) error { return boom },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from DB, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleTeleportCoords_InputValidation checks bad input before any
|
||||
// DB/RMQ call. globalDB is nil so the 503 guard fires for a connected path;
|
||||
// this only tests the 400 paths which fire first.
|
||||
func TestHandleTeleportCoords_InputValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "missing fls_id",
|
||||
body: map[string]any{"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "empty fls_id",
|
||||
body: map[string]any{"fls_id": "", "x": 1.0, "y": 2.0, "z": 3.0},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
body: nil, // signals raw bad JSON below
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var bodyBytes []byte
|
||||
if tt.body == nil {
|
||||
bodyBytes = []byte("{bad")
|
||||
} else {
|
||||
bodyBytes, _ = json.Marshal(tt.body)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/players/teleport-coords", bytes.NewReader(bodyBytes))
|
||||
rec := httptest.NewRecorder()
|
||||
handleTeleportCoords(rec, req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("want %d, got %d (body: %s)", tt.wantStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProcessWhisper exercises the whisper orchestration with injected deps (no
|
||||
// DB/broker), mirroring the processGiveItems testing pattern. It asserts the
|
||||
// resolved GM sender + recipient identities flow into send in the right slots and
|
||||
// that each failure short-circuits the steps after it.
|
||||
func TestProcessWhisper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
okGM := func(context.Context) (gmIdentity, error) {
|
||||
return gmIdentity{AccountID: gmIdentityAccountID, HexID: "GMHEX", FuncomID: "Server#0001"}, nil
|
||||
}
|
||||
|
||||
t.Run("happy path passes resolved identities to send", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var got struct{ senderFuncom, senderHex, recipFuncom, recipName, msg string }
|
||||
err := processWhisper(context.Background(), 42, "hello", whisperDeps{
|
||||
getGM: okGM,
|
||||
resolveRecip: func(_ context.Context, accountID int64) (string, string, error) {
|
||||
if accountID != 42 {
|
||||
t.Fatalf("resolveRecip got account %d, want 42", accountID)
|
||||
}
|
||||
return "Tester#1234", "Tester", nil
|
||||
},
|
||||
send: func(sf, sh, rf, rn, m string) error {
|
||||
got.senderFuncom, got.senderHex, got.recipFuncom, got.recipName, got.msg = sf, sh, rf, rn, m
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.senderFuncom != "Server#0001" || got.senderHex != "GMHEX" {
|
||||
t.Fatalf("sender identity wrong: %+v", got)
|
||||
}
|
||||
if got.recipFuncom != "Tester#1234" || got.recipName != "Tester" || got.msg != "hello" {
|
||||
t.Fatalf("recipient/message wrong: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gm not provisioned short-circuits before resolve/send", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
err := processWhisper(context.Background(), 42, "hi", whisperDeps{
|
||||
getGM: func(context.Context) (gmIdentity, error) { return gmIdentity{}, errGMNotProvisioned },
|
||||
resolveRecip: func(context.Context, int64) (string, string, error) { called = true; return "", "", nil },
|
||||
send: func(string, string, string, string, string) error { called = true; return nil },
|
||||
})
|
||||
if !errors.Is(err, errGMNotProvisioned) {
|
||||
t.Fatalf("want errGMNotProvisioned, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("resolve/send must not run when GM identity is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recipient resolve error short-circuits send", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errors.New("no such recipient")
|
||||
sent := false
|
||||
err := processWhisper(context.Background(), 42, "hi", whisperDeps{
|
||||
getGM: okGM,
|
||||
resolveRecip: func(context.Context, int64) (string, string, error) { return "", "", boom },
|
||||
send: func(string, string, string, string, string) error { sent = true; return nil },
|
||||
})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("want boom, got %v", err)
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("send must not run when recipient resolution fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("send error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errors.New("broker down")
|
||||
err := processWhisper(context.Background(), 42, "hi", whisperDeps{
|
||||
getGM: okGM,
|
||||
resolveRecip: func(context.Context, int64) (string, string, error) { return "r", "n", nil },
|
||||
send: func(string, string, string, string, string) error { return boom },
|
||||
})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("want boom, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
561
docs/reference-repos/icehunter/cmd/dune-admin/handlers_rmq.go
Normal file
561
docs/reference-repos/icehunter/cmd/dune-admin/handlers_rmq.go
Normal file
@@ -0,0 +1,561 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All handlers in this file publish RabbitMQ server commands.
|
||||
// They are fire-and-forget — the game server applies the command and logs the
|
||||
// result. The HTTP response indicates whether the command was sent, not whether
|
||||
// the game server executed it successfully.
|
||||
|
||||
// @Summary Send kick command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/kick [post]
|
||||
// POST /api/v1/players/kick
|
||||
func handleRMQKickPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqKickPlayer(req.FlsID); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("kick command sent for %s", req.FlsID)})
|
||||
}
|
||||
|
||||
// fillWaterParams bundles the injectable dependencies for processFillWater so
|
||||
// the online/offline branching can be unit-tested without a live DB or broker.
|
||||
type fillWaterParams struct {
|
||||
flsID string
|
||||
waterAmount int
|
||||
isOnline func(flsID string) bool
|
||||
sendRMQ func(flsID string, waterAmount int) error
|
||||
resolveActor func(flsID string) (int64, error)
|
||||
refillDB func(actorID int64) (int64, error)
|
||||
}
|
||||
|
||||
// processFillWater fills water containers for the given player. Online players
|
||||
// receive an immediate RMQ command; offline players get a DB write that takes
|
||||
// effect on their next relog. The zero water-amount defaults to 1 000 000.
|
||||
func processFillWater(p fillWaterParams) error {
|
||||
if p.waterAmount <= 0 {
|
||||
p.waterAmount = 1000000
|
||||
}
|
||||
if p.isOnline(p.flsID) {
|
||||
return p.sendRMQ(p.flsID, p.waterAmount)
|
||||
}
|
||||
actorID, err := p.resolveActor(p.flsID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve player: %w", err)
|
||||
}
|
||||
_, err = p.refillDB(actorID)
|
||||
return err
|
||||
}
|
||||
|
||||
// @Summary Send fill-water command via RabbitMQ (player must be online)
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID and optional water amount"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 422 {object} map[string]string "Player must be online"
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/fill-water [post]
|
||||
// POST /api/v1/players/fill-water
|
||||
func handleRMQFillWater(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
WaterAmount int `json:"water_amount"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id required"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: req.FlsID,
|
||||
waterAmount: req.WaterAmount,
|
||||
isOnline: func(id string) bool { return isHexIDOnline(ctx, id) },
|
||||
sendRMQ: func(id string, amt int) error { return rmqUpdateAllWaterFillables(id, amt) },
|
||||
resolveActor: func(id string) (int64, error) {
|
||||
return cmdActorIDFromFlsID(ctx, id)
|
||||
},
|
||||
refillDB: func(actorID int64) (int64, error) {
|
||||
return cmdRefillWaterOffline(ctx, actorID)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("handleRMQFillWater: %v", err)
|
||||
jsonErr(w, fmt.Errorf("fill water failed: %w", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("fill water command sent for %s", req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send set-skill-points command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID and skill points value"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/set-skill-points [post]
|
||||
// POST /api/v1/players/set-skill-points
|
||||
func handleRMQSetSkillPoints(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
SkillPoints int `json:"skill_points"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqSkillsSetUnspentSkillPoints(req.FlsID, req.SkillPoints); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("set skill points %d sent for %s", req.SkillPoints, req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send server-wide broadcast message via RabbitMQ
|
||||
// @Tags broadcast
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Localized texts and optional duration in seconds"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/broadcast [post]
|
||||
// POST /api/v1/broadcast
|
||||
func handleRMQBroadcast(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DurationSec int `json:"duration_sec"`
|
||||
Texts []localizedText `json:"texts"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if len(req.Texts) == 0 {
|
||||
jsonErr(w, fmt.Errorf("texts required"), 400)
|
||||
return
|
||||
}
|
||||
if req.DurationSec <= 0 {
|
||||
req.DurationSec = 30
|
||||
}
|
||||
if err := rmqServiceBroadcastGeneric(req.DurationSec, req.Texts); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "broadcast sent"})
|
||||
}
|
||||
|
||||
// @Summary Send shutdown broadcast command via RabbitMQ
|
||||
// @Tags broadcast
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Shutdown type, delay, frequency, duration, and cancel flag"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/broadcast/shutdown [post]
|
||||
// POST /api/v1/broadcast/shutdown
|
||||
func handleRMQBroadcastShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ShutdownType string `json:"shutdown_type"` // "Restart", "Maintenance", or "Update"
|
||||
DelayMinutes int `json:"delay_minutes"`
|
||||
Frequency int `json:"frequency"`
|
||||
Duration int `json:"duration"`
|
||||
Cancel bool `json:"cancel"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.ShutdownType == "" {
|
||||
req.ShutdownType = "Restart"
|
||||
}
|
||||
ts := time.Now().Add(time.Duration(req.DelayMinutes) * time.Minute).Unix()
|
||||
if err := rmqServiceBroadcastShutdown(req.ShutdownType, ts, req.Frequency, req.Duration, req.Cancel); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
action := "shutdown broadcast sent"
|
||||
if req.Cancel {
|
||||
action = "shutdown broadcast cancelled"
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": action})
|
||||
}
|
||||
|
||||
// @Summary Send cheat script command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID and script name"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/cheat-script [post]
|
||||
// POST /api/v1/players/cheat-script
|
||||
func handleRMQCheatScript(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
ScriptName string `json:"script_name"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" || req.ScriptName == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id and script_name required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqCheatScript(req.FlsID, req.ScriptName); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("cheat script %q sent for %s", req.ScriptName, req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send give-item command via RabbitMQ to an online player
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Actor ID, item template, quantity, and durability"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/give-item-live [post]
|
||||
// POST /api/v1/players/give-item-live
|
||||
// Give item to an ONLINE player via RMQ. Pre-checks weight/slot limits via DB.
|
||||
// Accepts actor_id (player pawn ID), resolves to FLS ID automatically.
|
||||
func handleRMQGiveItem(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
PlayerID int64 `json:"player_id"` // actor (pawn) ID
|
||||
Template string `json:"template"`
|
||||
Qty int `json:"qty"`
|
||||
Durability float64 `json:"durability"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.PlayerID == 0 || req.Template == "" {
|
||||
jsonErr(w, fmt.Errorf("player_id and template required"), 400)
|
||||
return
|
||||
}
|
||||
if req.Qty <= 0 {
|
||||
req.Qty = 1
|
||||
}
|
||||
if req.Durability <= 0 {
|
||||
req.Durability = 1.0
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Check weight/slot capacity before sending to avoid bypassing limits.
|
||||
if err := checkInventoryCapacity(ctx, req.PlayerID, req.Template, int64(req.Qty)); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
|
||||
flsID, err := flsIDFromActorID(ctx, req.PlayerID)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("resolve player: %w", err), 404)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rmqAddItemToInventory(flsID, req.Template, req.Qty, req.Durability); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"ok": fmt.Sprintf("sent %d × %s to online player %d via server command", req.Qty, req.Template, req.PlayerID),
|
||||
"path": "rmq",
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Send clean-inventory command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/clean-inventory [post]
|
||||
// POST /api/v1/players/clean-inventory
|
||||
func handleRMQCleanInventory(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqCleanPlayerInventory(req.FlsID); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("clean inventory command sent for %s", req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send reset-progression command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/reset-progression [post]
|
||||
// POST /api/v1/players/reset-progression
|
||||
func handleRMQResetProgression(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqResetProgression(req.FlsID); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("reset progression command sent for %s", req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send set-skill-module command via RabbitMQ
|
||||
// @Tags players
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Player FLS ID, module name, and level"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/players/set-skill-module [post]
|
||||
// POST /api/v1/players/set-skill-module
|
||||
func handleRMQSetSkillModule(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
Module string `json:"module"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" || req.Module == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id and module required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqSkillsSetModuleLevel(req.FlsID, req.Module, req.Level); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("set module %s level %d sent for %s", req.Module, req.Level, req.FlsID)})
|
||||
}
|
||||
|
||||
// @Summary Send vehicle spawn command via RabbitMQ
|
||||
// @Tags vehicles
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "FLS ID, class name, coordinates, rotation, template, persistence, and faction"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/vehicles/spawn [post]
|
||||
// POST /api/v1/vehicles/spawn
|
||||
func handleRMQSpawnVehicle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
FlsID string `json:"fls_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Rotation float64 `json:"rotation"`
|
||||
TemplateName string `json:"template_name"`
|
||||
Persistent bool `json:"persistent"`
|
||||
Faction string `json:"faction"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.FlsID == "" || req.ClassName == "" {
|
||||
jsonErr(w, fmt.Errorf("fls_id and class_name required"), 400)
|
||||
return
|
||||
}
|
||||
if err := rmqSpawnVehicleAt(req.FlsID, req.ClassName, req.X, req.Y, req.Z, req.Rotation, req.TemplateName, req.Persistent, req.Faction); err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("spawn %s command sent for %s", req.ClassName, req.FlsID)})
|
||||
}
|
||||
|
||||
// whisperDeps are the injected dependencies for processWhisper so the
|
||||
// orchestration can be unit-tested without a live DB or broker.
|
||||
type whisperDeps struct {
|
||||
getGM func(context.Context) (gmIdentity, error)
|
||||
resolveRecip func(ctx context.Context, accountID int64) (funcomID, charName string, err error)
|
||||
send func(senderFuncomID, senderHexID, recipientFuncomID, recipientName, message string) error
|
||||
}
|
||||
|
||||
// processWhisper resolves the GM/Server sender and the recipient identities, then
|
||||
// sends the whisper. The seeded GM persona is the sender (its funcom id and hex
|
||||
// FLS id); the recipient is looked up by account id. Returns the underlying error
|
||||
// so the handler can map errGMNotProvisioned to 503.
|
||||
func processWhisper(ctx context.Context, accountID int64, message string, d whisperDeps) error {
|
||||
gm, err := d.getGM(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipientFuncomID, recipientName, err := d.resolveRecip(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.send(gm.FuncomID, gm.HexID, recipientFuncomID, recipientName, message)
|
||||
}
|
||||
|
||||
// @Summary Send a whisper to a player from the GM/Server persona
|
||||
// @Tags chat
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object true "Recipient account id and message"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/chat/whisper [post]
|
||||
// POST /api/v1/chat/whisper
|
||||
//
|
||||
// Sends a private chat message to one player, shown in their Whispers tab and
|
||||
// attributed to the seeded GM/Server persona. The exact wire shape is pinned by
|
||||
// buildWhisperBody against the live-confirmed protocol.
|
||||
func handleRMQWhisper(w http.ResponseWriter, r *http.Request) {
|
||||
if globalDB == nil {
|
||||
jsonErr(w, fmt.Errorf("database not connected"), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
jsonErr(w, err, 400)
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 || req.Message == "" {
|
||||
jsonErr(w, fmt.Errorf("account_id and message required"), 400)
|
||||
return
|
||||
}
|
||||
|
||||
err := processWhisper(r.Context(), req.AccountID, req.Message, whisperDeps{
|
||||
getGM: cmdGetGMIdentity,
|
||||
resolveRecip: cmdResolveRecipientChatIdentity,
|
||||
send: rmqSendWhisper,
|
||||
})
|
||||
if errors.Is(err, errGMNotProvisioned) {
|
||||
jsonErr(w, err, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("handleRMQWhisper: %v", err)
|
||||
jsonErr(w, fmt.Errorf("failed to send whisper"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": fmt.Sprintf("whisper sent to account %d", req.AccountID)})
|
||||
}
|
||||
|
||||
// @Summary Resolve actor ID to both ID forms and render a sample RMQ envelope
|
||||
// @Tags players
|
||||
// @Produce json
|
||||
// @Param id path int true "Actor (pawn) ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/players/{id}/player-ids [get]
|
||||
// Returns both ID forms for an actor so you can verify which PlayerId the
|
||||
// game server would receive. Also renders a sample AddItemToInventory envelope
|
||||
// (without sending it) so the exact message format can be confirmed.
|
||||
func handlePlayerIDDebug(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
actorID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid actor id %q", idStr), 400)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ids, err := playerIDDebug(ctx, actorID)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a sample envelope to show what the game server receives.
|
||||
inner, _ := json.Marshal(map[string]any{
|
||||
"ServerCommand": "AddItemToInventory",
|
||||
"PlayerId": ids["hex_id"],
|
||||
"ItemName": "<item_template>",
|
||||
"Quantity": 1,
|
||||
"Durability": 1.0,
|
||||
})
|
||||
outer := map[string]any{
|
||||
"Version": 2,
|
||||
"AuthToken": serverCmdAuthToken,
|
||||
"MessageContent": string(inner),
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]any{
|
||||
"actor_id": actorID,
|
||||
"display_name": ids["display_name"],
|
||||
"hex_id": ids["hex_id"],
|
||||
"player_id_field": ids["hex_id"],
|
||||
"auth_token": serverCmdAuthToken,
|
||||
"publish_method": "rabbitmqctl eval (user_id=fls)",
|
||||
"sample_envelope": outer,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProcessFillWater exercises the online/offline branching with injected
|
||||
// deps — no DB or broker needed.
|
||||
func TestProcessFillWater(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("online player: RMQ path called", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotFlsID string
|
||||
var gotAmount int
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 500000,
|
||||
isOnline: func(string) bool { return true },
|
||||
sendRMQ: func(id string, amt int) error { gotFlsID = id; gotAmount = amt; return nil },
|
||||
resolveActor: func(string) (int64, error) { t.Error("resolveActor must not be called online"); return 0, nil },
|
||||
refillDB: func(int64) (int64, error) { t.Error("refillDB must not be called online"); return 0, nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotFlsID != "abc123" || gotAmount != 500000 {
|
||||
t.Fatalf("RMQ called with wrong args: flsID=%q amount=%d", gotFlsID, gotAmount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("offline player: DB path called", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rmqCalled := false
|
||||
var gotActorID int64
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 1000000,
|
||||
isOnline: func(string) bool { return false },
|
||||
sendRMQ: func(string, int) error { rmqCalled = true; return nil },
|
||||
resolveActor: func(string) (int64, error) { return 42, nil },
|
||||
refillDB: func(id int64) (int64, error) { gotActorID = id; return 3, nil },
|
||||
})
|
||||
if rmqCalled {
|
||||
t.Fatal("RMQ must not be called for offline player")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotActorID != 42 {
|
||||
t.Fatalf("refillDB called with wrong actor ID: %d", gotActorID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("offline: resolve actor error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errors.New("player not found")
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 1000000,
|
||||
isOnline: func(string) bool { return false },
|
||||
sendRMQ: func(string, int) error { return nil },
|
||||
resolveActor: func(string) (int64, error) { return 0, boom },
|
||||
refillDB: func(int64) (int64, error) { return 0, nil },
|
||||
})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("want boom, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("offline: DB error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
boom := errors.New("db down")
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 1000000,
|
||||
isOnline: func(string) bool { return false },
|
||||
sendRMQ: func(string, int) error { return nil },
|
||||
resolveActor: func(string) (int64, error) { return 42, nil },
|
||||
refillDB: func(int64) (int64, error) { return 0, boom },
|
||||
})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("want boom, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RMQ error propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 1000000,
|
||||
isOnline: func(string) bool { return true },
|
||||
sendRMQ: func(string, int) error { return errTestSentinel },
|
||||
resolveActor: func(string) (int64, error) { return 0, nil },
|
||||
refillDB: func(int64) (int64, error) { return 0, nil },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected RMQ error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("water amount defaults to 1000000 when zero", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotAmount int
|
||||
_ = processFillWater(fillWaterParams{
|
||||
flsID: "abc123",
|
||||
waterAmount: 0,
|
||||
isOnline: func(string) bool { return true },
|
||||
sendRMQ: func(_ string, amt int) error { gotAmount = amt; return nil },
|
||||
resolveActor: func(string) (int64, error) { return 0, nil },
|
||||
refillDB: func(int64) (int64, error) { return 0, nil },
|
||||
})
|
||||
if gotAmount != 1000000 {
|
||||
t.Fatalf("want default 1000000, got %d", gotAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleFillWater_InputValidation verifies bad input returns 400.
|
||||
func TestHandleFillWater_InputValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawBody []byte
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "missing fls_id",
|
||||
rawBody: []byte(`{"water_amount":1000}`),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "empty fls_id",
|
||||
rawBody: []byte(`{"fls_id":""}`),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
rawBody: []byte(`{bad`),
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/players/fill-water", bytes.NewReader(tt.rawBody))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRMQFillWater(rec, req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("want %d, got %d (body: %s)", tt.wantStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleFillWater_OfflineUsesDB verifies that an offline player triggers
|
||||
// the DB path. globalDB is nil in unit tests, so the DB call returns an error
|
||||
// and the handler returns 500 (not 422 or the old silent 200).
|
||||
func TestHandleFillWater_OfflineUsesDB(t *testing.T) {
|
||||
// NOT parallel — reads globalDB package global (nil in tests).
|
||||
// isHexIDOnline → false (nil DB), so the offline DB path fires.
|
||||
// cmdRefillWaterOffline sees globalDB nil and returns an error → 500.
|
||||
body, _ := json.Marshal(map[string]any{"fls_id": "abc123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/players/fill-water", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handleRMQFillWater(rec, req)
|
||||
// 500: DB path attempted but globalDB is nil.
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("want 500 (DB path, nil DB), got %d (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaterFillableTemplates verifies the generated list is non-empty and
|
||||
// contains expected canonical entries from DT_ItemTableFillables.
|
||||
func TestWaterFillableTemplates(t *testing.T) {
|
||||
t.Parallel()
|
||||
if len(waterFillableTemplates) == 0 {
|
||||
t.Fatal("waterFillableTemplates must not be empty")
|
||||
}
|
||||
want := []string{"literjon", "decajon", "dewpack", "literjon_t6"}
|
||||
set := make(map[string]bool, len(waterFillableTemplates))
|
||||
for _, s := range waterFillableTemplates {
|
||||
set[s] = true
|
||||
}
|
||||
for _, w := range want {
|
||||
if !set[w] {
|
||||
t.Errorf("waterFillableTemplates missing expected entry %q", w)
|
||||
}
|
||||
}
|
||||
// Must not contain blood containers.
|
||||
blood := []string{"bloodsack_01", "bloodsack_02", "bloodsack_t6"}
|
||||
for _, b := range blood {
|
||||
if set[b] {
|
||||
t.Errorf("waterFillableTemplates must not contain blood container %q", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// @Summary Get the scheduled-backup config + next backup time
|
||||
// @Tags scheduled-backups
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/scheduled-backups [get]
|
||||
func handleGetScheduledBackups(w http.ResponseWriter, _ *http.Request) {
|
||||
cfg := getScheduledBackupConfig()
|
||||
resp := map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"timezone": cfg.Timezone,
|
||||
"rules": cfg.Rules,
|
||||
"keep_n": cfg.KeepN,
|
||||
"last_fired": cfg.LastFired,
|
||||
}
|
||||
if cfg.Enabled {
|
||||
if next, ok := nextBackupAt(time.Now(), cfg.Rules, restartLocation(cfg.Timezone)); ok {
|
||||
resp["next_backup"] = next.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
func validateBackupRules(rules []backupRule) error {
|
||||
for _, r := range rules {
|
||||
if _, _, ok := parseHHMM(r.Time); !ok {
|
||||
return fmt.Errorf("invalid time %q (expected HH:MM)", r.Time)
|
||||
}
|
||||
if len(r.Days) == 0 {
|
||||
return fmt.Errorf("a backup rule has no days selected")
|
||||
}
|
||||
for _, d := range r.Days {
|
||||
if d < 0 || d > 6 {
|
||||
return fmt.Errorf("invalid weekday %d (expected 0-6)", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary Update the scheduled-backup config
|
||||
// @Tags scheduled-backups
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /api/v1/scheduled-backups [put]
|
||||
func handleUpdateScheduledBackups(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Timezone string `json:"timezone"`
|
||||
Rules []backupRule `json:"rules"`
|
||||
KeepN int `json:"keep_n"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBackupRules(body.Rules); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Timezone != "" {
|
||||
if _, err := time.LoadLocation(body.Timezone); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid timezone %q", body.Timezone), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
cur := getScheduledBackupConfig() // preserve last_fired watermark
|
||||
cur.Enabled = body.Enabled
|
||||
cur.Timezone = body.Timezone
|
||||
cur.Rules = body.Rules
|
||||
cur.KeepN = body.KeepN
|
||||
if err := saveScheduledBackupConfig(cur); err != nil {
|
||||
log.Printf("handleUpdateScheduledBackups: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not save schedule"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "schedule saved"})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// @Summary Get the scheduled-restart config + next restart time
|
||||
// @Tags scheduled-restarts
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/scheduled-restarts [get]
|
||||
func handleGetScheduledRestarts(w http.ResponseWriter, _ *http.Request) {
|
||||
cfg := getScheduledRestartConfig()
|
||||
resp := map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"timezone": cfg.Timezone,
|
||||
"rules": cfg.Rules,
|
||||
"warn_minutes": cfg.WarnMinutes,
|
||||
"last_fired": cfg.LastFired,
|
||||
}
|
||||
if cfg.Enabled {
|
||||
if next, ok := nextRestartAt(time.Now(), cfg.Rules, restartLocation(cfg.Timezone)); ok {
|
||||
resp["next_restart"] = next.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
func validateRestartRules(rules []restartRule) error {
|
||||
for _, r := range rules {
|
||||
if _, _, ok := parseHHMM(r.Time); !ok {
|
||||
return fmt.Errorf("invalid time %q (expected HH:MM)", r.Time)
|
||||
}
|
||||
if len(r.Days) == 0 {
|
||||
return fmt.Errorf("a restart rule has no days selected")
|
||||
}
|
||||
for _, d := range r.Days {
|
||||
if d < 0 || d > 6 {
|
||||
return fmt.Errorf("invalid weekday %d (expected 0-6)", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary Update the scheduled-restart config
|
||||
// @Tags scheduled-restarts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /api/v1/scheduled-restarts [put]
|
||||
func handleUpdateScheduledRestarts(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Timezone string `json:"timezone"`
|
||||
Rules []restartRule `json:"rules"`
|
||||
WarnMinutes int `json:"warn_minutes"`
|
||||
}
|
||||
if err := decode(r, &body); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateRestartRules(body.Rules); err != nil {
|
||||
jsonErr(w, err, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Timezone != "" {
|
||||
if _, err := time.LoadLocation(body.Timezone); err != nil {
|
||||
jsonErr(w, fmt.Errorf("invalid timezone %q", body.Timezone), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
cur := getScheduledRestartConfig() // preserve last_fired watermark
|
||||
cur.Enabled = body.Enabled
|
||||
cur.Timezone = body.Timezone
|
||||
cur.Rules = body.Rules
|
||||
cur.WarnMinutes = body.WarnMinutes
|
||||
if err := saveScheduledRestartConfig(cur); err != nil {
|
||||
log.Printf("handleUpdateScheduledRestarts: %v", err)
|
||||
jsonErr(w, fmt.Errorf("could not save schedule"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"ok": "schedule saved"})
|
||||
}
|
||||
|
||||
// @Summary Skip the next scheduled restart (without disabling the schedule)
|
||||
// @Tags scheduled-restarts
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /api/v1/scheduled-restarts/skip-next [post]
|
||||
func handleSkipNextRestart(w http.ResponseWriter, _ *http.Request) {
|
||||
cfg := getScheduledRestartConfig()
|
||||
next, ok := nextRestartAt(time.Now(), cfg.Rules, restartLocation(cfg.Timezone))
|
||||
if !ok {
|
||||
jsonErr(w, fmt.Errorf("no upcoming restart to skip"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Advancing the watermark to the next occurrence makes the scheduler treat it
|
||||
// as already handled — neither warned nor fired.
|
||||
setRestartLastFired(next.Unix())
|
||||
jsonOK(w, map[string]string{"ok": "next restart skipped"})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const ampDefaultsSuffix = "/extracted/game-server/home/dune/server/DuneSandbox/Config"
|
||||
|
||||
// TestAmpDefaultINIDir verifies the AMP stock-defaults directory is derived from
|
||||
// the instance layout: from the discovered INI dir, the configured server_ini_dir,
|
||||
// or the conventional ampdata path for the instance — in that order.
|
||||
func TestAmpDefaultINIDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctrl *ampControl
|
||||
iniDir string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "from discovered ue5-saved dir",
|
||||
ctrl: &Control{},
|
||||
iniDir: "/home/amp/.ampdata/instances/DuneAwakening01/duneawakening/server/state/ue5-saved/UserSettings",
|
||||
want: "/home/amp/.ampdata/instances/DuneAwakening01/duneawakening" + ampDefaultsSuffix,
|
||||
},
|
||||
{
|
||||
name: "from configured server_ini_dir when no discovered dir",
|
||||
ctrl: &Control{iniDir: "/opt/inst/duneawakening/server/state"},
|
||||
iniDir: "",
|
||||
want: "/opt/inst/duneawakening" + ampDefaultsSuffix,
|
||||
},
|
||||
{
|
||||
name: "from instance when nothing else available (container)",
|
||||
ctrl: &Control{useContainer: true, instance: "DA02", ampUser: "amp"},
|
||||
iniDir: "",
|
||||
want: "/home/amp/.ampdata/instances/DA02/duneawakening" + ampDefaultsSuffix,
|
||||
},
|
||||
{
|
||||
name: "empty when nothing derivable",
|
||||
ctrl: &Control{},
|
||||
iniDir: "/some/unrelated/path",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.ctrl.defaultINIDir(tt.iniDir); got != tt.want {
|
||||
t.Errorf("defaultINIDir(%q) = %q, want %q", tt.iniDir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverViaControlDefaultDir verifies the discovery glue reads the stock
|
||||
// default from the control-plane-derived directory, and is a no-op when the
|
||||
// control plane does not provide one.
|
||||
func TestDiscoverViaControlDefaultDir(t *testing.T) {
|
||||
origCtrl, origExec := globalControl, globalExecutor
|
||||
t.Cleanup(func() { globalControl, globalExecutor = origCtrl, origExec })
|
||||
|
||||
wantDir := "/home/amp/.ampdata/instances/DuneAwakening01/duneawakening" + ampDefaultsSuffix
|
||||
globalControl = &Control{useContainer: true, instance: "DuneAwakening01", ampUser: "amp"}
|
||||
globalExecutor = &fnExecutor{fn: func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, wantDir+"/DefaultGame.ini") {
|
||||
return "[Sec]\nKey=val\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}}
|
||||
|
||||
if got := discoverViaControlDefaultDir("", "DefaultGame.ini"); !strings.Contains(got, "Key=val") {
|
||||
t.Errorf("expected content from %s, got %q", wantDir, got)
|
||||
}
|
||||
|
||||
// Non-provider control plane → no derivation.
|
||||
globalControl = &localControl{}
|
||||
if got := discoverViaControlDefaultDir("", "DefaultGame.ini"); got != "" {
|
||||
t.Errorf("expected empty for non-AMP control, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfiguredDefaultINIPath(t *testing.T) {
|
||||
originalConfig := loadedConfig
|
||||
t.Cleanup(func() { loadedConfig = originalConfig })
|
||||
|
||||
loadedConfig.DefaultIniDir = "/opt/dune/config"
|
||||
if got := configuredDefaultINIPath("DefaultGame.ini"); got != "/opt/dune/config/DefaultGame.ini" {
|
||||
t.Fatalf("unexpected configured path: %q", got)
|
||||
}
|
||||
|
||||
loadedConfig.DefaultIniDir = ""
|
||||
if got := configuredDefaultINIPath("DefaultGame.ini"); got != "" {
|
||||
t.Fatalf("expected empty configured path when default ini dir unset, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sDerivedDefaultINICandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := k8sDerivedDefaultINICandidates("/home/dune/server/state", "DefaultEngine.ini")
|
||||
if len(got) < 7 {
|
||||
t.Fatalf("expected at least 7 candidates, got %d", len(got))
|
||||
}
|
||||
if got[0] != "/home/Config/DefaultEngine.ini" {
|
||||
t.Fatalf("unexpected first candidate: %q", got[0])
|
||||
}
|
||||
if got[len(got)-1] != "/game/DuneSandbox/Config/DefaultEngine.ini" {
|
||||
t.Fatalf("unexpected last candidate: %q", got[len(got)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDefaultINICandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := hostDefaultINICandidates("DefaultGame.ini")
|
||||
want := []string{
|
||||
"/home/dune/DefaultGame.ini",
|
||||
"/home/DefaultGame.ini",
|
||||
"/root/DefaultGame.ini",
|
||||
"/dune/DefaultGame.ini",
|
||||
"/home/dune/server/DuneSandbox/Config/DefaultGame.ini",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d host candidates, got %d", len(want), len(got))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("candidate %d mismatch: want %q got %q", i, want[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeDefaultINICandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := filepath.Clean("/srv/dune/server/state")
|
||||
got := relativeDefaultINICandidates(base, "DefaultEngine.ini")
|
||||
want := []string{
|
||||
filepath.Join(base, "..", "..", "..", "Config", "DefaultEngine.ini"),
|
||||
filepath.Join(base, "..", "..", "Config", "DefaultEngine.ini"),
|
||||
filepath.Join(base, "..", "..", "..", "..", "Config", "DefaultEngine.ini"),
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d relative candidates, got %d", len(want), len(got))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("candidate %d mismatch: want %q got %q", i, want[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerSettingsSchemaKeys(t *testing.T) {
|
||||
keys := serverSettingsSchemaKeys()
|
||||
if len(keys) != len(serverSettingsSchema) {
|
||||
t.Fatalf("expected %d keys, got %d", len(serverSettingsSchema), len(keys))
|
||||
}
|
||||
first := serverSettingsSchema[0]
|
||||
if !keys[first.Section+"|"+first.Key] {
|
||||
t.Fatalf("missing schema key %s|%s", first.Section, first.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettingLayers(t *testing.T) {
|
||||
s := ServerSetting{
|
||||
Section: "Sec",
|
||||
Key: "Key",
|
||||
Type: string(settingInt),
|
||||
Current: "0",
|
||||
}
|
||||
sources := []layerSource{
|
||||
{name: "defaultGame", ini: map[string]map[string]string{"Sec": {"Key": "1"}}},
|
||||
{name: "userGame", ini: map[string]map[string]string{"Sec": {"Key": "2"}}},
|
||||
}
|
||||
|
||||
applySettingLayers(&s, sources)
|
||||
|
||||
if s.Current != "2" || s.Source != "userGame" {
|
||||
t.Fatalf("unexpected current/source: %q / %q", s.Current, s.Source)
|
||||
}
|
||||
if !s.IsOverride {
|
||||
t.Fatal("expected IsOverride=true")
|
||||
}
|
||||
if len(s.Layers) != 2 {
|
||||
t.Fatalf("expected 2 layers, got %d", len(s.Layers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverUnknownSettings(t *testing.T) {
|
||||
schemaKeys := map[string]bool{"Sec|Known": true}
|
||||
sources := []layerSource{
|
||||
{
|
||||
name: "defaultGame",
|
||||
ini: map[string]map[string]string{
|
||||
"Sec": {"Known": "1", "+Array": "x", "CustomB": "true"},
|
||||
"A": {"CustomA": "42"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "userGame",
|
||||
ini: map[string]map[string]string{
|
||||
"Sec": {"CustomB": "false"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
discovered := discoverUnknownSettings(sources, schemaKeys)
|
||||
if len(discovered) != 2 {
|
||||
t.Fatalf("expected 2 discovered keys, got %d", len(discovered))
|
||||
}
|
||||
if discovered[0].section != "A" || discovered[0].key != "CustomA" {
|
||||
t.Fatalf("unexpected first discovered key: %+v", discovered[0])
|
||||
}
|
||||
if discovered[1].section != "Sec" || discovered[1].key != "CustomB" {
|
||||
t.Fatalf("unexpected second discovered key: %+v", discovered[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiscoveredSettings(t *testing.T) {
|
||||
discovered := []discoveredKey{{section: "Sec", key: "Custom"}}
|
||||
schemaKeys := map[string]bool{}
|
||||
sources := []layerSource{
|
||||
{name: "defaultGame", ini: map[string]map[string]string{"Sec": {"Custom": "1"}}},
|
||||
{name: "userGame", ini: map[string]map[string]string{"Sec": {"Custom": "True"}}},
|
||||
}
|
||||
|
||||
settings := buildDiscoveredSettings(discovered, sources, schemaKeys)
|
||||
if len(settings) != 1 {
|
||||
t.Fatalf("expected 1 discovered setting, got %d", len(settings))
|
||||
}
|
||||
if settings[0].Type != string(settingInt) {
|
||||
t.Fatalf("expected inferred type int from first layer, got %q", settings[0].Type)
|
||||
}
|
||||
if settings[0].Current != "True" || settings[0].Source != "userGame" {
|
||||
t.Fatalf("unexpected current/source: %q / %q", settings[0].Current, settings[0].Source)
|
||||
}
|
||||
if !settings[0].IsOverride {
|
||||
t.Fatal("expected IsOverride=true for user layer")
|
||||
}
|
||||
if !schemaKeys["Sec|Custom"] {
|
||||
t.Fatal("expected discovered key to be added to schema key set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerSettingsRawSections(t *testing.T) {
|
||||
schemaKeys := map[string]bool{"Sec|Known": true}
|
||||
defaultGame := "[Sec]\nKnown=1\nOther=2\n+Array=3\n"
|
||||
defaultEngine := "[Sec]\n-Array=4\n"
|
||||
raw := buildServerSettingsRawSections(defaultGame, defaultEngine, "", "", "", schemaKeys)
|
||||
|
||||
if len(raw) != 2 {
|
||||
t.Fatalf("expected 2 raw sections, got %d", len(raw))
|
||||
}
|
||||
if raw[0].Source != "defaultGame" || len(raw[0].Lines) != 2 {
|
||||
t.Fatalf("unexpected defaultGame raw section: %+v", raw[0])
|
||||
}
|
||||
if raw[1].Source != "defaultEngine" || len(raw[1].Lines) != 1 {
|
||||
t.Fatalf("unexpected defaultEngine raw section: %+v", raw[1])
|
||||
}
|
||||
if raw[0].Lines[0].Key != "Other" || raw[0].Lines[0].Prefix != "" {
|
||||
t.Fatalf("unexpected first defaultGame raw line: %+v", raw[0].Lines[0])
|
||||
}
|
||||
if raw[0].Lines[1].Key != "Array" || raw[0].Lines[1].Prefix != "+" {
|
||||
t.Fatalf("unexpected second defaultGame raw line: %+v", raw[0].Lines[1])
|
||||
}
|
||||
if raw[1].Lines[0].Key != "Array" || raw[1].Lines[0].Prefix != "-" {
|
||||
t.Fatalf("unexpected defaultEngine raw line: %+v", raw[1].Lines[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubControlPlane is a minimal ControlPlane that only implements DiscoverIniDir.
|
||||
// All other methods return "not implemented" errors.
|
||||
type stubControlPlane struct {
|
||||
iniDir string
|
||||
iniErr error
|
||||
}
|
||||
|
||||
func (s *stubControlPlane) Name() string { return "stub" }
|
||||
func (s *stubControlPlane) GetStatus(_ context.Context, _ Executor) (*BattlegroupStatus, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) ExecCommand(_ context.Context, _ Executor, _ string) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) ListProcesses(_ context.Context, _ Executor) ([]ProcessInfo, string, error) {
|
||||
return nil, "", errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) ListLogSources(_ context.Context, _ Executor) ([]LogSource, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) StreamLog(_ context.Context, _ Executor, _, _ string) (<-chan string, func(), error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) CaptureJWT(_ context.Context, _ Executor) (string, string, error) {
|
||||
return "", "", errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) EvalOnGameBroker(_ context.Context, _ Executor, _ string) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
func (s *stubControlPlane) DiscoverIniDir(_ context.Context, _ Executor) (string, error) {
|
||||
return s.iniDir, s.iniErr
|
||||
}
|
||||
func (s *stubControlPlane) ReadDefaultINI(_ context.Context, _ Executor, _ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func saveIniDirGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
origControl := globalControl
|
||||
origConfig := loadedConfig
|
||||
origServerIniDir := serverIniDir
|
||||
t.Cleanup(func() {
|
||||
globalControl = origControl
|
||||
loadedConfig = origConfig
|
||||
serverIniDir = origServerIniDir
|
||||
})
|
||||
}
|
||||
|
||||
// TestIniDir_PrefersControlPlaneOverConfigured verifies that when the control
|
||||
// plane successfully returns a path, iniDir() uses it even if server_ini_dir is
|
||||
// set in config. This ensures amp's ue5-saved/UserSettings probe always runs.
|
||||
func TestIniDir_PrefersControlPlaneOverConfigured(t *testing.T) {
|
||||
saveIniDirGlobals(t)
|
||||
setGlobalExecutor(t, func(_ string) (string, error) { return "", nil })
|
||||
|
||||
globalControl = &stubControlPlane{iniDir: "/discovered/ue5-saved/UserSettings"}
|
||||
loadedConfig.ServerIniDir = "/configured/state"
|
||||
|
||||
dir, err := iniDir()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if dir != "/discovered/ue5-saved/UserSettings" {
|
||||
t.Errorf("got %q, want /discovered/ue5-saved/UserSettings", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIniDir_FallsBackToConfiguredWhenControlPlaneFails verifies that when the
|
||||
// control plane errors (e.g. docker with no server_ini_dir stored), iniDir()
|
||||
// falls back to the configured server_ini_dir value.
|
||||
func TestIniDir_FallsBackToConfiguredWhenControlPlaneFails(t *testing.T) {
|
||||
saveIniDirGlobals(t)
|
||||
setGlobalExecutor(t, func(_ string) (string, error) { return "", nil })
|
||||
|
||||
globalControl = &stubControlPlane{iniErr: errors.New("control plane cannot discover ini dir")}
|
||||
loadedConfig.ServerIniDir = "/configured/state"
|
||||
|
||||
dir, err := iniDir()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if dir != "/configured/state" {
|
||||
t.Errorf("got %q, want /configured/state", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIniDir_UsesConfiguredWhenNoControlPlane verifies that when no control
|
||||
// plane is connected, iniDir() returns the configured server_ini_dir.
|
||||
func TestIniDir_UsesConfiguredWhenNoControlPlane(t *testing.T) {
|
||||
saveIniDirGlobals(t)
|
||||
origExecutor := globalExecutor
|
||||
t.Cleanup(func() { globalExecutor = origExecutor })
|
||||
|
||||
globalControl = nil
|
||||
globalExecutor = nil
|
||||
loadedConfig.ServerIniDir = "/configured/state"
|
||||
|
||||
dir, err := iniDir()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if dir != "/configured/state" {
|
||||
t.Errorf("got %q, want /configured/state", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIniDir_ErrorsWhenNothingConfigured verifies that iniDir() returns an
|
||||
// error when no control plane is available and no server_ini_dir is configured.
|
||||
func TestIniDir_ErrorsWhenNothingConfigured(t *testing.T) {
|
||||
saveIniDirGlobals(t)
|
||||
origExecutor := globalExecutor
|
||||
t.Cleanup(func() { globalExecutor = origExecutor })
|
||||
|
||||
globalControl = nil
|
||||
globalExecutor = nil
|
||||
loadedConfig.ServerIniDir = ""
|
||||
serverIniDir = ""
|
||||
|
||||
_, err := iniDir()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when nothing is configured, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAmpGameOverridePath verifies the AMP override path derivation: game
|
||||
// settings live in UserOverrides.ini in the state dir, two levels up from the
|
||||
// discovered ue5-saved/UserSettings INI dir.
|
||||
func TestAmpGameOverridePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ue5-saved layout",
|
||||
dir: "/home/amp/.ampdata/instances/DuneAwakening01/duneawakening/server/state/ue5-saved/UserSettings",
|
||||
want: "/home/amp/.ampdata/instances/DuneAwakening01/duneawakening/server/state/UserOverrides.ini",
|
||||
},
|
||||
{
|
||||
name: "trailing slash",
|
||||
dir: "/srv/state/ue5-saved/UserSettings/",
|
||||
want: "/srv/state/UserOverrides.ini",
|
||||
},
|
||||
{
|
||||
name: "non-ue5 dir falls back to sibling",
|
||||
dir: "/srv/state",
|
||||
want: "/srv/state/UserOverrides.ini",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := (&Control{}).gameOverridePath(tt.dir)
|
||||
if got != tt.want {
|
||||
t.Errorf("gameOverridePath(%q) = %q, want %q", tt.dir, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameWritePath_AMPUsesOverrides verifies game-scoped writes go to
|
||||
// UserOverrides.ini when the control plane is AMP (a gameOverrideProvider).
|
||||
func TestGameWritePath_AMPUsesOverrides(t *testing.T) {
|
||||
orig := globalControl
|
||||
globalControl = &Control{}
|
||||
t.Cleanup(func() { globalControl = orig })
|
||||
|
||||
dir := "/srv/state/ue5-saved/UserSettings"
|
||||
got := gameWritePath(dir)
|
||||
want := "/srv/state/UserOverrides.ini"
|
||||
if got != want {
|
||||
t.Errorf("gameWritePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGameWritePath_NonAMPWritesUserGame verifies non-AMP control planes keep
|
||||
// writing game settings directly to UserGame.ini in dir.
|
||||
func TestGameWritePath_NonAMPWritesUserGame(t *testing.T) {
|
||||
orig := globalControl
|
||||
t.Cleanup(func() { globalControl = orig })
|
||||
|
||||
dir := "/k8s/config"
|
||||
for _, ctrl := range []ControlPlane{&localControl{}, &kubectlControl{}, nil} {
|
||||
globalControl = ctrl
|
||||
got := gameWritePath(dir)
|
||||
want := dir + "/UserGame.ini"
|
||||
if got != want {
|
||||
t.Errorf("control %T: gameWritePath = %q, want %q", ctrl, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLayerSources_OverridesWin verifies the userGameOverrides layer takes
|
||||
// precedence over userGame (AMP-managed) for the same key, since AMP appends
|
||||
// UserOverrides.ini after UserGame.ini at boot.
|
||||
func TestBuildLayerSources_OverridesWin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gameIni := map[string]map[string]string{secBuilding: {"m_MaxLandclaim": "100"}}
|
||||
overridesIni := map[string]map[string]string{secBuilding: {"m_MaxLandclaim": "250"}}
|
||||
|
||||
layers := buildLayerSources(nil, nil, nil, gameIni, overridesIni)
|
||||
|
||||
s := &ServerSetting{Section: secBuilding, Key: "m_MaxLandclaim", Type: string(settingInt)}
|
||||
applySettingLayers(s, layers)
|
||||
|
||||
if s.Current != "250" {
|
||||
t.Errorf("Current = %q, want 250 (override should win)", s.Current)
|
||||
}
|
||||
if s.Source != "userGameOverrides" {
|
||||
t.Errorf("Source = %q, want userGameOverrides", s.Source)
|
||||
}
|
||||
if !s.IsOverride {
|
||||
t.Error("IsOverride = false, want true")
|
||||
}
|
||||
if len(s.Layers) != 2 {
|
||||
t.Fatalf("got %d layers, want 2 (userGame + userGameOverrides)", len(s.Layers))
|
||||
}
|
||||
if s.Layers[0].Source != "userGame" || s.Layers[1].Source != "userGameOverrides" {
|
||||
t.Errorf("layer order = [%s,%s], want [userGame,userGameOverrides]", s.Layers[0].Source, s.Layers[1].Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseINILines_FiltersSchemaAndPreservesArrayPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `
|
||||
; comment
|
||||
[SectionOne]
|
||||
Known=1
|
||||
Unknown = 2
|
||||
+Known=3
|
||||
-Known=4
|
||||
NoEqualsHere
|
||||
|
||||
[SectionTwo]
|
||||
Another=abc
|
||||
`
|
||||
schemaKeys := map[string]bool{"SectionOne|Known": true}
|
||||
|
||||
got := parseINILines(content, "userGame", schemaKeys)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(got))
|
||||
}
|
||||
if got[0].Section != "SectionOne" || got[0].Source != "userGame" {
|
||||
t.Fatalf("unexpected first section metadata: %+v", got[0])
|
||||
}
|
||||
if len(got[0].Lines) != 3 {
|
||||
t.Fatalf("expected 3 lines in SectionOne, got %d", len(got[0].Lines))
|
||||
}
|
||||
if got[0].Lines[0] != (RawLine{Key: "Unknown", Value: "2"}) {
|
||||
t.Fatalf("unexpected first raw line: %+v", got[0].Lines[0])
|
||||
}
|
||||
if got[0].Lines[1] != (RawLine{Prefix: "+", Key: "Known", Value: "3"}) {
|
||||
t.Fatalf("unexpected +array raw line: %+v", got[0].Lines[1])
|
||||
}
|
||||
if got[0].Lines[2] != (RawLine{Prefix: "-", Key: "Known", Value: "4"}) {
|
||||
t.Fatalf("unexpected -array raw line: %+v", got[0].Lines[2])
|
||||
}
|
||||
|
||||
if got[1].Section != "SectionTwo" || len(got[1].Lines) != 1 {
|
||||
t.Fatalf("unexpected second section: %+v", got[1])
|
||||
}
|
||||
if got[1].Lines[0] != (RawLine{Key: "Another", Value: "abc"}) {
|
||||
t.Fatalf("unexpected second section line: %+v", got[1].Lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseINILines_DropsSectionsWithoutRawLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `
|
||||
[OnlySchema]
|
||||
Known=1
|
||||
[HasRaw]
|
||||
Unknown=2
|
||||
`
|
||||
schemaKeys := map[string]bool{"OnlySchema|Known": true}
|
||||
|
||||
got := parseINILines(content, "defaultGame", schemaKeys)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected only one section after compaction, got %d", len(got))
|
||||
}
|
||||
if got[0].Section != "HasRaw" {
|
||||
t.Fatalf("expected HasRaw section to remain, got %q", got[0].Section)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setGlobalExecutor swaps globalExecutor for the duration of a test and
|
||||
// restores it via t.Cleanup. Uses fnExecutor (defined in control_amp_test.go).
|
||||
func setGlobalExecutor(t *testing.T, fn func(cmd string) (string, error)) {
|
||||
t.Helper()
|
||||
orig := globalExecutor
|
||||
globalExecutor = &fnExecutor{fn: fn}
|
||||
t.Cleanup(func() { globalExecutor = orig })
|
||||
}
|
||||
|
||||
// TestReadINIContent_PlainCatFirst verifies that readINIContent uses plain cat
|
||||
// before attempting sudo cat. When plain cat succeeds, sudo must not be called.
|
||||
func TestReadINIContent_PlainCatFirst(t *testing.T) {
|
||||
var calls []string
|
||||
setGlobalExecutor(t, func(cmd string) (string, error) {
|
||||
calls = append(calls, cmd)
|
||||
return "[section]\nkey=value\n", nil
|
||||
})
|
||||
|
||||
content := readINIContent("/path/to/UserGame.ini")
|
||||
|
||||
if content == "" {
|
||||
t.Fatal("expected content, got empty string")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 exec call, got %d: %v", len(calls), calls)
|
||||
}
|
||||
if strings.Contains(calls[0], "sudo") {
|
||||
t.Errorf("plain cat should be tried first, got: %q", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadINIContent_FallsBackToSudoCat verifies that when plain cat fails
|
||||
// (e.g. permission denied on a file not owned by the service user), readINIContent
|
||||
// retries with sudo cat — supporting non-AMP deployments where sudo is available.
|
||||
func TestReadINIContent_FallsBackToSudoCat(t *testing.T) {
|
||||
setGlobalExecutor(t, func(cmd string) (string, error) {
|
||||
if strings.Contains(cmd, "sudo") {
|
||||
return "[section]\nkey=sudovalue\n", nil
|
||||
}
|
||||
return "", errors.New("permission denied")
|
||||
})
|
||||
|
||||
content := readINIContent("/path/to/UserGame.ini")
|
||||
|
||||
if !strings.Contains(content, "sudovalue") {
|
||||
t.Fatalf("expected sudo cat fallback content, got: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadINIContent_ReturnsEmptyWhenBothFail verifies that when both cat and
|
||||
// sudo cat fail, readINIContent returns "" without panicking.
|
||||
func TestReadINIContent_ReturnsEmptyWhenBothFail(t *testing.T) {
|
||||
setGlobalExecutor(t, func(cmd string) (string, error) {
|
||||
return "", errors.New("no such file")
|
||||
})
|
||||
|
||||
content := readINIContent("/path/to/nonexistent.ini")
|
||||
|
||||
if content != "" {
|
||||
t.Fatalf("expected empty string, got: %q", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOverlayAMPSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := []ServerSetting{
|
||||
{
|
||||
Key: "GlobalMiningOutputMultiplier", Default: "1.0", Current: "1.0",
|
||||
FieldName: "ConsoleVariables.Dune.GlobalMiningOutputMultiplier",
|
||||
Source: "defaultEngine",
|
||||
Layers: []SettingLayer{{Source: "defaultEngine", Value: "1.0"}},
|
||||
},
|
||||
{Key: "ServerName", Default: "", Current: "OldName", FieldName: "WorldTitle"},
|
||||
{Key: "SomeIniThing", Default: "x", Current: "x"}, // no FieldName — untouched
|
||||
}
|
||||
amp := map[string]string{
|
||||
"ConsoleVariables.Dune.GlobalMiningOutputMultiplier": "5.0",
|
||||
"WorldTitle": "My Server",
|
||||
"NotInSettings": "ignored",
|
||||
}
|
||||
out := overlayAMPSettings(settings, amp)
|
||||
|
||||
// Mining: AMP value wins, overridden (5.0 != 1.0), amp source + top layer.
|
||||
if out[0].Current != "5.0" || !out[0].IsOverride || out[0].Source != ampSettingsSource {
|
||||
t.Fatalf("mining overlay = %+v", out[0])
|
||||
}
|
||||
last := out[0].Layers[len(out[0].Layers)-1]
|
||||
if last != (SettingLayer{Source: ampSettingsSource, Value: "5.0"}) {
|
||||
t.Fatalf("mining top layer = %+v, want amp/5.0", last)
|
||||
}
|
||||
// ServerName: default "" so "My Server" is an override.
|
||||
if out[1].Current != "My Server" || !out[1].IsOverride || out[1].Source != ampSettingsSource {
|
||||
t.Fatalf("servername overlay = %+v", out[1])
|
||||
}
|
||||
// Non-curated (no FieldName): untouched.
|
||||
if out[2].Current != "x" || out[2].Source == ampSettingsSource {
|
||||
t.Fatalf("non-curated should be untouched: %+v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
// When AMP holds the schema default, the setting is current=default and NOT
|
||||
// marked overridden — and gets no amp layer, so the "modified" filter is honest.
|
||||
func TestOverlayAMPSettings_DefaultValueNotOverridden(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := []ServerSetting{{
|
||||
Default: "1.0", Current: "9.0", // stale INI value
|
||||
FieldName: "X",
|
||||
Source: "userEngine",
|
||||
Layers: []SettingLayer{{Source: "userEngine", Value: "9.0"}},
|
||||
}}
|
||||
out := overlayAMPSettings(settings, map[string]string{"X": "1.0"})
|
||||
if out[0].Current != "1.0" {
|
||||
t.Fatalf("current = %q, want AMP's authoritative 1.0", out[0].Current)
|
||||
}
|
||||
if out[0].IsOverride {
|
||||
t.Fatal("value == default must not be flagged overridden")
|
||||
}
|
||||
for _, l := range out[0].Layers {
|
||||
if l.Source == ampSettingsSource {
|
||||
t.Fatalf("no amp layer expected when value == default: %+v", out[0].Layers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayAMPSettings_EmptyMapIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := []ServerSetting{{Current: "a", Source: "userGame", FieldName: "X"}}
|
||||
out := overlayAMPSettings(settings, nil)
|
||||
if out[0].Current != "a" || out[0].Source != "userGame" {
|
||||
t.Fatalf("empty amp map must be a no-op: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
// AMP returns "" (null/unset) for fields it has never explicitly stored.
|
||||
// The overlay must not clobber the INI-derived value in that case.
|
||||
func TestOverlayAMPSettings_EmptyAMPValueKeepsINIValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := []ServerSetting{
|
||||
{
|
||||
Default: "True", Current: "True", FieldName: "SandwormEnabled",
|
||||
Source: "userEngine",
|
||||
Layers: []SettingLayer{{Source: "userEngine", Value: "True"}},
|
||||
},
|
||||
{
|
||||
Default: "1.0", Current: "2.0", FieldName: "MiningRate",
|
||||
Source: "userGame",
|
||||
Layers: []SettingLayer{{Source: "userGame", Value: "2.0"}},
|
||||
},
|
||||
}
|
||||
// AMP returns empty string for both — means "not configured in AMP".
|
||||
amp := map[string]string{
|
||||
"SandwormEnabled": "",
|
||||
"MiningRate": "",
|
||||
}
|
||||
out := overlayAMPSettings(settings, amp)
|
||||
|
||||
if out[0].Current != "True" || out[0].Source != "userEngine" || out[0].IsOverride {
|
||||
t.Fatalf("bool: empty AMP value must not override INI value: %+v", out[0])
|
||||
}
|
||||
if out[1].Current != "2.0" || out[1].Source != "userGame" {
|
||||
t.Fatalf("float: empty AMP value must not override INI value: %+v", out[1])
|
||||
}
|
||||
for _, s := range out {
|
||||
for _, l := range s.Layers {
|
||||
if l.Source == ampSettingsSource {
|
||||
t.Fatalf("no amp layer expected when AMP value is empty: %+v", s.Layers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCuratedFieldNamesFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := []ServerSetting{
|
||||
{FieldName: "A"},
|
||||
{FieldName: ""}, // discovered/non-curated
|
||||
{FieldName: "B"},
|
||||
}
|
||||
got := curatedFieldNamesFrom(settings)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 curated field names, got %v", got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, f := range got {
|
||||
seen[f] = true
|
||||
}
|
||||
if !seen["A"] || !seen["B"] {
|
||||
t.Fatalf("missing expected field names: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestFieldNameToSectionKey covers the FieldName shapes the curated schema uses:
|
||||
// ConsoleVariables CVars (single [ConsoleVariables] section, dotted CVar name as
|
||||
// key) and UPROPERTY fields under /Script/... and /DeteriorationSystem...
|
||||
// (class-path section, trailing member as key).
|
||||
func TestFieldNameToSectionKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
fieldName string
|
||||
wantSection string
|
||||
wantKey string
|
||||
}{
|
||||
{"ConsoleVariables.Dune.GlobalMiningOutputMultiplier", "ConsoleVariables", "Dune.GlobalMiningOutputMultiplier"},
|
||||
{"ConsoleVariables.Bgd.ServerDisplayName", "ConsoleVariables", "Bgd.ServerDisplayName"},
|
||||
{"ConsoleVariables.dw.VehicleDurabilityDamageMultiplier", "ConsoleVariables", "dw.VehicleDurabilityDamageMultiplier"},
|
||||
{"/Script/DuneSandbox.BuildingSettings.m_MaxNumLandclaimSegments", "/Script/DuneSandbox.BuildingSettings", "m_MaxNumLandclaimSegments"},
|
||||
{"/Script/DuneSandbox.PvpPveSettings.m_bShouldForceEnablePvpOnAllPartitions", "/Script/DuneSandbox.PvpPveSettings", "m_bShouldForceEnablePvpOnAllPartitions"},
|
||||
{"/DeteriorationSystem.ItemDeteriorationConstants.UpdateRateInSeconds", "/DeteriorationSystem.ItemDeteriorationConstants", "UpdateRateInSeconds"},
|
||||
{"WorldTitle", "WorldTitle", ""}, // ampOnly / no ini target
|
||||
}
|
||||
for _, tt := range tests {
|
||||
gotSec, gotKey := fieldNameToSectionKey(tt.fieldName)
|
||||
if gotSec != tt.wantSection || gotKey != tt.wantKey {
|
||||
t.Errorf("fieldNameToSectionKey(%q) = (%q,%q), want (%q,%q)",
|
||||
tt.fieldName, gotSec, gotKey, tt.wantSection, tt.wantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSettingsSchema_Consistency ensures every curated entry's
|
||||
// (Section,Key) was derived from its FieldName, has the required metadata, and
|
||||
// is unique. ampOnly settings (empty Key) are out of scope for this rework.
|
||||
func TestServerSettingsSchema_Consistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
seenField := map[string]bool{}
|
||||
seenSectionKey := map[string]bool{}
|
||||
for _, d := range serverSettingsSchema {
|
||||
if d.FieldName == "" {
|
||||
t.Errorf("schema entry %q has empty FieldName", d.Label)
|
||||
continue
|
||||
}
|
||||
wantSec, wantKey := fieldNameToSectionKey(d.FieldName)
|
||||
if d.Section != wantSec || d.Key != wantKey {
|
||||
t.Errorf("%s: (Section,Key) = (%q,%q), want (%q,%q) derived from FieldName",
|
||||
d.FieldName, d.Section, d.Key, wantSec, wantKey)
|
||||
}
|
||||
if d.Key == "" {
|
||||
t.Errorf("%s: empty Key — ampOnly settings are not in the curated ini schema", d.FieldName)
|
||||
}
|
||||
if d.Label == "" || d.Category == "" {
|
||||
t.Errorf("%s: missing Label or Category", d.FieldName)
|
||||
}
|
||||
if seenField[d.FieldName] {
|
||||
t.Errorf("duplicate FieldName %q", d.FieldName)
|
||||
}
|
||||
seenField[d.FieldName] = true
|
||||
sk := d.Section + "|" + d.Key
|
||||
if seenSectionKey[sk] {
|
||||
t.Errorf("duplicate Section|Key %q", sk)
|
||||
}
|
||||
seenSectionKey[sk] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSettingsSchema_NoFictionalKeys guards against regressing to the
|
||||
// fictional m_Global*Multiplier keys that #122 proved are no-ops — they are
|
||||
// absent from the real DefaultGame.ini, so writing them does nothing.
|
||||
func TestServerSettingsSchema_NoFictionalKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
fictional := map[string]bool{
|
||||
"m_GlobalHealthMultiplier": true, "m_GlobalDamageToNpcsMultiplier": true,
|
||||
"m_GlobalDamageToPlayersMultiplier": true, "m_GlobalXPMultiplier": true,
|
||||
"m_GlobalProgressionSpeedMultiplier": true, "m_GlobalFameMultiplier": true,
|
||||
"m_GlobalHarvestAmountMultiplier": true, "m_GlobalHarvestHealthMultiplier": true,
|
||||
"m_GlobalBuildingDamageMultiplier": true, "m_InventoryWeightMultiplier": true,
|
||||
}
|
||||
for _, d := range serverSettingsSchema {
|
||||
if fictional[d.Key] {
|
||||
t.Errorf("fictional no-op key %q must not be in the curated schema (FieldName %q)", d.Key, d.FieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerSettingsSchema_HasProvenMiningCVar locks in the #122 fix: the real
|
||||
// mining-output CVar is present and decomposes to the correct INI target.
|
||||
func TestServerSettingsSchema_HasProvenMiningCVar(t *testing.T) {
|
||||
t.Parallel()
|
||||
var found *settingDef
|
||||
for i := range serverSettingsSchema {
|
||||
if serverSettingsSchema[i].FieldName == "ConsoleVariables.Dune.GlobalMiningOutputMultiplier" {
|
||||
found = &serverSettingsSchema[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("proven mining-output CVar missing from schema")
|
||||
}
|
||||
if found.Section != "ConsoleVariables" || found.Key != "Dune.GlobalMiningOutputMultiplier" {
|
||||
t.Errorf("mining CVar decomposed to (%q,%q), want (ConsoleVariables, Dune.GlobalMiningOutputMultiplier)",
|
||||
found.Section, found.Key)
|
||||
}
|
||||
if found.Type != settingFloat {
|
||||
t.Errorf("mining CVar type = %q, want float", found.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitServerSettingsUpdatesByFile_ConsoleVariablesAlwaysEngine verifies CVar
|
||||
// updates route to UserEngine.ini even when DefaultEngine.ini is unreadable
|
||||
// (empty map): [ConsoleVariables] is engine-scoped regardless of what the
|
||||
// default files happen to declare.
|
||||
func TestSplitServerSettingsUpdatesByFile_ConsoleVariablesAlwaysEngine(t *testing.T) {
|
||||
t.Parallel()
|
||||
emptyDefaultEngine := map[string]map[string]string{}
|
||||
updates := map[string]map[string]string{
|
||||
"ConsoleVariables": {"Dune.GlobalMiningOutputMultiplier": "3.0"},
|
||||
secBuilding: {"m_MaxNumLandclaimSegments": "6"},
|
||||
}
|
||||
game, engine := splitServerSettingsUpdatesByFile(emptyDefaultEngine, updates)
|
||||
if _, ok := engine["ConsoleVariables"]; !ok {
|
||||
t.Error("ConsoleVariables must route to engine even with empty DefaultEngine.ini")
|
||||
}
|
||||
if _, ok := game[secBuilding]; !ok {
|
||||
t.Error("UPROPERTY section should route to game updates")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStripEmptySections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "removes section with only blank body",
|
||||
content: "[Keep]\nKey=1\n\n[Remove]\n\n\n[AlsoKeep]\n;comment\n",
|
||||
want: "[Keep]\nKey=1\n\n[AlsoKeep]\n;comment\n",
|
||||
},
|
||||
{
|
||||
name: "preserves sections with comments or values",
|
||||
content: "[Commented]\n; docs\n\n[Valued]\n+Array=1\n\n[Blank]\n\n",
|
||||
want: "[Commented]\n; docs\n\n[Valued]\n+Array=1\n",
|
||||
},
|
||||
{
|
||||
name: "keeps non-section preface lines",
|
||||
content: "; header\n\n[Remove]\n\n[Keep]\nValue=1\n",
|
||||
want: "; header\n\n[Keep]\nValue=1\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := stripEmptySections(tt.content); got != tt.want {
|
||||
t.Fatalf("unexpected output\nwant:\n%q\ngot:\n%q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStripKeysFromContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `[Sec]
|
||||
Foo=1
|
||||
+Foo=2
|
||||
-Foo=3
|
||||
Bar=9
|
||||
[Other]
|
||||
Foo=4
|
||||
`
|
||||
|
||||
owned := map[string]map[string]bool{
|
||||
"Sec": {"Foo": true},
|
||||
}
|
||||
got := stripKeysFromContent(content, owned)
|
||||
want := `[Sec]
|
||||
Bar=9
|
||||
[Other]
|
||||
Foo=4
|
||||
`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected stripped content\nwant:\n%q\ngot:\n%q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripKeysFromContent_PrefixedOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `[Sec]
|
||||
Foo=1
|
||||
+Foo=2
|
||||
-Foo=3
|
||||
`
|
||||
|
||||
owned := map[string]map[string]bool{
|
||||
"Sec": {"+Foo": true},
|
||||
}
|
||||
got := stripKeysFromContent(content, owned)
|
||||
want := `[Sec]
|
||||
Foo=1
|
||||
-Foo=3
|
||||
`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected prefixed strip result\nwant:\n%q\ngot:\n%q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripKeysFromContent_PreservesCommentsAndInvalidLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := `[Sec]
|
||||
; comment
|
||||
# hash comment
|
||||
NoEquals
|
||||
Owned=1
|
||||
`
|
||||
|
||||
owned := map[string]map[string]bool{
|
||||
"Sec": {"Owned": true},
|
||||
}
|
||||
got := stripKeysFromContent(content, owned)
|
||||
want := `[Sec]
|
||||
; comment
|
||||
# hash comment
|
||||
NoEquals
|
||||
`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected comment preservation result\nwant:\n%q\ngot:\n%q", want, got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user