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:
31
docs/reference-repos/icehunter/.air.toml
Normal file
31
docs/reference-repos/icehunter/.air.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/dune-admin ./cmd/dune-admin"
|
||||
bin = "./tmp/dune-admin"
|
||||
full_bin = "./tmp/dune-admin"
|
||||
include_ext = ["go"]
|
||||
exclude_dir = ["web", "bin", "tmp", "db-routines", "db-snapshots", "docs", "deploy"]
|
||||
exclude_file = ["*_test.go"]
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
delay = 1000
|
||||
kill_delay = "0s"
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
log = "build-errors.log"
|
||||
|
||||
[log]
|
||||
time = false
|
||||
main_only = true
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
9
docs/reference-repos/icehunter/.dockerignore
Normal file
9
docs/reference-repos/icehunter/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
web/node_modules
|
||||
web/dist
|
||||
dune-admin
|
||||
dune-admin-linux
|
||||
db-snapshots
|
||||
.env
|
||||
sshKey
|
||||
sshKey.pub
|
||||
*.log
|
||||
23
docs/reference-repos/icehunter/.env.example
Normal file
23
docs/reference-repos/icehunter/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# dune-admin configuration
|
||||
# Copy to .env and run: go run . -setup (or: ./dune-admin -setup)
|
||||
# The setup wizard fills this in automatically.
|
||||
|
||||
# Connection mode: 'direct' (no SSH, connect to DB directly) or 'ssh' (tunnel through VM)
|
||||
CONNECTION_MODE=direct
|
||||
|
||||
# ── Direct mode settings ──────────────────────────────────────────────────────
|
||||
# Used when CONNECTION_MODE=direct — connect to PostgreSQL directly.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=15432
|
||||
DB_USER=dune
|
||||
DB_PASS=dune
|
||||
DB_NAME=dune
|
||||
DB_SCHEMA=dune
|
||||
|
||||
# ── SSH mode settings (optional — only needed for CONNECTION_MODE=ssh) ────────
|
||||
# SSH_HOST=192.168.0.72:22
|
||||
# SSH_USER=dune
|
||||
# SSH_KEY=./sshKey
|
||||
|
||||
SCRIP_CURRENCY=1
|
||||
LISTEN_ADDR=:9090
|
||||
15
docs/reference-repos/icehunter/.gitattributes
vendored
Normal file
15
docs/reference-repos/icehunter/.gitattributes
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Force LF line endings for files where line endings matter at runtime or for
|
||||
# tooling correctness. Without this, contributors who clone on Windows with
|
||||
# the default `core.autocrlf=true` get CRLF in the working tree, which makes
|
||||
# `gofmt -l .` (and therefore `make fmt-check` / the pre-commit hook) report
|
||||
# every Go file as unformatted on first checkout.
|
||||
|
||||
*.go text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
Makefile text eol=lf
|
||||
.githooks/* text eol=lf
|
||||
|
||||
# Treat lockfiles as binary so diffs don't churn on encoding changes.
|
||||
go.sum -text
|
||||
web/pnpm-lock.yaml -text
|
||||
145
docs/reference-repos/icehunter/.githooks/pre-commit
Executable file
145
docs/reference-repos/icehunter/.githooks/pre-commit
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook: auto-fix formatting and fast checks
|
||||
# Skip with: git commit --no-verify
|
||||
#
|
||||
# This hook is contextual - it only runs checks for file types that are staged.
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Detect which file types are staged
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
HAS_GO_FILES=$(echo "$STAGED_FILES" | grep -E '\.go$' || true)
|
||||
HAS_MD_FILES=$(echo "$STAGED_FILES" | grep -E '\.md$' || true)
|
||||
HAS_TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
|
||||
|
||||
# Exit early if no relevant files are staged
|
||||
if [ -z "$HAS_GO_FILES" ] && [ -z "$HAS_MD_FILES" ] && [ -z "$HAS_TS_FILES" ]; then
|
||||
echo -e "${GREEN}✓${NC} No Go, Markdown, or TypeScript files staged, skipping checks"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Header
|
||||
echo ""
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║${NC} ${BOLD}PRE-COMMIT CHECKS${NC} ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Show what's being checked
|
||||
if [ -n "$HAS_GO_FILES" ]; then
|
||||
echo -e "${YELLOW}Go files staged:${NC} $(echo "$HAS_GO_FILES" | wc -l | tr -d ' ') file(s)"
|
||||
fi
|
||||
if [ -n "$HAS_MD_FILES" ]; then
|
||||
echo -e "${YELLOW}Markdown files staged:${NC} $(echo "$HAS_MD_FILES" | wc -l | tr -d ' ') file(s)"
|
||||
fi
|
||||
if [ -n "$HAS_TS_FILES" ]; then
|
||||
echo -e "${YELLOW}TypeScript files staged:${NC} $(echo "$HAS_TS_FILES" | wc -l | tr -d ' ') file(s)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Track if any check fails
|
||||
FAILED=0
|
||||
|
||||
# Go checks (only if .go files are staged)
|
||||
if [ -n "$HAS_GO_FILES" ]; then
|
||||
# 1. Auto-format Go code
|
||||
echo -e "${YELLOW}▶${NC} Auto-formatting Go code..."
|
||||
if make fmt > /tmp/hook-output.txt 2>&1; then
|
||||
# Check if any files were modified
|
||||
if git diff --name-only | grep -q "\.go$"; then
|
||||
echo -e "${GREEN}✓${NC} Go code formatted (changes staged)"
|
||||
git add -u # Stage modified tracked files
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} Go code already formatted"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} Go formatting failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 2. Run go vet
|
||||
echo -e "${YELLOW}▶${NC} Running go vet (static analysis)..."
|
||||
if make vet > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} go vet passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} go vet failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 3. Run golangci-lint
|
||||
echo -e "${YELLOW}▶${NC} Running golangci-lint..."
|
||||
if make lint-go > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} golangci-lint passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} golangci-lint failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 4. Run gosec (high-severity security scan) — sprung here so findings
|
||||
# surface at commit time, not only at pre-push.
|
||||
echo -e "${YELLOW}▶${NC} Running gosec (security scan)..."
|
||||
if make gosec > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} gosec passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} gosec failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Markdown checks (only if .md files are staged)
|
||||
if [ -n "$HAS_MD_FILES" ]; then
|
||||
echo -e "${YELLOW}▶${NC} Running markdownlint..."
|
||||
if make lint-md > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} markdownlint passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} markdownlint failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# TypeScript checks (only if .ts/.tsx files are staged)
|
||||
if [ -n "$HAS_TS_FILES" ]; then
|
||||
echo -e "${YELLOW}▶${NC} Running ESLint (TypeScript)..."
|
||||
if (cd web && pnpm lint) > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} ESLint passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} ESLint failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}▶${NC} Running TypeScript type check (tsc --noEmit)..."
|
||||
if make tsc > /tmp/hook-output.txt 2>&1; then
|
||||
echo -e "${GREEN}✓${NC} TypeScript type check passed"
|
||||
else
|
||||
echo -e "${RED}✗${NC} TypeScript type check failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${BLUE}║${NC} ${GREEN}${BOLD}✓ ALL CHECKS PASSED!${NC} ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${BLUE}║${NC} ${RED}${BOLD}✗ CHECKS FAILED${NC} ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "${YELLOW}Fix the issues above or use --no-verify to skip${NC}"
|
||||
exit 1
|
||||
fi
|
||||
206
docs/reference-repos/icehunter/.githooks/pre-push
Executable file
206
docs/reference-repos/icehunter/.githooks/pre-push
Executable file
@@ -0,0 +1,206 @@
|
||||
#!/bin/bash
|
||||
# Pre-push hook: full verification including security checks
|
||||
# Skip with: git push --no-verify
|
||||
#
|
||||
# This hook is contextual - it only runs checks for file types in the commits being pushed.
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get the commits being pushed
|
||||
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
|
||||
UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$UPSTREAM" ]; then
|
||||
# No upstream, compare against main/master
|
||||
BASE_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@' || echo "main")
|
||||
CHANGED_FILES=$(git diff --name-only "origin/${BASE_BRANCH}...HEAD" 2>/dev/null || git diff --name-only HEAD~10..HEAD 2>/dev/null || echo "")
|
||||
else
|
||||
# Compare against upstream
|
||||
CHANGED_FILES=$(git diff --name-only @{u}..HEAD 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# Detect which file types are in the commits being pushed
|
||||
HAS_GO_FILES=$(echo "$CHANGED_FILES" | grep -E '\.go$' || true)
|
||||
HAS_MD_FILES=$(echo "$CHANGED_FILES" | grep -E '\.md$' || true)
|
||||
HAS_TS_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx)$' || true)
|
||||
|
||||
# Exit early if no relevant files in the push
|
||||
if [ -z "$HAS_GO_FILES" ] && [ -z "$HAS_MD_FILES" ] && [ -z "$HAS_TS_FILES" ]; then
|
||||
echo -e "${GREEN}✓${NC} No Go, Markdown, or TypeScript files in push, skipping checks"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Header
|
||||
echo ""
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║${NC} ${BOLD}PRE-PUSH VERIFICATION${NC} ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Show what's being pushed
|
||||
echo -e "${YELLOW}Branch:${NC} ${CURRENT_BRANCH}"
|
||||
echo -e "${YELLOW}Commits:${NC}"
|
||||
if [ -n "$UPSTREAM" ]; then
|
||||
git log --oneline @{u}.. 2>/dev/null | head -5 | sed 's/^/ /' || echo " (new branch)"
|
||||
else
|
||||
echo " (new branch)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Show what's being checked
|
||||
if [ -n "$HAS_GO_FILES" ]; then
|
||||
GO_COUNT=$(echo "$HAS_GO_FILES" | wc -l | tr -d ' ')
|
||||
echo -e "${YELLOW}Go files changed:${NC} ${GO_COUNT} file(s)"
|
||||
fi
|
||||
if [ -n "$HAS_MD_FILES" ]; then
|
||||
MD_COUNT=$(echo "$HAS_MD_FILES" | wc -l | tr -d ' ')
|
||||
echo -e "${YELLOW}Markdown files changed:${NC} ${MD_COUNT} file(s)"
|
||||
fi
|
||||
if [ -n "$HAS_TS_FILES" ]; then
|
||||
TS_COUNT=$(echo "$HAS_TS_FILES" | wc -l | tr -d ' ')
|
||||
echo -e "${YELLOW}TypeScript files changed:${NC} ${TS_COUNT} file(s)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Track if any check fails
|
||||
FAILED=0
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# Count total checks to run
|
||||
TOTAL_CHECKS=0
|
||||
if [ -n "$HAS_GO_FILES" ]; then
|
||||
TOTAL_CHECKS=$((TOTAL_CHECKS + 6)) # fmt-check, vet, lint-go, gosec, vulncheck, test-race
|
||||
fi
|
||||
if [ -n "$HAS_MD_FILES" ]; then
|
||||
TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) # markdownlint
|
||||
fi
|
||||
if [ -n "$HAS_TS_FILES" ]; then
|
||||
TOTAL_CHECKS=$((TOTAL_CHECKS + 2)) # eslint + tsc
|
||||
fi
|
||||
CURRENT_CHECK=0
|
||||
|
||||
# Helper function to run a check with timing
|
||||
run_check() {
|
||||
local name=$1
|
||||
local command=$2
|
||||
local start=$(date +%s)
|
||||
CURRENT_CHECK=$((CURRENT_CHECK + 1))
|
||||
|
||||
echo -e "${CYAN}[${CURRENT_CHECK}/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} ${name}..."
|
||||
if eval "$command" > /tmp/hook-output.txt 2>&1; then
|
||||
local duration=$(($(date +%s) - start))
|
||||
echo -e " ${GREEN}✓${NC} ${name} passed ${CYAN}(${duration}s)${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} ${name} failed"
|
||||
echo ""
|
||||
cat /tmp/hook-output.txt
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Go checks (only if .go files are in the push)
|
||||
if [ -n "$HAS_GO_FILES" ]; then
|
||||
# 1. Format check
|
||||
run_check "Checking Go code formatting" "make fmt-check" || FAILED=1
|
||||
|
||||
# 2. Static analysis (go vet)
|
||||
run_check "Running static analysis (go vet)" "make vet" || FAILED=1
|
||||
|
||||
# 3. Go linting
|
||||
run_check "Running golangci-lint" "make lint-go" || FAILED=1
|
||||
|
||||
# 4. Security scan (gosec)
|
||||
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Running security scan (gosec)..."
|
||||
CURRENT_CHECK=$((CURRENT_CHECK + 1))
|
||||
START=$(date +%s)
|
||||
if make gosec > /tmp/hook-output.txt 2>&1; then
|
||||
DURATION=$(($(date +%s) - START))
|
||||
ISSUES=$(grep -oE "Issues : [0-9]+" /tmp/hook-output.txt | grep -oE "[0-9]+" || echo "0")
|
||||
NOSEC=$(grep -oE "Nosec : [0-9]+" /tmp/hook-output.txt | grep -oE "[0-9]+" || echo "0")
|
||||
echo -e " ${GREEN}✓${NC} Security scan passed - ${ISSUES} issues, ${NOSEC} suppressed ${CYAN}(${DURATION}s)${NC}"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Security scan failed"
|
||||
echo ""
|
||||
cat /tmp/hook-output.txt
|
||||
echo ""
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 5. Vulnerability check (govulncheck)
|
||||
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Checking for vulnerabilities (govulncheck)..."
|
||||
CURRENT_CHECK=$((CURRENT_CHECK + 1))
|
||||
START=$(date +%s)
|
||||
if make vulncheck > /tmp/hook-output.txt 2>&1; then
|
||||
DURATION=$(($(date +%s) - START))
|
||||
if grep -q "No vulnerabilities found" /tmp/hook-output.txt; then
|
||||
echo -e " ${GREEN}✓${NC} No vulnerabilities found ${CYAN}(${DURATION}s)${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Vulnerability check completed ${CYAN}(${DURATION}s)${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Vulnerability check failed"
|
||||
echo ""
|
||||
cat /tmp/hook-output.txt
|
||||
echo ""
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 6. Tests with race detector and coverage
|
||||
echo -e "${CYAN}[$((CURRENT_CHECK + 1))/${TOTAL_CHECKS}]${NC} ${YELLOW}▶${NC} Running tests with race detector..."
|
||||
CURRENT_CHECK=$((CURRENT_CHECK + 1))
|
||||
START=$(date +%s)
|
||||
if make test-race > /tmp/hook-output.txt 2>&1; then
|
||||
DURATION=$(($(date +%s) - START))
|
||||
PASSED=$(grep -c "^ok" /tmp/hook-output.txt || echo "0")
|
||||
echo -e " ${GREEN}✓${NC} All tests passed (${PASSED} packages) ${CYAN}(${DURATION}s)${NC}"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Tests failed"
|
||||
cat /tmp/hook-output.txt
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Markdown checks (only if .md files are in the push)
|
||||
if [ -n "$HAS_MD_FILES" ]; then
|
||||
run_check "Running markdownlint" "make lint-md" || FAILED=1
|
||||
fi
|
||||
|
||||
# TypeScript checks (only if .ts/.tsx files are in the push)
|
||||
if [ -n "$HAS_TS_FILES" ]; then
|
||||
run_check "Running ESLint (TypeScript)" "(cd web && pnpm lint)" || FAILED=1
|
||||
run_check "TypeScript type check (tsc --noEmit)" "make tsc" || FAILED=1
|
||||
fi
|
||||
|
||||
# Summary
|
||||
TOTAL_TIME=$(($(date +%s) - START_TIME))
|
||||
echo ""
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${BLUE}║${NC} ${GREEN}${BOLD}✓ ALL CHECKS PASSED!${NC} ${BLUE}║${NC}"
|
||||
printf "${BLUE}║${NC} ${CYAN}Total time: %-37s${NC}${BLUE}║${NC}\n" "${TOTAL_TIME}s"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Ready to push!${NC}"
|
||||
echo ""
|
||||
exit 0
|
||||
else
|
||||
echo -e "${BLUE}║${NC} ${RED}${BOLD}✗ CHECKS FAILED${NC} ${BLUE}║${NC}"
|
||||
printf "${BLUE}║${NC} ${CYAN}Total time: %-37s${NC}${BLUE}║${NC}\n" "${TOTAL_TIME}s"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${RED}Fix the issues above before pushing.${NC}"
|
||||
echo -e "${YELLOW}Or skip with: ${NC}git push --no-verify"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
38
docs/reference-repos/icehunter/.github/workflows/deploy.yml
vendored
Normal file
38
docs/reference-repos/icehunter/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
working-directory: web
|
||||
|
||||
- run: pnpm run build
|
||||
working-directory: web
|
||||
env:
|
||||
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
|
||||
VITE_CDN_BASE_URL: ${{ vars.VITE_CDN_BASE_URL }}
|
||||
|
||||
- uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
command: pages deploy dist --project-name=dune-admin
|
||||
workingDirectory: web
|
||||
120
docs/reference-repos/icehunter/.github/workflows/release.yml
vendored
Normal file
120
docs/reference-repos/icehunter/.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sast:
|
||||
name: SAST (gosec)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run gosec
|
||||
run: make gosec
|
||||
|
||||
sca:
|
||||
name: SCA (govulncheck + pnpm audit)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Run govulncheck
|
||||
run: make vulncheck
|
||||
|
||||
- name: Run pnpm audit
|
||||
run: make pnpm-audit
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: macos-latest # macOS so codesign is available for ad-hoc signing
|
||||
needs: [sast, sca]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.4"
|
||||
cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Build frontend (embeds into binary)
|
||||
run: make web
|
||||
|
||||
- name: Install syft (SBOMs)
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
- uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT with `repo` scope on Icehunter/homebrew-tap.
|
||||
# If unset, the brews publisher will fail; configure before
|
||||
# cutting a release that should land in the tap.
|
||||
# PAT with `repo` scope on Icehunter/scoop-bucket.
|
||||
# PAT with `repo` scope and a fork of microsoft/winget-pkgs
|
||||
# at Icehunter/winget-pkgs.
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
deploy-frontend:
|
||||
name: Deploy Frontend to Cloudflare Pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
working-directory: web
|
||||
|
||||
- run: pnpm run build
|
||||
working-directory: web
|
||||
env:
|
||||
VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}
|
||||
VITE_CDN_BASE_URL: ${{ vars.VITE_CDN_BASE_URL }}
|
||||
|
||||
- uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
command: pages deploy dist --project-name=dune-admin
|
||||
workingDirectory: web
|
||||
21
docs/reference-repos/icehunter/.github/workflows/sast.yml
vendored
Normal file
21
docs/reference-repos/icehunter/.github/workflows/sast.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: SAST
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
gosec:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run gosec
|
||||
run: make gosec
|
||||
31
docs/reference-repos/icehunter/.github/workflows/sca.yml
vendored
Normal file
31
docs/reference-repos/icehunter/.github/workflows/sca.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: SCA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run govulncheck
|
||||
run: make vulncheck
|
||||
|
||||
pnpm-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Run pnpm audit
|
||||
run: make pnpm-audit
|
||||
24
docs/reference-repos/icehunter/.github/workflows/test.yml
vendored
Normal file
24
docs/reference-repos/icehunter/.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test -race ./...
|
||||
58
docs/reference-repos/icehunter/.gitignore
vendored
Normal file
58
docs/reference-repos/icehunter/.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
db-snapshots/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
settings.local.json
|
||||
|
||||
# SSH keys — never commit
|
||||
sshKey
|
||||
sshKey.pub
|
||||
|
||||
# Browser fetch script — local dev tool, not for sharing
|
||||
fetch-item-data.js
|
||||
|
||||
# Compiled binaries
|
||||
/bin/
|
||||
/dune-admin
|
||||
/dune-admin-linux
|
||||
/dune-admin.exe
|
||||
*.exe
|
||||
|
||||
# Embedded frontend dist (generated by make web, not committed)
|
||||
/cmd/dune-admin/dist/
|
||||
|
||||
# Tools (downloaded, not part of the project)
|
||||
repak_win/
|
||||
repak_win.zip
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
blueprint.json
|
||||
|
||||
# Local Go build cache (used when system cache is unavailable)
|
||||
.go-cache/
|
||||
.superpowers/
|
||||
|
||||
# docs/ — default-private (local notes, scratch).
|
||||
# Exception: community-facing ADRs, deployment docs, and generated Swagger spec are tracked.
|
||||
docs/*
|
||||
!docs/amp-direct-connect/
|
||||
!docs/adr/
|
||||
!docs/plans/
|
||||
!docs/superpowers/
|
||||
!docs/docs.go
|
||||
!docs/swagger.json
|
||||
!docs/swagger.yaml
|
||||
|
||||
# Local dev/testing artifacts
|
||||
local/
|
||||
deploy/k8s/dune-admin.rendered.yaml
|
||||
|
||||
# Reference material / ideas / scratch — not for sharing
|
||||
Samples-Ideas/
|
||||
tmp/dune-admin
|
||||
.worktrees
|
||||
3
docs/reference-repos/icehunter/.gocognit-ignore
Normal file
3
docs/reference-repos/icehunter/.gocognit-ignore
Normal file
@@ -0,0 +1,3 @@
|
||||
# Functions excluded from the gocognit complexity gate.
|
||||
# Each entry has a GitHub issue tracking the refactor work.
|
||||
# Format: FuncName file:line score issue-url
|
||||
6
docs/reference-repos/icehunter/.golangci.yml
Normal file
6
docs/reference-repos/icehunter/.golangci.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
exclusions:
|
||||
paths:
|
||||
- web/node_modules
|
||||
216
docs/reference-repos/icehunter/.goreleaser.yml
Normal file
216
docs/reference-repos/icehunter/.goreleaser.yml
Normal file
@@ -0,0 +1,216 @@
|
||||
version: 2
|
||||
|
||||
project_name: dune-admin
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- id: dune-admin
|
||||
main: ./cmd/dune-admin
|
||||
binary: dune-admin
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -tags=embed
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.AppVersion={{.Version}}
|
||||
- -X main.GitCommit={{.Commit}}
|
||||
- -X main.BuildTime={{.Date}}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
# Combine darwin amd64 + arm64 into a single fat binary so `brew install`
|
||||
# gets one artifact that runs natively on both Intel and Apple Silicon.
|
||||
universal_binaries:
|
||||
- id: dune-admin-darwin-universal
|
||||
ids:
|
||||
- dune-admin
|
||||
replace: true
|
||||
name_template: dune-admin
|
||||
|
||||
# Ad-hoc sign the macOS universal binary. Free, no Apple Developer
|
||||
# account required. Satisfies the Apple Silicon "must be signed"
|
||||
# requirement; Gatekeeper still won't run a notarization check because
|
||||
# Homebrew's curl-based download doesn't set the quarantine xattr.
|
||||
signs:
|
||||
- id: macos-adhoc
|
||||
ids:
|
||||
- dune-admin-darwin-universal
|
||||
cmd: codesign
|
||||
args:
|
||||
- "-s"
|
||||
- "-"
|
||||
- "--force"
|
||||
- "--preserve-metadata=entitlements,requirements,flags,runtime"
|
||||
- "${artifact}"
|
||||
artifacts: binary
|
||||
output: true
|
||||
|
||||
archives:
|
||||
- id: dune-admin
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "dune-admin_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- item-data.json
|
||||
- quality-data.json
|
||||
- tags-data.json
|
||||
- gameplayTags.json
|
||||
- skillModules.json
|
||||
- vehicles.json
|
||||
- cheatScripts.json
|
||||
- README.md
|
||||
- .env.example
|
||||
|
||||
source:
|
||||
enabled: true
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_source"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
algorithm: sha256
|
||||
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: "^feat"
|
||||
order: 0
|
||||
- title: Bug Fixes
|
||||
regexp: "^fix"
|
||||
order: 1
|
||||
- title: Performance
|
||||
regexp: "^perf"
|
||||
order: 2
|
||||
- title: Other
|
||||
order: 999
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^ci:"
|
||||
- "^chore:"
|
||||
- "Merge pull request"
|
||||
- "Merge branch"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: Icehunter
|
||||
name: dune-admin
|
||||
name_template: "{{.ProjectName}} {{.Tag}}"
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
||||
# Homebrew formula. GoReleaser renamed the `brews:` key to
|
||||
# `homebrew_casks:` in v2.13 but that type generates a Homebrew Cask
|
||||
# (for GUI .app bundles), not a Formula — wrong for a CLI binary.
|
||||
# `brews:` still generates a proper Formula and is the correct key for
|
||||
# this use case. The deprecation warning can be ignored until GoReleaser
|
||||
# provides a first-class Formula key again. Requires RELEASE_TOKEN
|
||||
# secret (PAT with `repo` scope on Icehunter/homebrew-tap). Skipped on
|
||||
# prereleases.
|
||||
brews:
|
||||
- name: dune-admin
|
||||
ids:
|
||||
- dune-admin
|
||||
repository:
|
||||
owner: Icehunter
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
token: "{{ .Env.RELEASE_TOKEN }}"
|
||||
directory: Formula
|
||||
homepage: "https://github.com/Icehunter/dune-admin"
|
||||
description: "Local-first, provider-aware terminal agent (Claude Code wire-compatible)."
|
||||
license: "MIT"
|
||||
skip_upload: auto
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: bot@goreleaser.com
|
||||
commit_msg_template: "brew: bump {{ .ProjectName }} to {{ .Tag }}"
|
||||
test: |
|
||||
system "#{bin}/dune-admin version"
|
||||
install: |
|
||||
bin.install "dune-admin"
|
||||
pkgshare.install "item-data.json"
|
||||
pkgshare.install "quality-data.json"
|
||||
pkgshare.install "tags-data.json"
|
||||
pkgshare.install "gameplayTags.json"
|
||||
pkgshare.install "skillModules.json"
|
||||
pkgshare.install "vehicles.json"
|
||||
pkgshare.install "cheatScripts.json"
|
||||
|
||||
# Scoop bucket. Requires RELEASE_TOKEN secret (PAT with `repo` scope on
|
||||
# Icehunter/scoop-bucket). Windows-only; goreleaser uses the windows
|
||||
# zip archive.
|
||||
scoops:
|
||||
- name: dune-admin
|
||||
ids:
|
||||
- dune-admin
|
||||
repository:
|
||||
owner: Icehunter
|
||||
name: scoop-bucket
|
||||
branch: main
|
||||
token: "{{ .Env.RELEASE_TOKEN }}"
|
||||
directory: bucket
|
||||
homepage: "https://github.com/Icehunter/dune-admin"
|
||||
description: "Local-first, provider-aware terminal agent (Claude Code wire-compatible)."
|
||||
license: "MIT"
|
||||
skip_upload: auto
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: bot@goreleaser.com
|
||||
commit_msg_template: "scoop: bump {{ .ProjectName }} to {{ .Tag }}"
|
||||
|
||||
# winget manifest. Requires RELEASE_TOKEN secret (PAT with `repo` scope
|
||||
# and a fork of microsoft/winget-pkgs at Icehunter/winget-pkgs).
|
||||
# GoReleaser commits the manifest to your fork on the branch
|
||||
# `dune-admin-{version}` and you (or automation) open the PR upstream.
|
||||
winget:
|
||||
- name: dune-admin
|
||||
ids:
|
||||
- dune-admin
|
||||
publisher: Icehunter
|
||||
publisher_url: "https://github.com/Icehunter"
|
||||
publisher_support_url: "https://github.com/Icehunter/dune-admin/issues"
|
||||
package_identifier: Icehunter.dune-admin
|
||||
short_description: "Local-first, provider-aware terminal agent."
|
||||
description: "dune-admin is a local-first, provider-aware terminal agent that maintains Claude Code wire compatibility while innovating on behavior and architecture."
|
||||
license: "MIT"
|
||||
license_url: "https://github.com/Icehunter/dune-admin/blob/main/LICENSE"
|
||||
homepage: "https://github.com/Icehunter/dune-admin"
|
||||
skip_upload: auto
|
||||
repository:
|
||||
owner: Icehunter
|
||||
name: winget-pkgs
|
||||
branch: "dune-admin-{{.Version}}"
|
||||
token: "{{ .Env.RELEASE_TOKEN }}"
|
||||
pull_request:
|
||||
enabled: true
|
||||
draft: false
|
||||
base:
|
||||
owner: microsoft
|
||||
name: winget-pkgs
|
||||
branch: master
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: bot@goreleaser.com
|
||||
commit_msg_template: "New version: {{ .ProjectName }} {{ .Version }}"
|
||||
13
docs/reference-repos/icehunter/.markdownlint-cli2.yaml
Normal file
13
docs/reference-repos/icehunter/.markdownlint-cli2.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
ignores:
|
||||
- "web/node_modules/**"
|
||||
- "node_modules/**"
|
||||
|
||||
config:
|
||||
MD013: false
|
||||
MD024: false
|
||||
MD033: false
|
||||
MD036: false
|
||||
MD040: false
|
||||
MD051: false
|
||||
MD056: false
|
||||
MD060: false
|
||||
23
docs/reference-repos/icehunter/LICENSE
Normal file
23
docs/reference-repos/icehunter/LICENSE
Normal file
@@ -0,0 +1,23 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ryan Wilson
|
||||
|
||||
---
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
283
docs/reference-repos/icehunter/Makefile
Normal file
283
docs/reference-repos/icehunter/Makefile
Normal file
@@ -0,0 +1,283 @@
|
||||
.PHONY: build web go go-embed linux dev dev-server dev-backend dev-web setup deploy-web \
|
||||
render-k8s render-k8s-stdout k8s-dry-run \
|
||||
vulncheck gosec pnpm-audit \
|
||||
test test-race vet fmt fmt-check tsc \
|
||||
tools docs verify \
|
||||
version version-patch version-minor version-major
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
CMD := ./cmd/dune-admin
|
||||
PKG := ./...
|
||||
GO := go
|
||||
PREFIX ?= /usr/local
|
||||
COGNIT_TARGET := $(if $(wildcard cmd/dune-admin),./cmd/dune-admin,.)
|
||||
|
||||
# On Windows, Make defaults to cmd.exe which can't run POSIX recipes.
|
||||
# Force bash from Git for Windows so all targets work from any terminal.
|
||||
ifeq ($(OS),Windows_NT)
|
||||
SHELL := cmd.exe
|
||||
.SHELLFLAGS := /C
|
||||
BIN := bin/dune-admin.exe
|
||||
LOCAL_BIN := dune-admin.exe
|
||||
else
|
||||
BIN := bin/dune-admin
|
||||
LOCAL_BIN := dune-admin
|
||||
endif
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
VERSION ?= $(shell type VERSION 2>NUL || echo dev)
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>NUL || echo unknown)
|
||||
BUILD_TIME ?= $(shell powershell -NoProfile -Command "[DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')")
|
||||
else
|
||||
VERSION ?= $(shell cat VERSION 2>/dev/null || git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
endif
|
||||
LDFLAGS := -ldflags "-s -w -X main.AppVersion=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)"
|
||||
|
||||
# Build frontend + backend binary with embedded SPA.
|
||||
build: web go-embed
|
||||
|
||||
# Build backend binary only (no embedded frontend).
|
||||
go:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@if not exist bin mkdir bin
|
||||
$(GO) build -trimpath $(LDFLAGS) -o $(BIN) $(CMD)
|
||||
@copy /Y "bin\dune-admin.exe" "$(LOCAL_BIN)" >NUL
|
||||
else
|
||||
@mkdir -p bin
|
||||
$(GO) build -trimpath $(LDFLAGS) -o $(BIN) $(CMD)
|
||||
install -m 0755 $(BIN) ./$(LOCAL_BIN)
|
||||
endif
|
||||
|
||||
# Build backend binary with embedded frontend (requires make web first).
|
||||
go-embed:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@if not exist bin mkdir bin
|
||||
$(GO) build -trimpath $(LDFLAGS) -tags embed -o $(BIN) $(CMD)
|
||||
@copy /Y "bin\dune-admin.exe" "$(LOCAL_BIN)" >NUL
|
||||
else
|
||||
@mkdir -p bin
|
||||
$(GO) build -trimpath $(LDFLAGS) -tags embed -o $(BIN) $(CMD)
|
||||
install -m 0755 $(BIN) ./dune-admin
|
||||
endif
|
||||
|
||||
# Install the binary system-wide (Linux/macOS only).
|
||||
install: go
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@echo "Use 'make go' to build. Copy $(BIN) to your desired location manually."
|
||||
else
|
||||
install -d $(DESTDIR)$(PREFIX)/bin
|
||||
install -m 0755 $(BIN) $(DESTDIR)$(PREFIX)/bin/dune-admin
|
||||
endif
|
||||
|
||||
linux:
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -trimpath $(LDFLAGS) -o dune-admin-linux $(CMD)
|
||||
|
||||
dev-server:
|
||||
go run $(CMD)
|
||||
|
||||
dev-backend:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
go tool github.com/air-verse/air -build.cmd "go build -o ./tmp/dune-admin.exe ./cmd/dune-admin" -build.bin "./tmp/dune-admin.exe" -build.full_bin "./tmp/dune-admin.exe"
|
||||
else
|
||||
go tool github.com/air-verse/air
|
||||
endif
|
||||
|
||||
dev-web:
|
||||
cd web && pnpm dev
|
||||
|
||||
dev:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@start "dune-admin-backend" /MIN $(MAKE) dev-backend
|
||||
-@cd web && node node_modules\vite\bin\vite.js
|
||||
-@taskkill /F /FI "WINDOWTITLE eq dune-admin-backend*" >NUL 2>&1
|
||||
-@taskkill /F /IM dune-admin.exe >NUL 2>&1
|
||||
else
|
||||
@set -e; \
|
||||
AIR_PID=; VITE_PID=; \
|
||||
cleanup() { \
|
||||
trap - EXIT INT TERM; \
|
||||
[ -n "$$AIR_PID" ] && kill $$AIR_PID 2>/dev/null || true; \
|
||||
[ -n "$$VITE_PID" ] && kill $$VITE_PID 2>/dev/null || true; \
|
||||
[ -n "$$AIR_PID" ] && wait $$AIR_PID 2>/dev/null || true; \
|
||||
[ -n "$$VITE_PID" ] && wait $$VITE_PID 2>/dev/null || true; \
|
||||
}; \
|
||||
trap 'cleanup' EXIT INT TERM; \
|
||||
$(MAKE) dev-backend & AIR_PID=$$!; \
|
||||
$(MAKE) dev-web & VITE_PID=$$!; \
|
||||
set +e; \
|
||||
while kill -0 $$AIR_PID 2>/dev/null && kill -0 $$VITE_PID 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if ! kill -0 $$AIR_PID 2>/dev/null; then \
|
||||
wait $$AIR_PID; status=$$?; \
|
||||
kill $$VITE_PID 2>/dev/null || true; \
|
||||
wait $$VITE_PID 2>/dev/null || true; \
|
||||
else \
|
||||
wait $$VITE_PID; status=$$?; \
|
||||
kill $$AIR_PID 2>/dev/null || true; \
|
||||
wait $$AIR_PID 2>/dev/null || true; \
|
||||
fi; \
|
||||
exit $$status
|
||||
endif
|
||||
|
||||
setup:
|
||||
go run $(CMD) -setup
|
||||
|
||||
# ── Web ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
web:
|
||||
cd web && pnpm install --frozen-lockfile && pnpm build
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@if exist cmd\dune-admin\dist rmdir /S /Q cmd\dune-admin\dist
|
||||
@xcopy /E /I /Q web\dist cmd\dune-admin\dist >NUL
|
||||
else
|
||||
rm -rf cmd/dune-admin/dist
|
||||
cp -r web/dist cmd/dune-admin/dist
|
||||
endif
|
||||
|
||||
deploy-web:
|
||||
cd web && pnpm install --frozen-lockfile && pnpm build && wrangler pages deploy dist --project-name dune-admin
|
||||
|
||||
render-k8s:
|
||||
go run $(CMD) -render-k8s deploy/k8s/dune-admin.rendered.yaml
|
||||
|
||||
render-k8s-stdout:
|
||||
go run $(CMD) -render-k8s -
|
||||
|
||||
k8s-dry-run:
|
||||
@$(MAKE) render-k8s-stdout | kubectl apply --dry-run=client -f -
|
||||
|
||||
# ── Test ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
test:
|
||||
go test $(PKG)
|
||||
|
||||
test-race:
|
||||
go test -race $(PKG)
|
||||
|
||||
# ── Quality ───────────────────────────────────────────────────────────────────
|
||||
|
||||
vet:
|
||||
go vet $(PKG)
|
||||
|
||||
fmt:
|
||||
go fmt $(PKG)
|
||||
gofmt -s -w .
|
||||
|
||||
fmt-check:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@powershell -NoProfile -Command "if (gofmt -l .) { Write-Host 'Code is not formatted. Run make fmt'; exit 1 }"
|
||||
else
|
||||
@test -z "$$(gofmt -l .)" || (echo "Code is not formatted. Run 'make fmt'" && exit 1)
|
||||
endif
|
||||
|
||||
vulncheck:
|
||||
go tool golang.org/x/vuln/cmd/govulncheck $(PKG)
|
||||
|
||||
gocognit:
|
||||
@echo "Running code complexity analysis with gocognit..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -over 15 -ignore "_test|node_modules" $(COGNIT_TARGET) \
|
||||
> %TEMP%\gocognit-out.txt 2>&1 || (exit /b 0)
|
||||
@powershell -NoProfile -Command "\
|
||||
$$ignore = (Get-Content .gocognit-ignore | Where-Object { $$_ -notmatch '^\s*#' -and $$_.Trim() } | ForEach-Object { ($$_ -split '\s+')[0] }); \
|
||||
$$lines = Get-Content $$env:TEMP\gocognit-out.txt -ErrorAction SilentlyContinue | Where-Object { $$line = $$_; -not ($$ignore | Where-Object { $$line -like \"*$$_*\" }) }; \
|
||||
if ($$lines) { $$lines | Write-Host; exit 1 }"
|
||||
else
|
||||
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -over 15 -ignore "_test|node_modules" $(COGNIT_TARGET) \
|
||||
> /tmp/gocognit-out.txt 2>&1 || true; \
|
||||
grep -v '^#' .gocognit-ignore | awk '{print $$1}' > /tmp/gocognit-ignore.txt; \
|
||||
grep -v -F -f /tmp/gocognit-ignore.txt /tmp/gocognit-out.txt > /tmp/gocognit-new.txt || true; \
|
||||
if [ -s /tmp/gocognit-new.txt ]; then cat /tmp/gocognit-new.txt; exit 1; fi
|
||||
endif
|
||||
|
||||
gosec:
|
||||
go tool github.com/securego/gosec/v2/cmd/gosec -severity high -confidence high $(PKG)
|
||||
|
||||
pnpm-audit:
|
||||
cd web && pnpm audit --audit-level=high
|
||||
|
||||
tsc:
|
||||
cd web && pnpm typecheck
|
||||
|
||||
lint:
|
||||
@$(MAKE) lint-go
|
||||
@$(MAKE) lint-md
|
||||
|
||||
lint-go:
|
||||
@$(GO) tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint run
|
||||
|
||||
lint-md:
|
||||
@npx -y markdownlint-cli2 --fix "**/*.md"
|
||||
|
||||
verify:
|
||||
@$(MAKE) fmt-check
|
||||
@$(MAKE) vet
|
||||
@$(MAKE) test-race
|
||||
@$(MAKE) vulncheck
|
||||
@$(MAKE) lint
|
||||
@$(MAKE) gocognit
|
||||
@echo "All verification checks passed!"
|
||||
|
||||
# ── Docs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
docs:
|
||||
$(GO) tool github.com/swaggo/swag/cmd/swag init -g cmd/dune-admin/main.go -o docs
|
||||
|
||||
# ── Tools ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
tools:
|
||||
@echo "Caching dev tools (versions pinned in go.mod)..."
|
||||
@$(GO) tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint --version || true
|
||||
@$(GO) tool github.com/air-verse/air -v || true
|
||||
@$(GO) tool golang.org/x/vuln/cmd/govulncheck -version || true
|
||||
@$(GO) tool github.com/uudashr/gocognit/cmd/gocognit -version || true
|
||||
@$(GO) tool github.com/securego/gosec/v2/cmd/gosec --version || true
|
||||
@echo "Done!"
|
||||
|
||||
# Print current version.
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
|
||||
# Setup git hooks
|
||||
hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "Git hooks configured!"
|
||||
|
||||
# Bump patch version (1.0.0 → 1.0.1), commit, tag, and push — triggers release workflow.
|
||||
version-patch:
|
||||
@V=$$(cat VERSION); \
|
||||
MAJOR=$$(echo $$V | cut -d. -f1); \
|
||||
MINOR=$$(echo $$V | cut -d. -f2); \
|
||||
PATCH=$$(echo $$V | cut -d. -f3); \
|
||||
NEW="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
|
||||
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
|
||||
echo $$NEW > VERSION; \
|
||||
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
|
||||
git push && git push origin "v$$NEW"; \
|
||||
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"
|
||||
|
||||
# Bump minor version (1.0.0 → 1.1.0), commit, tag, and push — triggers release workflow.
|
||||
version-minor:
|
||||
@V=$$(cat VERSION); \
|
||||
MAJOR=$$(echo $$V | cut -d. -f1); \
|
||||
MINOR=$$(echo $$V | cut -d. -f2); \
|
||||
NEW="$$MAJOR.$$((MINOR + 1)).0"; \
|
||||
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
|
||||
echo $$NEW > VERSION; \
|
||||
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
|
||||
git push && git push origin "v$$NEW"; \
|
||||
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"
|
||||
|
||||
# Bump major version (1.0.0 → 2.0.0), commit, tag, and push — triggers release workflow.
|
||||
version-major:
|
||||
@V=$$(cat VERSION); \
|
||||
MAJOR=$$(echo $$V | cut -d. -f1); \
|
||||
NEW="$$((MAJOR + 1)).0.0"; \
|
||||
printf "Push tag v$$NEW to origin? [y/N] "; read ans; [ "$$ans" = "y" ] || { echo "Aborted."; exit 1; }; \
|
||||
echo $$NEW > VERSION; \
|
||||
git add VERSION && git commit -m "chore: bump version to $$NEW" && git tag "v$$NEW"; \
|
||||
git push && git push origin "v$$NEW"; \
|
||||
echo "Bumped $$V -> $$NEW (tagged and pushed v$$NEW)"
|
||||
332
docs/reference-repos/icehunter/README.md
Normal file
332
docs/reference-repos/icehunter/README.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# dune-admin
|
||||
|
||||
<img width="2172" height="724" alt="image" src="https://github.com/user-attachments/assets/facd62b8-3c06-4f92-8d2c-cf06b46e983e" />
|
||||
|
||||
Web-based admin panel for a Dune Awakening private server. Works against any deployment topology: CubeCoders AMP (podman or docker), k3s/k8s over SSH, Docker containers, or a bare-metal install.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
**[@dakkinbyte](https://github.com/dakkinbyte)** deserves special recognition for substantial ongoing contributions: bug fixes, feature work across the dashboard, issue triage, and keeping the project moving. The breadth and consistency of that work has made a real difference.
|
||||
|
||||
---
|
||||
|
||||
A huge thank you to **[@adainrivers](https://github.com/adainrivers)** and the [**dune-dedicated-server-manager**](https://github.com/adainrivers/dune-dedicated-server-manager) project.
|
||||
|
||||
The RabbitMQ server command integration in dune-admin (the envelope format, auth token, AMQP publish path, and the complete catalogue of working server commands) was made possible by the research and live-testing work done in that project. Without it, we would not have known which commands actually work over MQ, what the correct field names are, or that the outer envelope must be base64-encoded before publishing via `rabbitmqctl eval`.
|
||||
|
||||
If you run a Dune Awakening private server, check out their project, a full-featured dedicated server manager with a Tauri desktop frontend.
|
||||
|
||||
---
|
||||
|
||||
## Quick install
|
||||
|
||||
On a fresh Ubuntu 22.04 / 24.04 host with passwordless sudo and your game-server stack already running:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Icehunter/dune-admin/main/scripts/install.sh \
|
||||
| bash
|
||||
```
|
||||
|
||||
The script installs the build toolchain (Go 1.26, Node 22 LTS, pnpm 10.28, build-essential), clones the source, builds the binary and SPA, and installs them into `/opt/dune-admin/`. It refuses to overwrite a running service and leaves `.prev` backups for one-step rollback. See `--help` for flags (`--branch`, `--install-dir`, `--service-user`, `--patches-dir`, `--no-patches`).
|
||||
|
||||
When the script finishes it prints the next manual steps: run the setup wizard, apply the sudoers entry it generates, drop a systemd unit, and start the service.
|
||||
|
||||
For development or non-Ubuntu hosts, see [Manual build](#manual-build).
|
||||
|
||||
---
|
||||
|
||||
## Providers
|
||||
|
||||
Pick the provider that matches your game-server topology. Each guide covers prerequisites, wizard answers, and provider-specific config keys.
|
||||
|
||||
| Provider | Use when | Guide |
|
||||
|----------|----------|-------|
|
||||
| **amp** | Game server runs under CubeCoders AMP (host, or a podman/docker container) | [SETUP_AMP.md](SETUP_AMP.md) |
|
||||
| **kubectl** | Game server runs in k3s/K8s on a remote VM | [SETUP_KUBECTL.md](SETUP_KUBECTL.md) |
|
||||
| **docker** | Game server runs as Docker containers (compose or standalone) | [SETUP_DOCKER.md](SETUP_DOCKER.md) |
|
||||
| **local** | Game server runs on the same machine (bare metal, LGSM, custom) | [SETUP_LOCAL.md](SETUP_LOCAL.md) |
|
||||
|
||||
---
|
||||
|
||||
## Setup wizard
|
||||
|
||||
After install, configure dune-admin with the built-in wizard:
|
||||
|
||||
```bash
|
||||
cd /opt/dune-admin
|
||||
./dune-admin -setup
|
||||
```
|
||||
|
||||
It asks which control plane to use, then prompts for the settings that provider needs (instance name, paths, DB credentials, etc.). When you select `amp`, the wizard auto-detects instances via `ampinstmgr -l` and pre-fills prompts with discovered values, so you can typically accept the defaults straight through. For container topology it also probes the container to discover the actual game install path, so the wizard isn't pinned to any one AMP module's directory layout.
|
||||
|
||||
When done it writes `~/.dune-admin/config.yaml` (mode 600, never committed) and prints a sudoers entry to copy into `/etc/sudoers.d/dune-admin`.
|
||||
|
||||
Re-run the wizard any time your configuration changes.
|
||||
|
||||
---
|
||||
|
||||
## Deploy modes
|
||||
|
||||
The same binary supports three deployment shapes:
|
||||
|
||||
**Single-binary on a host (AMP, local Go, k3s port-forward)**
|
||||
|
||||
The binary serves both the API and the SPA from `./dist` next to itself. `scripts/install.sh` lays this out for you. The simplest model: one process, one port, no CDN.
|
||||
|
||||
**k3s / k8s cluster**
|
||||
|
||||
A unified manifest deploys dune-admin into a cluster with PostgreSQL and RabbitMQ alongside. Helper scripts handle kubeconfig pull, image build/import, manifest render/apply, and port-forward:
|
||||
|
||||
```bash
|
||||
./deploy.sh # macOS / Linux
|
||||
./deploy.ps1 # Windows
|
||||
```
|
||||
|
||||
See [SETUP_KUBECTL.md](SETUP_KUBECTL.md) for full options and troubleshooting.
|
||||
|
||||
**Hosted SPA + local backend**
|
||||
|
||||
Run the binary as an API-only process and serve the SPA from Cloudflare Pages (or any static host). The SPA prompts for a backend URL on first load, stored in localStorage. The backend adds CORS headers automatically, no extra config needed.
|
||||
|
||||
```bash
|
||||
make deploy-web # builds and pushes the SPA to Cloudflare Pages
|
||||
```
|
||||
|
||||
Modern browsers allow HTTPS pages to reach HTTP localhost without mixed-content errors, so `https://your-site.pages.dev` → `http://localhost:8080` works out of the box.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Config is loaded in this order (first match wins per field):
|
||||
|
||||
1. `~/.dune-admin/config.yaml`, written by `dune-admin -setup`
|
||||
2. `.env` in the working directory, legacy fallback for existing installs
|
||||
3. Environment variables
|
||||
4. Command-line flags
|
||||
|
||||
### Common fields
|
||||
|
||||
| Env var | Flag | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `CONTROL` | `-control` | *(auto)* | Control plane: `amp`, `kubectl`, `docker`, or `local` |
|
||||
| `SSH_HOST` | `-host` | - | VM `host:port`, tunnels all connections through SSH when set |
|
||||
| `SSH_USER` | `-user` | `dune` | SSH user |
|
||||
| `SSH_KEY` | `-key` | *(auto-detected)* | SSH private key path |
|
||||
| `DB_HOST` | `-dbhost` | `127.0.0.1` | PostgreSQL host or Docker DNS name |
|
||||
| `DB_PORT` | `-dbport` | `15432` | PostgreSQL port |
|
||||
| `DB_USER` | `-dbuser` | `dune` | PostgreSQL user |
|
||||
| `DB_PASS` | `-dbpass` | - | PostgreSQL password |
|
||||
| `DB_NAME` | `-dbname` | `dune` | PostgreSQL database name |
|
||||
| `DB_SCHEMA` | `-schema` | `dune` | PostgreSQL schema |
|
||||
| `CONTROL_NAMESPACE` | `-control-ns` | *(auto-discovered)* | K8s namespace (kubectl only) |
|
||||
| `BROKER_GAME_ADDR` | `-broker-game` | - | mq-game broker `host:port` |
|
||||
| `BROKER_ADMIN_ADDR` | `-broker-admin` | - | mq-admin broker `host:port` |
|
||||
| `BACKUP_DIR` | `-backup-dir` | - | Backup directory path |
|
||||
| `LISTEN_ADDR` | `-addr` | `:8080` | HTTP listen address |
|
||||
| `SCRIP_CURRENCY` | `-scripcurrency` | `1` | Scrip currency ID |
|
||||
|
||||
Provider-specific fields (`docker_gameserver`, `amp_instance`, `cmd_start`, etc.) have no env var equivalents and are set via the wizard or `config.yaml` directly. See the provider guides for the full list.
|
||||
|
||||
### Market bot
|
||||
|
||||
dune-admin runs the market bot **embedded**, an in-process goroutine that shares the main DB pool. Enable in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
market_bot_enabled: true
|
||||
market_bot_cache_db: ~/.dune-admin/market-bot-cache.db # auto-created (SQLite, pure Go)
|
||||
market_bot_item_data: ./item-data.json # falls back to standard search paths
|
||||
market_bot_buy_interval: 5m
|
||||
market_bot_list_interval: 30m
|
||||
market_bot_buy_threshold: 1.05
|
||||
market_bot_max_buys: 50
|
||||
```
|
||||
|
||||
The Market tab's lifecycle buttons map to in-process actions:
|
||||
|
||||
- **Start** -> `Resume()`: flips the bot's enabled flag back on
|
||||
- **Stop** -> `Pause()`: flips the bot's enabled flag off (goroutine stays resident)
|
||||
- **Restart** -> `Restart()`: pauses, reinitializes the exchange, resumes
|
||||
|
||||
Config edits in the Market tab apply directly to the live runtime config. A full process restart only matters when changing the cache DB path or item-data path.
|
||||
|
||||
See [ADR 0002](docs/adr/0002-embed-market-bot-as-library.md) and [ADR 0004](docs/adr/0004-in-process-bot-lifecycle.md) for the design rationale.
|
||||
|
||||
### Welcome Kits
|
||||
|
||||
An opt-in feature that auto-grants a configured item package to every player **once, on first login**. Manage it in the **Welcome Kits** tab, or in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
welcome_package_enabled: true
|
||||
welcome_package_scan_interval_secs: 30
|
||||
welcome_package_active_version: v1
|
||||
welcome_packages:
|
||||
- version: v1
|
||||
items:
|
||||
- { template: AluminiumBar, qty: 5, quality: 0 }
|
||||
```
|
||||
|
||||
dune-admin keeps a library of named packages plus an active-version pointer. An in-process scanner grants the active package once per `(player, version)`, tracked in a persistent SQLite ledger at `~/.dune-admin/welcome-package.db` (so a restart never re-grants). Bumping the active version re-issues to everyone. It defaults **off** (it mutates every player's inventory) and delivers items through the same live-RMQ + DB-fallback path as manual give-items.
|
||||
|
||||
**Message of the Day (MOTD).** The same tab (**Welcome Kits → Config**) also has a separate **Message of the Day** card: an in-game message whispered to a player from the GM persona **every time they join** — every session, unlike the once-per-version welcome above. It's decoupled from the package system, so it works even with no active package. Use `{player}` in the message for the player's character name, and leave the sender blank to use the seeded GM persona. A presence tracker diffs the online set on each scan tick to detect joins, and seeds a silent baseline on startup so a restart never re-messages players already in-game. Defaults **off**; configure and toggle it live (no restart) in the UI.
|
||||
|
||||
### Server settings
|
||||
|
||||
The **Server Settings** tab manages gameplay config (mining/vehicle output, PvP & security zones, sandstorm/sandworm toggles, building limits, item deterioration, server name/password, …). How it writes depends on the provider:
|
||||
|
||||
- **amp**: settings are written through AMP's Web API, because AMP regenerates the game INIs from its own config on every start (a direct file edit would be clobbered). This requires AMP API credentials in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
amp_api_user: admin # an AMP panel login for the instance
|
||||
amp_api_pass: yourpassword
|
||||
amp_api_port: 8081 # instance ADS API port (default 8081)
|
||||
```
|
||||
|
||||
A game restart applies them; dune-admin's **Restart** recycles the AMP container (`<runtime> restart`, matching `amp_container_runtime`), which is what actually cycles the game processes. The container-runtime sudoers grant must allow this. See [SETUP_AMP.md](SETUP_AMP.md#server-settings-gameplay-config).
|
||||
- **docker / kubectl / local**: settings are written straight to `UserGame.ini` / `UserEngine.ini`; no API credentials needed.
|
||||
|
||||
Either way, a server restart is required for changes to take effect.
|
||||
|
||||
### SSH key lookup order
|
||||
|
||||
When `SSH_KEY` / `-key` is not set, dune-admin checks these paths in order:
|
||||
|
||||
1. `./sshKey`
|
||||
2. `~/.dune-admin/sshKey`
|
||||
3. `~/.ssh/dune`
|
||||
4. `~/.ssh/id_ed25519`
|
||||
5. `~/.ssh/id_rsa`
|
||||
|
||||
---
|
||||
|
||||
## Tabs
|
||||
|
||||
Tabs are organised into a grouped left sidebar.
|
||||
|
||||
**Operations**
|
||||
|
||||
| Tab | What it does |
|
||||
|-----|--------------|
|
||||
| **Battlegroup** | Start/stop game-server pods; stream container logs; manage backups |
|
||||
| **Logs** | Stream live logs; view cheat detection events |
|
||||
| **Database** | Run raw SQL against the game DB; browse tables |
|
||||
| **Server Settings** | Edit UE5 server settings; writes go to `UserGame.ini` in a managed block |
|
||||
|
||||
**Player World**
|
||||
|
||||
| Tab | What it does |
|
||||
|-----|--------------|
|
||||
| **Players** | Browse players; view/edit inventory, specs, currency, XP, faction rep; journey nodes; teleport; session history |
|
||||
| **Storage** | Browse server-side storage containers |
|
||||
| **Bases** | Browse and export player base placements |
|
||||
| **Blueprints** | View all unlockable blueprint definitions |
|
||||
|
||||
**Economy**
|
||||
|
||||
| Tab | What it does |
|
||||
|-----|--------------|
|
||||
| **Market Bot** | View live market listings; control the embedded market bot |
|
||||
| **Welcome Kits** | Auto-grant a configured item package to every player once, on first login — plus an optional Message-of-the-Day whispered on every join |
|
||||
|
||||
---
|
||||
|
||||
## Manual build
|
||||
|
||||
For development or if you'd rather not use `scripts/install.sh`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Icehunter/dune-admin
|
||||
cd dune-admin
|
||||
|
||||
# Frontend (Vite + Rolldown, needs node-linker=hoisted for the native binding)
|
||||
echo 'node-linker=hoisted' > web/.npmrc
|
||||
cd web && pnpm install --frozen-lockfile && pnpm build && cd ..
|
||||
|
||||
# Backend
|
||||
make linux # cross-compile a Linux amd64 binary (dune-admin-linux)
|
||||
# or
|
||||
make build # host OS binary (bin/dune-admin), plus the frontend build
|
||||
```
|
||||
|
||||
Then run the setup wizard:
|
||||
|
||||
```bash
|
||||
./dune-admin -setup
|
||||
```
|
||||
|
||||
Prerequisites: Go 1.26+, Node 20.19+ or 22.12+, pnpm 10.28+, `make`.
|
||||
|
||||
> **Windows note:** `make build` works from PowerShell or `cmd.exe` as long as
|
||||
> [GNU Make](https://gnuwin32.sourceforge.net/packages/make.htm) is installed (e.g.
|
||||
> `winget install GnuWin32.Make` or `choco install make`). The binary is named
|
||||
> `dune-admin.exe`. For `make dev`, `make verify`, and the `make version-*` targets,
|
||||
> run from a **Git Bash** shell (right-click the folder -> "Git Bash Here"); those
|
||||
> recipes use POSIX shell features that cmd.exe can't run.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
dune-admin ships pre-commit and pre-push hooks that mirror the CI quality gate. Set them up once:
|
||||
|
||||
```bash
|
||||
make hooks # wires git's core.hooksPath to .githooks/
|
||||
make tools # caches golangci-lint, govulncheck, gocognit, gosec, air (via Go's go tool mechanism)
|
||||
```
|
||||
|
||||
After that, every `git commit` runs `gofmt -w` + `go vet` + `golangci-lint` on staged Go files (and `markdownlint-cli2` on `.md` files), and every `git push` adds `gosec`, `govulncheck`, and `go test -race`. Bypass with `--no-verify` if you really need to.
|
||||
|
||||
You can run the full suite by hand at any time:
|
||||
|
||||
```bash
|
||||
make verify # fmt-check + vet + test-race + vulncheck + lint + gocognit
|
||||
```
|
||||
|
||||
> **Windows note:** the race detector needs cgo. Either install MinGW (e.g. `winget install BrechtSanders.WinLibs.POSIX.UCRT`) or skip race testing locally and rely on the pre-push hook on a Linux dev box or CI.
|
||||
|
||||
---
|
||||
|
||||
## Makefile targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `make setup` | Run the interactive setup wizard |
|
||||
| `make build` | Build frontend + Go binary for the host OS |
|
||||
| `make linux` | Cross-compile a Linux amd64 binary (`dune-admin-linux`) |
|
||||
| `make dev` | Run backend + frontend in live mode (Air + Vite HMR) |
|
||||
| `make dev-server` | Run backend only (`go run ./cmd/dune-admin`) |
|
||||
| `make web` | Build the frontend only |
|
||||
| `make deploy-web` | Build and deploy the SPA to Cloudflare Pages |
|
||||
| `make render-k8s` | Render `deploy/k8s/dune-admin.rendered.yaml` from `~/.dune-admin/config.yaml` |
|
||||
| `make k8s-dry-run` | Render and run `kubectl apply --dry-run=client` |
|
||||
| `make test` | Run tests |
|
||||
| `make verify` | Run all quality checks (fmt-check, vet, test-race, vulncheck, lint, gocognit) |
|
||||
| `make hooks` | Install the pre-commit + pre-push hooks |
|
||||
| `make tools` | Cache the dev toolchain (`golangci-lint`, `gosec`, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Item data (optional)
|
||||
|
||||
`item-data.json` provides friendly item names, stack limits, volume, tier, and rarity. It ships with the repo.
|
||||
|
||||
Without it the panel still works; inventory items show raw template IDs.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
dune-admin is a single Go binary that exposes a REST API over the game's PostgreSQL + RabbitMQ stack. The React SPA can be served by the same binary (from `./dist`) or hosted independently (Cloudflare Pages). A control-plane abstraction (`amp` / `kubectl` / `docker` / `local`) drives lifecycle operations, log streaming, INI editing, and broker access against whatever topology you're running.
|
||||
|
||||
For design rationale and trade-offs, see the architecture decision records in [`docs/adr/`](docs/adr/):
|
||||
|
||||
- [0001 - Standard Go project layout](docs/adr/0001-standard-go-layout.md)
|
||||
- [0002 - Embed market bot as `internal/marketbot` library](docs/adr/0002-embed-market-bot-as-library.md)
|
||||
- [0003 - Ship a single binary and container image](docs/adr/0003-single-binary-deployment.md)
|
||||
- [0004 - In-process bot lifecycle control](docs/adr/0004-in-process-bot-lifecycle.md)
|
||||
- [0005 - Ring-buffer for embedded bot log streaming](docs/adr/0005-ring-buffer-log-streaming.md)
|
||||
- [0006 - Replace per-project k8s manifests with one unified manifest](docs/adr/0006-unified-k8s-manifest.md)
|
||||
- [0007 - Persistent volume for SQLite market-bot cache](docs/adr/0007-sqlite-cache-storage.md)
|
||||
- [0008 - Extend config.yaml for embedded-bot settings](docs/adr/0008-config-yaml-extensions.md)
|
||||
148
docs/reference-repos/icehunter/SETUP_AMP.md
Normal file
148
docs/reference-repos/icehunter/SETUP_AMP.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Provider: amp (CubeCoders AMP)
|
||||
|
||||
Use this provider when your Dune server is managed by AMP (`ampinstmgr`) and RabbitMQ/Postgres live in the AMP stack (host-native, or in a podman- or docker-backed container). Set `amp_container_runtime` to match (`podman` default, or `docker`).
|
||||
|
||||
```
|
||||
dune-admin
|
||||
├─ ampinstmgr / podman exec → lifecycle + logs + broker ops
|
||||
├─ elevated INI writes as amp user
|
||||
└─ TCP → PostgreSQL (host or SSH-tunnelled host)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
|
||||
| **AMP host access** | Run dune-admin on the AMP host, or set `ssh_host` to run remotely over SSH |
|
||||
| **Sudoers grant** | dune-admin user must run `ampinstmgr`, the container runtime (`podman`, or `docker` when `amp_container_runtime: docker`), and `tee` as AMP user without prompts. The container-runtime grant covers both `exec` (logs/broker) and `restart` (applying server settings). |
|
||||
|
||||
Example sudoers entry (adjust user/path names as needed) — **podman backend (default)**:
|
||||
|
||||
```bash
|
||||
dune-admin ALL=(amp) NOPASSWD: /usr/bin/ampinstmgr, /usr/bin/podman, /usr/bin/tee
|
||||
```
|
||||
|
||||
If your AMP container runs on **docker** (`amp_container_runtime: docker`), grant `docker` instead:
|
||||
|
||||
```bash
|
||||
dune-admin ALL=(amp) NOPASSWD: /usr/bin/ampinstmgr, /usr/bin/docker, /usr/bin/tee
|
||||
```
|
||||
|
||||
The same runtime grant is what lets dune-admin's **Restart** action cycle the container
|
||||
(`<runtime> restart <container>`) — see [Server settings](#server-settings-gameplay-config) for why a
|
||||
real container restart is required.
|
||||
|
||||
## Quick start (wizard)
|
||||
|
||||
```bash
|
||||
make setup
|
||||
# Select: amp
|
||||
# Fill AMP instance/container/user details
|
||||
make build # builds frontend + dune-admin binary
|
||||
./dune-admin
|
||||
```
|
||||
|
||||
## Manual config (`~/.dune-admin/config.yaml`)
|
||||
|
||||
```yaml
|
||||
control: amp
|
||||
|
||||
# Optional if running dune-admin from a different machine:
|
||||
# ssh_host: 192.168.0.72:22
|
||||
# ssh_user: dune-admin
|
||||
# ssh_key: /home/you/.ssh/amp-host
|
||||
|
||||
db_host: 127.0.0.1
|
||||
db_port: 15432
|
||||
db_user: postgres
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
|
||||
amp_instance: DuneAwakening01
|
||||
amp_container: AMP_DuneAwakening01
|
||||
amp_user: amp
|
||||
amp_log_path: /AMP/duneawakening/logs
|
||||
server_ini_dir: /home/amp/.ampdata/instances/DuneAwakening01/duneawakening/server/state
|
||||
|
||||
# Optional:
|
||||
amp_use_container: true
|
||||
amp_container_runtime: docker # podman (default) | docker — match your AMP container backend
|
||||
amp_data_root: /AMP/duneawakening
|
||||
director_url: http://127.0.0.1:11717
|
||||
broker_exec_prefix: "sudo -i -u amp podman exec AMP_DuneAwakening01"
|
||||
listen_addr: :18080 # avoids collision with AMP web panel on :8080
|
||||
|
||||
# AMP Web API — required to manage gameplay settings under AMP (see "Server settings" below).
|
||||
# These are an AMP panel login for the instance; the API is reached in-container at
|
||||
# 127.0.0.1:<amp_api_port>, so no host port needs to be exposed.
|
||||
amp_api_user: admin
|
||||
amp_api_pass: yourpassword
|
||||
amp_api_port: 8081 # instance ADS API port (default 8081)
|
||||
```
|
||||
|
||||
## Embedded market bot (recommended in AMP)
|
||||
|
||||
```yaml
|
||||
market_bot_enabled: true
|
||||
market_bot_cache_db: /home/amp/.dune-admin/market-bot-cache.db
|
||||
market_bot_item_data: /path/to/dune-admin/item-data.json
|
||||
market_bot_buy_interval: 5m
|
||||
market_bot_list_interval: 30m
|
||||
market_bot_buy_threshold: 1.05
|
||||
market_bot_max_buys: 50
|
||||
```
|
||||
|
||||
External market-bot mode is removed; use embedded mode for AMP deployments.
|
||||
|
||||
## Server settings (gameplay config)
|
||||
|
||||
The **Server Settings** tab manages gameplay knobs — mining/vehicle output, PvP/security zones,
|
||||
sandstorm and sandworm toggles, building limits, item deterioration, server name/password, and so on.
|
||||
|
||||
Under AMP this path is different from every other provider, and it matters:
|
||||
|
||||
- **AMP owns the game INI files.** AMP regenerates `UserEngine.ini` / `UserGame.ini` from its own
|
||||
config on every start, so editing those files directly is silently clobbered. dune-admin therefore
|
||||
writes settings through **AMP's Web API** (`Core/SetConfig`), which persists them in AMP's config and
|
||||
survives restarts. This is why the `amp_api_user` / `amp_api_pass` / `amp_api_port` credentials above
|
||||
are required — without them, saving a setting under AMP returns an error rather than failing silently.
|
||||
- **A restart is required to apply.** Saving writes the value to AMP's config; the running game only
|
||||
picks it up on the next start. dune-admin's **Restart** action recycles the AMP **container**
|
||||
(`<runtime> restart <container>`), which is the action that actually reaps the
|
||||
`DuneSandboxServer` processes — `ampinstmgr` alone leaves them running, so the change never takes
|
||||
effect. The container restart also briefly cycles the in-container PostgreSQL and broker; dune-admin
|
||||
reconnects to the database automatically afterwards.
|
||||
|
||||
Typical flow: change a value in **Server Settings** → **Save** → **Restart** (Operations) → the new
|
||||
value is live in-game.
|
||||
|
||||
Other providers (docker / kubectl / local) have no AMP layer to clobber the files, so they write the
|
||||
INIs directly and do **not** need the `amp_api_*` credentials.
|
||||
|
||||
## What works
|
||||
|
||||
| Feature | Supported |
|
||||
|---------|-----------|
|
||||
| Battlegroup status | Yes |
|
||||
| Start / stop | Yes (`ampinstmgr`) |
|
||||
| Restart | Yes — cycles the container (`<runtime> restart`) so game processes actually recycle |
|
||||
| Process list | Yes |
|
||||
| Log streaming | Yes |
|
||||
| DB access | Yes (direct or SSH tunnel) |
|
||||
| Broker command path | Yes (`broker_exec_prefix`) |
|
||||
| Server settings (gameplay) | Yes — written via AMP Web API (`amp_api_*`); restart to apply |
|
||||
| INI read/write | Yes (`ampExecutor` writes as AMP user; non-gameplay/raw sections) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`sudo` prompts or permission denied** — fix `/etc/sudoers.d/*` for the dune-admin user and AMP user.
|
||||
|
||||
**INI changes fail** — verify `server_ini_dir` and that AMP user owns `UserGame.ini` / `UserEngine.ini`.
|
||||
|
||||
**Broker commands fail** — set `broker_exec_prefix` to the exact `podman exec` (or `docker exec`) wrapper used on your AMP host.
|
||||
|
||||
**Saving a server setting returns an error (502)** — dune-admin could not reach or authenticate to the AMP Web API. Check `amp_api_user` / `amp_api_pass` (an AMP panel login) and `amp_api_port` (default `8081`), and that the instance ADS is running inside the container.
|
||||
|
||||
**A server setting saved but did nothing in-game** — settings apply on the next game start. Use dune-admin's **Restart** (which does `<runtime> restart <container>`), not just `ampinstmgr`; confirm the container-runtime sudoers grant above is in place so the restart can run.
|
||||
109
docs/reference-repos/icehunter/SETUP_DOCKER.md
Normal file
109
docs/reference-repos/icehunter/SETUP_DOCKER.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Provider: docker
|
||||
|
||||
Use this provider when your Dune server runs as Docker containers (e.g. alongside a compose stack) and dune-admin can reach the Docker daemon directly — either co-located on the same host or SSH'd into a Docker host.
|
||||
|
||||
```
|
||||
dune-admin
|
||||
├─ docker CLI → container lifecycle + logs
|
||||
├─ docker exec → RabbitMQ broker commands
|
||||
└─ TCP (Docker DNS) → PostgreSQL (database:15432)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
|
||||
| **Docker CLI** | Must be in `$PATH` |
|
||||
| **Docker access** | The user running dune-admin must be able to run `docker` (i.e. in the `docker` group or running as root) |
|
||||
|
||||
### If dune-admin runs on a different host
|
||||
|
||||
Add `SSH_HOST` to your config so all commands and DB connections tunnel through SSH:
|
||||
|
||||
```yaml
|
||||
ssh_host: 192.168.0.72:22
|
||||
ssh_user: dune
|
||||
ssh_key: /home/you/.ssh/key
|
||||
```
|
||||
|
||||
With SSH set, `docker` CLI commands run on the remote host and DB connections are tunnelled — no ports need to be exposed.
|
||||
|
||||
## Quick start (wizard)
|
||||
|
||||
```bash
|
||||
make setup
|
||||
# Select: docker
|
||||
# Enter container names when prompted
|
||||
make build # builds frontend + dune-admin binary
|
||||
./dune-admin
|
||||
```
|
||||
|
||||
The wizard asks for container names, tests them with `docker inspect`, and asks for DB connection details.
|
||||
|
||||
## Manual config (`~/.dune-admin/config.yaml`)
|
||||
|
||||
```yaml
|
||||
control: docker
|
||||
|
||||
# Container names — must match exactly what `docker ps` shows:
|
||||
docker_gameserver: dune-gameserver
|
||||
docker_broker_game: dune-mq-game # optional — for broker command path
|
||||
docker_broker_admin: dune-mq-admin # optional — for broker command path
|
||||
|
||||
# Database — use Docker DNS name or IP:
|
||||
db_host: database # service name in your compose file
|
||||
db_port: 15432
|
||||
db_user: dune
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
|
||||
# Optional:
|
||||
backup_dir: /backups
|
||||
broker_game_addr: dune-mq-game:5672 # defaults to docker_broker_game container DNS if omitted
|
||||
broker_admin_addr: dune-mq-admin:5672
|
||||
broker_tls: false
|
||||
listen_addr: :8080
|
||||
scrip_currency: 1
|
||||
```
|
||||
|
||||
> **Note:** `docker_*` and `cmd_*` fields are only read from `~/.dune-admin/config.yaml` — they have no env var equivalents. Use `make setup` or edit the file directly.
|
||||
|
||||
## Typical compose layout
|
||||
|
||||
Your compose file doesn't need to change. dune-admin just needs the container names:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gameserver:
|
||||
container_name: dune-gameserver # ← docker_gameserver
|
||||
database:
|
||||
container_name: dune-db
|
||||
mq-game:
|
||||
container_name: dune-mq-game # ← docker_broker_game
|
||||
mq-admin:
|
||||
container_name: dune-mq-admin # ← docker_broker_admin
|
||||
```
|
||||
|
||||
## What works
|
||||
|
||||
| Feature | Supported |
|
||||
|---------|-----------|
|
||||
| Battlegroup status | Partial — shows container state, not K8s CRD fields |
|
||||
| Start / stop / restart | Yes — `docker start/stop/restart` |
|
||||
| Update / backup | Not supported (no `battlegroup.sh`) |
|
||||
| Container list | Yes — `docker ps` |
|
||||
| Log streaming | Yes — `docker logs -f` |
|
||||
| DB access | Yes — direct TCP to `db_host:db_port` |
|
||||
| RabbitMQ broker commands | Yes — `docker exec` into broker container |
|
||||
| Backup download / upload | Yes — through executor file I/O |
|
||||
| Backup restore | Yes — `pg_restore` run via executor |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"docker inspect failed"** — the container name is wrong or Docker is not running. Check with `docker ps` and update `docker_gameserver` in config.yaml.
|
||||
|
||||
**DB connection fails** — verify `db_host` matches the container's DNS name or IP. Inside a compose network, use the service/container name directly (e.g. `database`). Outside the network, use the host IP and a mapped port.
|
||||
|
||||
**Logs show nothing** — confirm `docker_gameserver` is the correct container name. Container names are exact-match, not prefix.
|
||||
235
docs/reference-repos/icehunter/SETUP_KUBECTL.md
Normal file
235
docs/reference-repos/icehunter/SETUP_KUBECTL.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Provider: kubectl (Kubernetes / k3s)
|
||||
|
||||
Use this provider when your Dune server runs inside a Kubernetes cluster (e.g. k3s on a VM) and dune-admin runs on your local machine or another host with SSH access to that VM.
|
||||
|
||||
All commands run over SSH — no exposed ports, no VPN.
|
||||
|
||||
```
|
||||
your machine
|
||||
└─ SSH tunnel → VM
|
||||
├─ kubectl → battlegroup CRDs / pod logs
|
||||
└─ TCP tunnel → PostgreSQL (pod IP:15432)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
|
||||
| **SSH key** | Private key authorised on the VM |
|
||||
| **VM access** | Port 22 reachable; SSH user needs passwordless `sudo kubectl` |
|
||||
|
||||
## Quick start (wizard)
|
||||
|
||||
```bash
|
||||
# Place SSH key (checked automatically in this order):
|
||||
# ./sshKey │ ~/.dune-admin/sshKey │ ~/.ssh/dune │ ~/.ssh/id_ed25519 │ ~/.ssh/id_rsa
|
||||
cp /path/to/key ./sshKey && chmod 600 ./sshKey
|
||||
|
||||
make setup # prompts for VM host:port, discovers namespace + DB password automatically
|
||||
make build # builds frontend + dune-admin binary
|
||||
./dune-admin
|
||||
```
|
||||
|
||||
The wizard:
|
||||
|
||||
1. Locates your SSH key
|
||||
2. SSHes into the VM
|
||||
3. Runs `kubectl get pods -A` to find the database pod and namespace
|
||||
4. Reads `~/.dune/<battlegroup>.yaml` on the VM for DB credentials
|
||||
5. Writes `~/.dune-admin/config.yaml`
|
||||
|
||||
## External VM deploy example (`dune@192.168.0.72`)
|
||||
|
||||
```bash
|
||||
cd /Volumes/Engineering/Icehunter/dune-admin
|
||||
|
||||
# Pull kubeconfig from VM and point kubectl at the external cluster.
|
||||
mkdir -p ~/.kube
|
||||
ssh dune@192.168.0.72 "sudo cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/dune-external.yaml
|
||||
sed -i '' 's/127.0.0.1/192.168.0.72/g' ~/.kube/dune-external.yaml
|
||||
export KUBECONFIG=~/.kube/dune-external.yaml
|
||||
kubectl get nodes
|
||||
|
||||
# Configure dune-admin runtime values.
|
||||
make setup
|
||||
|
||||
# Ensure these are set in ~/.dune-admin/config.yaml for container runtime:
|
||||
# market_bot_enabled: true
|
||||
# market_bot_item_data: /app/item-data.json
|
||||
# market_bot_cache_db: /data/market-bot-cache.db
|
||||
|
||||
# Build local image and import it into k3s runtime on the VM.
|
||||
docker buildx build --platform linux/amd64 -f deploy/Dockerfile -t dune-admin:local --load .
|
||||
docker save dune-admin:local | ssh dune@192.168.0.72 "sudo k3s ctr images import -"
|
||||
|
||||
# Render deployment manifest from ~/.dune-admin/config.yaml and switch image tag.
|
||||
make render-k8s
|
||||
sed -i '' 's#ghcr.io/icehunter/dune-admin:latest#dune-admin:local#g' deploy/k8s/dune-admin.rendered.yaml
|
||||
|
||||
# Deploy and wait for readiness.
|
||||
kubectl apply -f deploy/k8s/dune-admin.rendered.yaml
|
||||
kubectl -n dune-admin rollout status deploy/dune-admin
|
||||
kubectl -n dune-admin get pods,svc
|
||||
|
||||
# Verify API and bot from inside the cluster.
|
||||
kubectl -n dune-admin run curl --rm -it --restart=Never --image=curlimages/curl -- \
|
||||
sh -c "curl -s http://dune-admin:8080/api/v1/market-bot/status"
|
||||
|
||||
# Local access from your laptop.
|
||||
kubectl -n dune-admin port-forward svc/dune-admin 8080:8080
|
||||
```
|
||||
|
||||
## Scripted deploy (Linux/macOS + Windows)
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
```powershell
|
||||
./deploy.ps1
|
||||
```
|
||||
|
||||
> On Windows, if script execution is blocked, run once:
|
||||
> `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`
|
||||
|
||||
Both scripts run the full k8s flow:
|
||||
|
||||
1. Pull kubeconfig from VM (`dune@192.168.0.72` by default) unless skipped
|
||||
2. Build a fresh timestamped image tag (`dune-admin:local-<timestamp>` by default)
|
||||
3. Import image into VM k3s runtime (`k3s ctr images import`)
|
||||
4. Render and apply `deploy/k8s/dune-admin.rendered.yaml`
|
||||
5. Auto-fix embedded bot deployment settings in the rendered manifest:
|
||||
- `MARKET_BOT_ENABLED: "true"`
|
||||
- `market_bot_enabled: true`
|
||||
- `market_bot_item_data: /app/item-data.json`
|
||||
- `market_bot_cache_db: /data/market-bot-cache.db`
|
||||
6. Restart rollout, wait for old terminating pods to drain, then run in-cluster health checks for:
|
||||
- `/api/v1/status` reachable
|
||||
- `/` returns HTTP 200 (no UI 404)
|
||||
- `/api/v1/market-bot/status` reports `"enabled":true`
|
||||
7. Open `kubectl port-forward` on `127.0.0.1:8080` (unless disabled)
|
||||
|
||||
SSH auth behavior in both scripts:
|
||||
|
||||
- If `./sshKey` exists, it is used first (`-i ./sshKey`)
|
||||
- If key auth fails or no key is present, SSH falls back to password prompt
|
||||
|
||||
### Script options
|
||||
|
||||
| Purpose | Bash | PowerShell |
|
||||
|---|---|---|
|
||||
| VM user | `--vm-user dune` | `-VmUser dune` |
|
||||
| VM host | `--vm-host 192.168.0.72` | `-VmHost 192.168.0.72` |
|
||||
| SSH key path | `--ssh-key ./sshKey` | `-SshKeyPath .\sshKey` |
|
||||
| Kubeconfig path | `--kubeconfig ~/.kube/dune-external.yaml` | `-KubeconfigPath "$HOME/.kube/dune-external.yaml"` |
|
||||
| Image tag | `--image dune-admin:local` | `-Image dune-admin:local` |
|
||||
| Namespace | `--namespace dune-admin` | `-Namespace dune-admin` |
|
||||
| Manifest path | `--manifest deploy/k8s/dune-admin.rendered.yaml` | `-Manifest deploy/k8s/dune-admin.rendered.yaml` |
|
||||
| Skip kubeconfig pull | `--skip-kubeconfig` | `-SkipKubeconfig` |
|
||||
| Skip image build | `--skip-build` | `-SkipBuild` |
|
||||
| Skip VM image import | `--skip-image-import` | `-SkipImageImport` |
|
||||
| Skip port-forward | `--no-port-forward` | `-NoPortForward` |
|
||||
|
||||
### First deploy vs quick redeploy
|
||||
|
||||
First deploy:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
Quick redeploy (reuse kubeconfig, no auto port-forward):
|
||||
|
||||
```bash
|
||||
./deploy.sh --skip-kubeconfig --no-port-forward
|
||||
```
|
||||
|
||||
Quick redeploy with the exact same image tag (advanced):
|
||||
|
||||
```bash
|
||||
./deploy.sh --image dune-admin:local --skip-kubeconfig --no-port-forward
|
||||
```
|
||||
|
||||
Override defaults with flags (same names on both scripts), e.g.:
|
||||
|
||||
```bash
|
||||
./deploy.sh --vm-user dune --vm-host 192.168.0.72 --image dune-admin:local --no-port-forward
|
||||
```
|
||||
|
||||
```powershell
|
||||
./deploy.ps1 -VmUser dune -VmHost 192.168.0.72 -Image dune-admin:local -NoPortForward
|
||||
```
|
||||
|
||||
## Manual config (`~/.dune-admin/config.yaml`)
|
||||
|
||||
```yaml
|
||||
control: kubectl
|
||||
|
||||
ssh_host: 192.168.0.72:22 # VM host:port
|
||||
ssh_user: dune # SSH user
|
||||
ssh_key: /home/you/.ssh/key # absolute path; omit to use auto-detection
|
||||
|
||||
db_host: 127.0.0.1 # unused for kubectl — pod IP is discovered automatically
|
||||
db_port: 15432
|
||||
db_user: postgres
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
|
||||
# Optional — discovered automatically if omitted:
|
||||
control_namespace: funcom-seabass-mybattlegroup
|
||||
|
||||
# Optional broker command path:
|
||||
broker_game_addr: 10.43.48.246:5672
|
||||
broker_admin_addr: 10.43.189.193:5672
|
||||
broker_tls: true
|
||||
|
||||
# Optional:
|
||||
backup_dir: /funcom/artifacts/database-dumps/mybattlegroup
|
||||
listen_addr: :8080
|
||||
scrip_currency: 1
|
||||
```
|
||||
|
||||
## What works
|
||||
|
||||
| Feature | Supported |
|
||||
|---------|-----------|
|
||||
| Battlegroup status (phase, servers) | Yes |
|
||||
| Start / stop / restart | Yes — `kubectl patch battlegroup` |
|
||||
| Update / backup | Yes — `battlegroup.sh` |
|
||||
| Pod list | Yes |
|
||||
| Log streaming | Yes — `kubectl logs -f` |
|
||||
| DB access | Yes — tunnelled through SSH to pod IP |
|
||||
| RabbitMQ broker commands | Yes — `kubectl exec` into broker pod |
|
||||
| Backup download / upload / restore | Yes |
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Existing `.env` files with just `SSH_HOST`, `SSH_USER`, `DB_PASS`, etc. continue to work unchanged. The control plane defaults to `kubectl` whenever `SSH_HOST` is set, and the namespace is auto-discovered at startup.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Battlegroup tab shows nothing** — the namespace was not discovered. Check that the SSH user can run `sudo kubectl get pods -A`. You can also pin it explicitly with `control_namespace` in config.yaml.
|
||||
|
||||
**DB connection fails** — pod discovery succeeded but DB password is wrong. Delete `~/.dune-admin/config.yaml` and re-run `make setup`.
|
||||
|
||||
**"sudo: kubectl: command not found"** — kubectl is not in the sudo-safe PATH on the VM. Add `/usr/local/bin` (or wherever kubectl lives) to `/etc/sudoers` `secure_path`.
|
||||
|
||||
**Port-forward works but `http://127.0.0.1:8080` returns 404** — you are running an old image that does not contain the built frontend (`/app/dist`). Rebuild and redeploy with the deploy script.
|
||||
|
||||
**Market bot panel shows inactive after deploy** — verify:
|
||||
|
||||
1. `market_bot_enabled: true` in `~/.dune-admin/config.yaml`
|
||||
2. `market_bot_item_data: /app/item-data.json`
|
||||
3. `market_bot_cache_db: /data/market-bot-cache.db`
|
||||
|
||||
Then redeploy and check:
|
||||
|
||||
```bash
|
||||
kubectl -n dune-admin logs deploy/dune-admin --tail=120
|
||||
kubectl -n dune-admin run curl --rm -it --restart=Never --image=curlimages/curl -- \
|
||||
sh -c "curl -s http://dune-admin:8080/api/v1/market-bot/status"
|
||||
```
|
||||
119
docs/reference-repos/icehunter/SETUP_LOCAL.md
Normal file
119
docs/reference-repos/icehunter/SETUP_LOCAL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Provider: local
|
||||
|
||||
Use this provider when dune-admin runs on the same machine as the Dune server and there is no Kubernetes or Docker involved — e.g. LGSM, bare-metal, or any setup where the game server is managed by shell commands.
|
||||
|
||||
> If you run CubeCoders AMP, prefer `control: amp` and follow [SETUP_AMP.md](SETUP_AMP.md).
|
||||
|
||||
```
|
||||
dune-admin (same machine)
|
||||
├─ shell commands → server lifecycle (start/stop/restart/status)
|
||||
└─ TCP (127.0.0.1) → PostgreSQL (localhost:15432)
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Go 1.21+** | `brew install go` or <https://go.dev/dl/> |
|
||||
| **PostgreSQL reachable** | `db_host` must be accessible from where dune-admin runs |
|
||||
| **Shell commands** | Optional — only needed for start/stop/restart/status in the Battlegroup tab |
|
||||
|
||||
## Quick start (wizard)
|
||||
|
||||
```bash
|
||||
make setup
|
||||
# Select: local
|
||||
# Enter DB host, port, credentials
|
||||
# Enter optional shell commands for server control
|
||||
make build # builds frontend + dune-admin binary
|
||||
./dune-admin
|
||||
```
|
||||
|
||||
## Manual config (`~/.dune-admin/config.yaml`)
|
||||
|
||||
```yaml
|
||||
control: local
|
||||
|
||||
db_host: 127.0.0.1
|
||||
db_port: 15432
|
||||
db_user: dune
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
|
||||
# Shell commands for Battlegroup tab (all optional):
|
||||
cmd_start: "amp start DuneAwakening"
|
||||
cmd_stop: "amp stop DuneAwakening"
|
||||
cmd_restart: "amp restart DuneAwakening"
|
||||
cmd_status: "amp status DuneAwakening"
|
||||
|
||||
# If RabbitMQ runs inside a container (e.g. AMP uses Podman internally),
|
||||
# set this prefix and it will be prepended to all rabbitmqctl calls:
|
||||
# broker_exec_prefix: "podman exec AMP_MehDune01"
|
||||
# broker_exec_prefix: "docker exec my-broker"
|
||||
|
||||
# Optional:
|
||||
backup_dir: /home/dune/backups
|
||||
listen_addr: :8080
|
||||
scrip_currency: 1
|
||||
```
|
||||
|
||||
### AMP example
|
||||
|
||||
```yaml
|
||||
control: local
|
||||
db_host: 127.0.0.1
|
||||
db_port: 15432
|
||||
db_user: postgres
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
cmd_start: "ampinstmgr start DuneAwakening01"
|
||||
cmd_stop: "ampinstmgr stop DuneAwakening01"
|
||||
cmd_restart: "ampinstmgr restart DuneAwakening01"
|
||||
cmd_status: "ampinstmgr status DuneAwakening01"
|
||||
```
|
||||
|
||||
### LGSM example
|
||||
|
||||
```yaml
|
||||
control: local
|
||||
db_host: 127.0.0.1
|
||||
db_port: 15432
|
||||
db_user: dune
|
||||
db_pass: yourpassword
|
||||
db_name: dune
|
||||
db_schema: dune
|
||||
cmd_start: "/home/dune/duneserver start"
|
||||
cmd_stop: "/home/dune/duneserver stop"
|
||||
cmd_restart: "/home/dune/duneserver restart"
|
||||
cmd_status: "/home/dune/duneserver status"
|
||||
```
|
||||
|
||||
### DB only (no server control)
|
||||
|
||||
Leave all `cmd_*` fields empty. The Battlegroup tab will show an error for start/stop/restart but everything else — players, inventory, DB browser, etc. — works normally.
|
||||
|
||||
> **Note:** `cmd_*` fields are only read from `~/.dune-admin/config.yaml` — they have no env var equivalents. Use `make setup` or edit the file directly.
|
||||
|
||||
## What works
|
||||
|
||||
| Feature | Supported |
|
||||
|---------|-----------|
|
||||
| Battlegroup status | Partial — runs `cmd_status` and shows raw output |
|
||||
| Start / stop / restart | Yes — runs the configured shell commands |
|
||||
| Update / backup | Not supported |
|
||||
| Pod/process list | Not supported |
|
||||
| Log streaming | Not supported — tail your own log files |
|
||||
| DB access | Yes — direct TCP to `db_host:db_port` |
|
||||
| RabbitMQ broker commands | Yes — runs `rabbitmqctl` directly if available in `$PATH` |
|
||||
| Backup download / upload | Yes — through local file I/O |
|
||||
| Backup restore | Yes — `pg_restore` run locally |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Start/stop does nothing** — the shell commands run as the same user that launched dune-admin. Make sure that user has permission to run the AMP/LGSM commands. Test manually first: `ampinstmgr start DuneAwakening01`.
|
||||
|
||||
**DB connection fails** — verify PostgreSQL is listening on the configured `db_host:db_port`. For AMP, the DB is usually on `127.0.0.1:5432` or a custom port; check your AMP instance settings.
|
||||
|
||||
**RabbitMQ broker command fails** — `rabbitmqctl` must be in `$PATH` for the user running dune-admin. Run `which rabbitmqctl` to verify.
|
||||
1
docs/reference-repos/icehunter/VERSION
Normal file
1
docs/reference-repos/icehunter/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
0.33.0
|
||||
8
docs/reference-repos/icehunter/cheatScripts.json
Normal file
8
docs/reference-repos/icehunter/cheatScripts.json
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{ "name": "LeaveMeAlone", "danger": false },
|
||||
{ "name": "AwardPlayerXP", "danger": false },
|
||||
{ "name": "UnlockAllSkills", "danger": false },
|
||||
{ "name": "UnlockAllAbilities", "danger": false },
|
||||
{ "name": "PlaytestSetup", "danger": true },
|
||||
{ "name": "PlaytestSetupAdmin", "danger": true }
|
||||
]
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user