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:
1
docs/reference-repos/adainrivers/crates/dune-server-service/.gitignore
vendored
Normal file
1
docs/reference-repos/adainrivers/crates/dune-server-service/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "dune-server-service"
|
||||
version = "0.3.16"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
description = "Dune dedicated-server management daemon: scheduled maintenance + admin command API."
|
||||
|
||||
[[bin]]
|
||||
name = "dune-server-service"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process", "fs", "io-util", "sync", "time"] }
|
||||
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio", "query"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tokio-postgres = "0.7"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
|
||||
base64 = "0.22"
|
||||
once_cell = "1"
|
||||
regex = "1"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
cron = "0.12"
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,872 @@
|
||||
[
|
||||
{
|
||||
"id": "Skills.Ability.BinduNerveStrike",
|
||||
"name": "Prana-Bindu Strikes",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.Blindspot",
|
||||
"name": "Ignore",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.Hypersprint",
|
||||
"name": "Bindu Sprint",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.LitanyAgainstFear",
|
||||
"name": "Litany Against Fear",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.VoiceCompel",
|
||||
"name": "Compel",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.VoiceStop",
|
||||
"name": "Stop",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.WeirdingStep",
|
||||
"name": "Weirding Step",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Manipulation1",
|
||||
"name": "Voice Training",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SelfControl1",
|
||||
"name": "Recovery",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SelfControl2",
|
||||
"name": "Sun Tolerance",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SelfControl3",
|
||||
"name": "Vitality",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SelfControl4",
|
||||
"name": "Self-Healing",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SelfControl5",
|
||||
"name": "Poison Tolerance",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.WeirdingWay1",
|
||||
"name": "Blade Damage",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.WeirdingWay2",
|
||||
"name": "Short Blade Damage",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.BeneGesserit1",
|
||||
"name": "XX_Bene Gesserit Phase 1",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.BeneGesserit2",
|
||||
"name": "XX_Bene Gesserit Phase 2",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.BeneGesserit3",
|
||||
"name": "XX_Bene Gesserit Phase 3",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneManipulation",
|
||||
"name": "XX_Manipulation Capstone",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneSelfControl",
|
||||
"name": "XX_Self Control Capstone",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneWeirdingWay",
|
||||
"name": "XX_Weirding Way Capstone",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.Backstabber",
|
||||
"name": "Manipulate Instability",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.BinduStability",
|
||||
"name": "Prana-Bindu Stability",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.MetabolizePoison",
|
||||
"name": "Metabolize Poison",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.RegenCap",
|
||||
"name": "Trauma Recovery",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.VoiceAnalysis",
|
||||
"name": "Rapid Register",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.BinduDodge",
|
||||
"name": "Bindu Dodge",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.VoiceSplash",
|
||||
"name": "Screech",
|
||||
"category": "BeneGesserit",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.ControlSpace",
|
||||
"name": "Control Space",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.HealerSeeker",
|
||||
"name": "LOC_Ability: Healer Seeker",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.HealingCapsule",
|
||||
"name": "LOC_Ability: Healing Capsule",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorGrenade_Stabilization",
|
||||
"name": "Stabilization Bomb",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorMine_Stabilization",
|
||||
"name": "Remote Stabilization Mine",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Blade3",
|
||||
"name": "XX_",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.Gunsmith",
|
||||
"name": "Gunsmith",
|
||||
"category": "Hidden",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.HunterSeeker",
|
||||
"name": "Hunter-Seeker",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.PoisonCapsuleLauncher",
|
||||
"name": "Poison Capsule",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.PoisonMine",
|
||||
"name": "Poison Mine",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.PortableGenerator",
|
||||
"name": "Source of Power",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SolidoDecoy",
|
||||
"name": "Solido Decoy",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.StunDart",
|
||||
"name": "Stunner",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorMine_Amplification",
|
||||
"name": "Gravity Mine",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorMine_Reduction",
|
||||
"name": "Anti-gravity Mine",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorWall",
|
||||
"name": "Shield Wall",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.TurretSeeker",
|
||||
"name": "The Sentinel",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Assassination1",
|
||||
"name": "Headshot Damage",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Assassination2",
|
||||
"name": "Assassin's Shot",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.MentalCalculus1",
|
||||
"name": "Garment Keeper",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.MentalCalculus2",
|
||||
"name": "Ranged Damage",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.MentalCalculus3",
|
||||
"name": "Tailoring",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.MentalCalculus4",
|
||||
"name": "Pistol Damage",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.MentalCalculus5",
|
||||
"name": "Rifle Damage",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneAssassination",
|
||||
"name": "XX_Assassination Capstone",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneMentalCalculus",
|
||||
"name": "XX_MentalCalculus Capstone",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneTactician",
|
||||
"name": "XX_Tactician Capstone",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Mentat1",
|
||||
"name": "XX_Mentat Phase 1",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Mentat2",
|
||||
"name": "XX_Mentat Phase 2",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Mentat3",
|
||||
"name": "XX_Mentat Phase 3",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.ExploitWeakness",
|
||||
"name": "Exploit Weakness",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.HeadShots",
|
||||
"name": "Marksman",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.IronWill",
|
||||
"name": "Iron Will",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.PoisonTooth",
|
||||
"name": "Poison Tooth",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.ShieldWeakpoint",
|
||||
"name": "Shield Overcharge",
|
||||
"category": "Mentat",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorPad",
|
||||
"name": "Suspensor Pad",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver1",
|
||||
"name": "Vehicle Repair",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver2",
|
||||
"name": "Fuel Efficient Driver",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver3",
|
||||
"name": "Vehicle Mining",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver4",
|
||||
"name": "Vehicle Scanning",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver5",
|
||||
"name": "Fuel Efficient Pilot",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Driver6",
|
||||
"name": "Sandcrawler Yield",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Explorer1",
|
||||
"name": "Cartographer",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Explorer2",
|
||||
"name": "Mountaineer",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Explorer3",
|
||||
"name": "Scanner Mastery",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Explorer4",
|
||||
"name": "Stillsuit Seals",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Explorer5",
|
||||
"name": "Spice Surveyor",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Scientist1",
|
||||
"name": "Cutteray Mining",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Scientist2",
|
||||
"name": "Dew Gathering",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Scientist3",
|
||||
"name": "Rerouting",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Scientist4",
|
||||
"name": "Deep Analysis",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Scientist5",
|
||||
"name": "Compaction",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneDriver",
|
||||
"name": "XX_Driver Capstone",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneExplorer",
|
||||
"name": "XX_Explorer Capstone",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneScientist",
|
||||
"name": "XX_Scientist Capstone",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Planetologist1",
|
||||
"name": "XX_Planetologist Phase 1",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Planetologist2",
|
||||
"name": "XX_Planetologist Phase 2",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Planetologist3",
|
||||
"name": "XX_Planetologist Phase 3",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.BatteryExpert",
|
||||
"name": "Conservation of Energy",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Science.m_PowerMax",
|
||||
"name": "Overcharge",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.VehicleHeat",
|
||||
"name": "Heat Management",
|
||||
"category": "Planetologist",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.BattleCry",
|
||||
"name": "Inspiration",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.CripplingStrike",
|
||||
"name": "Crippling Strike",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.DeflectionSlow",
|
||||
"name": "Deflection",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.KneeCharge",
|
||||
"name": "Knee Charge",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.RiposteBreak",
|
||||
"name": "Foil",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.RiposteInjure",
|
||||
"name": "Retaliate",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.Whirlwind",
|
||||
"name": "Eye of the Storm",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Aggression1",
|
||||
"name": "Field Medicine",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Aggression2",
|
||||
"name": "Optimized Hydration",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Aggression3",
|
||||
"name": "General Conditioning",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Aggression4",
|
||||
"name": "Desert Conditioning",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Blade1",
|
||||
"name": "Blade Damage",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Blade2",
|
||||
"name": "Long Blade Damage",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Resolve1",
|
||||
"name": "Bleed Tolerance",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Resolve2",
|
||||
"name": "Solid Stance",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.UnstoppableAttacks",
|
||||
"name": "Confidence",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneAggression",
|
||||
"name": "XX_Aggression Capstone",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneBlade",
|
||||
"name": "XX_Blade Capstone",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneResolve",
|
||||
"name": "XX_Resolve Capstone",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Swordmaster1",
|
||||
"name": "XX_Swordmaster Phase 1",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Swordmaster2",
|
||||
"name": "XX_Swordmaster Phase 2",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Swordmaster3",
|
||||
"name": "XX_Swordmaster Phase 3",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.MeleeChain",
|
||||
"name": "Dance of Blades",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.SprintStamina",
|
||||
"name": "Disciplined Breathing",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.ThriveOnDanger",
|
||||
"name": "Thrive on Danger",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.ToughLunge",
|
||||
"name": "Reckless Lunge",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.ParryBoost",
|
||||
"name": "Precise Parry",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.ShadowStrike",
|
||||
"name": "Prescient Strike",
|
||||
"category": "Swordmaster",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.AssaultSeeker",
|
||||
"name": "Assault Seeker",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.CablePull",
|
||||
"name": "Shigawire Claw",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.CollapseGrenade",
|
||||
"name": "Collapse Grenade",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.EnergyCapsule",
|
||||
"name": "Energy Capsule",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.FragGrenade",
|
||||
"name": "Explosive Grenade",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.MagneticAttractor",
|
||||
"name": "Attractor Field",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorBlast",
|
||||
"name": "Suspensor Blast",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorGrenade_Amplification",
|
||||
"name": "Gravity Field",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Ability.SuspensorGrenade_Reduction",
|
||||
"name": "Anti-gravity Field",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.SuspensorTech1",
|
||||
"name": "Suspensor Efficiency",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry1",
|
||||
"name": "Ranged Damage",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry2",
|
||||
"name": "Disruptor Damage",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry3",
|
||||
"name": "Scattergun Damage",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry4",
|
||||
"name": "Field Maintenance",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry5",
|
||||
"name": "Heavy Weapon Damage",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Attribute.Weaponry6",
|
||||
"name": "Gunsmith",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneGadgets",
|
||||
"name": "XX_Gadgets Capstone",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneSuspensorTech",
|
||||
"name": "XX_Suspensor Tech Capstone",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.CapstoneWeaponry",
|
||||
"name": "XX_Weaponry Capstone",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Trooper1",
|
||||
"name": "XX_Trooper Phase 1",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Trooper2",
|
||||
"name": "XX_Trooper Phase 2",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Key.Trooper3",
|
||||
"name": "XX_Trooper Phase 3",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.BodyShots",
|
||||
"name": "Center of Mass",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.DeathFromAbove",
|
||||
"name": "Death from Above",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.HeavyWeaponNaib",
|
||||
"name": "Heavy Weapon Agility",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.SuspensorDash",
|
||||
"name": "Suspensor Dash",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
},
|
||||
{
|
||||
"id": "Skills.Perk.TrooperCooldowns",
|
||||
"name": "Battle Hardened",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 3
|
||||
},
|
||||
{
|
||||
"id": "Skills.Spice.GadgetReload",
|
||||
"name": "Reflexive Reload",
|
||||
"category": "Trooper",
|
||||
"maxLevel": 1
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"id": "Sandbike",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_Sandbike_CHOAM.BP_Sandbike_CHOAM_C",
|
||||
"templates": [
|
||||
"T1_ExtraSeat",
|
||||
"T2_Inventory",
|
||||
"T3_Boost",
|
||||
"T4_Scanner",
|
||||
"T5",
|
||||
"T6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Buggy",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_Buggy_CHOAM.BP_Buggy_CHOAM_C",
|
||||
"templates": [
|
||||
"T3_Inventory",
|
||||
"T4_Boost",
|
||||
"T5_Mining",
|
||||
"T6_Combat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Tank",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_Tank_CHOAM.BP_Tank_CHOAM_C",
|
||||
"templates": [
|
||||
"T6_CombatFire",
|
||||
"T6_CombatDart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Sandcrawler",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_SandCrawler_CHOAM.BP_SandCrawler_CHOAM_C",
|
||||
"templates": [
|
||||
"T6_Harvesting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "OrnithopterLight",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/FlyingVehicles/BP_LightOrnithopter_Choam.BP_LightOrnithopter_Choam_C",
|
||||
"templates": [
|
||||
"T4_Inventory",
|
||||
"T5_Boost",
|
||||
"T6_Combat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "OrnithopterMedium",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/FlyingVehicles/BP_MediumOrnithopter_CHOAM.BP_MediumOrnithopter_CHOAM_C",
|
||||
"templates": [
|
||||
"T5_Inventory",
|
||||
"T6_Combat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "OrnithopterTransport",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/FlyingVehicles/BP_TransportOrnithopter_CHOAM.BP_TransportOrnithopter_CHOAM_C",
|
||||
"templates": [
|
||||
"T6_Boost"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "TreadWheel",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_TreadWheel.BP_TreadWheel_C",
|
||||
"templates": [
|
||||
"T4_Passenger",
|
||||
"T5_Inventory",
|
||||
"T6_Boost"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ContainerVehicle",
|
||||
"actor_class": "/Game/Dune/Systems/Vehicles/Blueprints/GroundVehicles/BP_ContainerVehicle.BP_ContainerVehicle_C",
|
||||
"templates": [
|
||||
"Container"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
[
|
||||
{
|
||||
"id": "Reward.XP.Large",
|
||||
"family": "Reward.XP",
|
||||
"constant": "ContractLarge"
|
||||
},
|
||||
{
|
||||
"id": "Reward.XP.Medium",
|
||||
"family": "Reward.XP",
|
||||
"constant": "ContractMedium"
|
||||
},
|
||||
{
|
||||
"id": "Reward.XP.Small",
|
||||
"family": "Reward.XP",
|
||||
"constant": "ContractSmall"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.Contract.Large",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.Contract.Medium",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.Contract.Small",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.KilledEnemy.NPC",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.KilledEnemy.NPC_Boss",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.KilledEnemy.NPC_Elite",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.KilledEnemy.NPC_Minion",
|
||||
"family": "XP.Combat",
|
||||
"constant": "ExtraSmall"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.KilledEnemy.Player",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.WreckVehicle.Large",
|
||||
"family": "XP.Combat",
|
||||
"constant": "ExtraLarge"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.WreckVehicle.Medium",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Combat.WreckVehicle.Small",
|
||||
"family": "XP.Combat",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.AreaReveal",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Contract.Large",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Contract.Medium",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Contract.Small",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.MarkerReveal.Large",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Larger"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.MarkerReveal.Medium",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.MarkerReveal.Small",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Pickup.Large",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "ExtraLarge"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Pickup.Medium",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Larger"
|
||||
},
|
||||
{
|
||||
"id": "XP.Exploration.Pickup.Small",
|
||||
"family": "XP.Exploration",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Contract.Large",
|
||||
"family": "XP.Industry",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Contract.Medium",
|
||||
"family": "XP.Industry",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Contract.Small",
|
||||
"family": "XP.Industry",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Craft.Large",
|
||||
"family": "XP.Industry",
|
||||
"constant": "NoXP"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Craft.Medium",
|
||||
"family": "XP.Industry",
|
||||
"constant": "NoXP"
|
||||
},
|
||||
{
|
||||
"id": "XP.Industry.Craft.Small",
|
||||
"family": "XP.Industry",
|
||||
"constant": "NoXP"
|
||||
},
|
||||
{
|
||||
"id": "XP.Journey.Major",
|
||||
"family": "XP.Journey",
|
||||
"constant": "Epic"
|
||||
},
|
||||
{
|
||||
"id": "XP.Journey.Short",
|
||||
"family": "XP.Journey",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Journey.Standard",
|
||||
"family": "XP.Journey",
|
||||
"constant": "Larger"
|
||||
},
|
||||
{
|
||||
"id": "XP.Journey.VeryShort",
|
||||
"family": "XP.Journey",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Politics.Contract.Large",
|
||||
"family": "XP.Politics",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Politics.Contract.Medium",
|
||||
"family": "XP.Politics",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Politics.Contract.Small",
|
||||
"family": "XP.Politics",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Contract.Large",
|
||||
"family": "XP.Science",
|
||||
"constant": "Large"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Contract.Medium",
|
||||
"family": "XP.Science",
|
||||
"constant": "Medium"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Contract.Small",
|
||||
"family": "XP.Science",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Harvest.Common",
|
||||
"family": "XP.Science",
|
||||
"constant": "MicroPlus"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Harvest.Dew",
|
||||
"family": "XP.Science",
|
||||
"constant": "Micro"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Harvest.Rare",
|
||||
"family": "XP.Science",
|
||||
"constant": "Small"
|
||||
},
|
||||
{
|
||||
"id": "XP.Science.Harvest.Uncommon",
|
||||
"family": "XP.Science",
|
||||
"constant": "ExtraSmall"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/sbin/openrc-run
|
||||
# Alpine OpenRC init script for the Dune server management service.
|
||||
|
||||
name="dune-server-service"
|
||||
description="Dune server management service (Rust)"
|
||||
|
||||
command="/opt/dune-server-service/dune-server-service"
|
||||
command_user="dune:dune"
|
||||
pidfile="/run/dune-server-service.pid"
|
||||
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=10
|
||||
respawn_max=0
|
||||
# supervise-daemon redirects stdout/stderr to the file so we can read tracing
|
||||
# output during boot/crash diagnosis.
|
||||
supervise_daemon_args="--stdout /var/log/dune-server-service.log --stderr /var/log/dune-server-service.log"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath --directory --owner dune:dune --mode 0755 /home/dune/.dune
|
||||
checkpath --directory --owner dune:dune --mode 0700 /home/dune/.dune/state
|
||||
checkpath --file --owner dune:dune --mode 0644 /var/log/dune-server-service.log
|
||||
export DUNE_SERVICE_HOME="${DUNE_SERVICE_HOME:-/home/dune}"
|
||||
export DUNE_DASHBOARD_PORT="${DUNE_DASHBOARD_PORT:-29187}"
|
||||
if [ -f /etc/dune-server-service.env ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/dune-server-service.env
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::{find_command, CommandSpec, FieldKind, FieldSpec, ValidationError};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum BuildKind {
|
||||
/// Inner payload is `{ServerCommand: id, ...validated_values}`.
|
||||
Passthrough,
|
||||
/// `ServiceBroadcast` has a custom shape (Generic vs ServerShutdown).
|
||||
ServiceBroadcast,
|
||||
}
|
||||
|
||||
/// Validate raw form input against a command spec and build the inner JSON
|
||||
/// payload that goes inside the MQ envelope. Faithful port of
|
||||
/// `validateAndBuild` from `src/admin/commands.ts`.
|
||||
pub fn validate_and_build(
|
||||
command_id: &str,
|
||||
values: &Map<String, Value>,
|
||||
) -> Result<Value, ValidationError> {
|
||||
let spec = find_command(command_id)
|
||||
.ok_or_else(|| ValidationError::UnknownCommand(command_id.to_string()))?;
|
||||
|
||||
let mut normalized = Map::new();
|
||||
for field in spec.fields {
|
||||
let raw = values.get(field.key);
|
||||
let is_empty = match raw {
|
||||
None => true,
|
||||
Some(Value::Null) => true,
|
||||
Some(Value::String(s)) if s.is_empty() => true,
|
||||
_ => false,
|
||||
};
|
||||
if is_empty {
|
||||
if let Some(default) = default_for(field) {
|
||||
normalized.insert(field.key.to_string(), default);
|
||||
} else if field.required.unwrap_or(false) {
|
||||
return Err(ValidationError::MissingField(field.key.to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let coerced = coerce(field.kind, raw.unwrap()).ok_or_else(|| {
|
||||
ValidationError::WrongType(field.key.to_string(), kind_str(field.kind))
|
||||
})?;
|
||||
normalized.insert(field.key.to_string(), coerced);
|
||||
}
|
||||
|
||||
if matches!(spec.build, BuildKind::ServiceBroadcast) {
|
||||
let bt = normalized
|
||||
.get("BroadcastType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Generic");
|
||||
if bt == "Generic"
|
||||
&& (normalized
|
||||
.get("Title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
|| normalized
|
||||
.get("Body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.is_empty())
|
||||
{
|
||||
return Err(ValidationError::BroadcastNeedsTitleAndBody);
|
||||
}
|
||||
// ServerShutdown fields are auto-defaulted; nothing else to validate
|
||||
// beyond what the parser docs say. ShouldCancel handling is in
|
||||
// build_service_broadcast.
|
||||
}
|
||||
|
||||
Ok(build(spec, &normalized))
|
||||
}
|
||||
|
||||
pub fn build(spec: &CommandSpec, normalized: &Map<String, Value>) -> Value {
|
||||
match spec.build {
|
||||
BuildKind::Passthrough => {
|
||||
let mut obj = Map::new();
|
||||
obj.insert(
|
||||
"ServerCommand".to_string(),
|
||||
Value::String(spec.id.to_string()),
|
||||
);
|
||||
for (k, v) in normalized {
|
||||
obj.insert(k.clone(), v.clone());
|
||||
}
|
||||
// The seabass AwardXP server-command handler appears to require
|
||||
// `Category` to be present in the payload (otherwise it silently
|
||||
// no-ops). The value itself is ignored — every category lands as
|
||||
// generic player XP — so we always inject "Combat" so the user
|
||||
// doesn't have to see / fill the field.
|
||||
if spec.id == "AwardXP" && !obj.contains_key("Category") {
|
||||
obj.insert("Category".to_string(), Value::String("Combat".to_string()));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
BuildKind::ServiceBroadcast => build_service_broadcast(normalized),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_service_broadcast(values: &Map<String, Value>) -> Value {
|
||||
let bt = values
|
||||
.get("BroadcastType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Generic");
|
||||
if bt == "ServerShutdown" {
|
||||
let should_cancel = values
|
||||
.get("ShouldCancel")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if should_cancel {
|
||||
// Per server-commands-broadcast.md: ShouldCancel short-circuits
|
||||
// the parser; no other shutdown metadata required.
|
||||
return json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"BroadcastPayload": { "ShouldCancel": true },
|
||||
});
|
||||
}
|
||||
// Required by the parser for non-cancel shutdowns. Defaults match the
|
||||
// spec's `default_for` entries; we keep them belt-and-braces here.
|
||||
let shutdown_type = values
|
||||
.get("ShutdownType")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Restart");
|
||||
let lead_secs = values
|
||||
.get("ShutdownDuration")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(600)
|
||||
.max(1);
|
||||
let frequency = values
|
||||
.get("BroadcastFrequency")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(60)
|
||||
.max(1);
|
||||
let broadcast_duration = values
|
||||
.get("BroadcastDuration")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(30)
|
||||
.max(1);
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
return json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"BroadcastPayload": {
|
||||
"ShutdownType": shutdown_type,
|
||||
"DateTimestamp": now,
|
||||
"ShutdownDuration": lead_secs,
|
||||
"ShutdownTimestamp": now + lead_secs,
|
||||
"BroadcastFrequency": frequency,
|
||||
"BroadcastDuration": broadcast_duration,
|
||||
},
|
||||
});
|
||||
}
|
||||
let title = values
|
||||
.get("Title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let body = values
|
||||
.get("Body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration = values
|
||||
.get("BroadcastDuration")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(30);
|
||||
json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "Generic",
|
||||
"BroadcastPayload": {
|
||||
"BroadcastDuration": duration,
|
||||
"LocalizedText": [
|
||||
{"Key": "en", "Title": title, "Body": body},
|
||||
{"Key": "en-US", "Title": title, "Body": body},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The specs file uses const stub defaults so the array can sit in static
|
||||
/// storage. Real defaults are materialized here at runtime based on field key.
|
||||
fn default_for(field: &FieldSpec) -> Option<Value> {
|
||||
match field.key {
|
||||
"Quantity" => Some(json!(1)),
|
||||
"Durability" => Some(json!(1.0)),
|
||||
"WaterAmount" => Some(json!(1_000_000)),
|
||||
"Experience" => Some(json!(1000)),
|
||||
"Level" => Some(json!(1)),
|
||||
"SkillPoints" => Some(json!(0)),
|
||||
"Category" => Some(json!("Combat")),
|
||||
"BroadcastType" => Some(json!("Generic")),
|
||||
"BroadcastDuration" => Some(json!(30)),
|
||||
"ShutdownType" => Some(json!("Restart")),
|
||||
"ShutdownDuration" => Some(json!(600)),
|
||||
"BroadcastFrequency" => Some(json!(60)),
|
||||
"ShouldCancel" => Some(json!(false)),
|
||||
// TemplateName default removed — the frontend auto-picks the first
|
||||
// valid template per the selected vehicle's available list.
|
||||
"Persistent" => Some(json!(1.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn coerce(kind: FieldKind, raw: &Value) -> Option<Value> {
|
||||
match kind {
|
||||
FieldKind::String | FieldKind::Text | FieldKind::Select => match raw {
|
||||
Value::String(s) => Some(Value::String(s.clone())),
|
||||
Value::Number(n) => Some(Value::String(n.to_string())),
|
||||
Value::Bool(b) => Some(Value::String(b.to_string())),
|
||||
_ => None,
|
||||
},
|
||||
FieldKind::Int => {
|
||||
let n = number_value(raw)?;
|
||||
if n.fract() == 0.0 && n.is_finite() {
|
||||
Some(json!(n as i64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
FieldKind::Float => {
|
||||
let n = number_value(raw)?;
|
||||
if n.is_finite() {
|
||||
Some(json!(n))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
FieldKind::Bool => match raw {
|
||||
Value::Bool(b) => Some(Value::Bool(*b)),
|
||||
Value::String(s) => match s.as_str() {
|
||||
"true" | "1" => Some(Value::Bool(true)),
|
||||
"false" | "0" => Some(Value::Bool(false)),
|
||||
_ => None,
|
||||
},
|
||||
Value::Number(n) => match n.as_i64() {
|
||||
Some(0) => Some(Value::Bool(false)),
|
||||
Some(1) => Some(Value::Bool(true)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn number_value(raw: &Value) -> Option<f64> {
|
||||
match raw {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
Value::String(s) => s.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_str(kind: FieldKind) -> &'static str {
|
||||
match kind {
|
||||
FieldKind::String => "string",
|
||||
FieldKind::Int => "int",
|
||||
FieldKind::Float => "float",
|
||||
FieldKind::Bool => "bool",
|
||||
FieldKind::Select => "select",
|
||||
FieldKind::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn into_map(v: Value) -> Map<String, Value> {
|
||||
v.as_object().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_includes_server_command_and_defaults() {
|
||||
let raw = into_map(json!({"PlayerId": "P1", "ItemName": "UniqueSda6"}));
|
||||
let inner = validate_and_build("AddItemToInventory", &raw).unwrap();
|
||||
assert_eq!(inner["ServerCommand"], "AddItemToInventory");
|
||||
assert_eq!(inner["PlayerId"], "P1");
|
||||
assert_eq!(inner["ItemName"], "UniqueSda6");
|
||||
assert_eq!(inner["Quantity"], 1);
|
||||
assert_eq!(inner["Durability"], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_errors() {
|
||||
let raw = into_map(json!({"PlayerId": "P1"}));
|
||||
let err = validate_and_build("AddItemToInventory", &raw).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::MissingField(ref k) if k == "ItemName"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_errors() {
|
||||
let raw = into_map(json!({}));
|
||||
let err = validate_and_build("DoesNotExist", &raw).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::UnknownCommand(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_broadcast_generic_requires_title_and_body() {
|
||||
let raw = into_map(json!({"BroadcastType": "Generic"}));
|
||||
let err = validate_and_build("ServiceBroadcast", &raw).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::BroadcastNeedsTitleAndBody));
|
||||
|
||||
let ok_raw = into_map(json!({"Title": "Hi", "Body": "msg", "BroadcastDuration": 15}));
|
||||
let inner = validate_and_build("ServiceBroadcast", &ok_raw).unwrap();
|
||||
assert_eq!(inner["BroadcastType"], "Generic");
|
||||
assert_eq!(inner["BroadcastPayload"]["BroadcastDuration"], 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_broadcast_server_shutdown_populates_countdown_payload() {
|
||||
let raw = into_map(json!({"BroadcastType": "ServerShutdown"}));
|
||||
let inner = validate_and_build("ServiceBroadcast", &raw).unwrap();
|
||||
assert_eq!(inner["BroadcastType"], "ServerShutdown");
|
||||
let payload = &inner["BroadcastPayload"];
|
||||
// Defaults applied
|
||||
assert_eq!(payload["ShutdownType"], "Restart");
|
||||
assert_eq!(payload["ShutdownDuration"], 600);
|
||||
assert_eq!(payload["BroadcastFrequency"], 60);
|
||||
assert_eq!(payload["BroadcastDuration"], 30);
|
||||
// ShutdownTimestamp = DateTimestamp + ShutdownDuration
|
||||
let ts = payload["ShutdownTimestamp"].as_i64().unwrap();
|
||||
let date_ts = payload["DateTimestamp"].as_i64().unwrap();
|
||||
assert_eq!(ts - date_ts, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_broadcast_server_shutdown_honors_overrides() {
|
||||
let raw = into_map(json!({
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"ShutdownType": "Maintenance",
|
||||
"ShutdownDuration": 900,
|
||||
"BroadcastFrequency": 30,
|
||||
"BroadcastDuration": 15,
|
||||
}));
|
||||
let inner = validate_and_build("ServiceBroadcast", &raw).unwrap();
|
||||
let payload = &inner["BroadcastPayload"];
|
||||
assert_eq!(payload["ShutdownType"], "Maintenance");
|
||||
assert_eq!(payload["ShutdownDuration"], 900);
|
||||
assert_eq!(payload["BroadcastFrequency"], 30);
|
||||
assert_eq!(payload["BroadcastDuration"], 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_broadcast_cancel_short_circuits_payload() {
|
||||
let raw = into_map(json!({
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"ShouldCancel": true,
|
||||
// Other fields should be ignored when cancelling.
|
||||
"ShutdownDuration": 999,
|
||||
}));
|
||||
let inner = validate_and_build("ServiceBroadcast", &raw).unwrap();
|
||||
let payload = &inner["BroadcastPayload"];
|
||||
assert_eq!(payload["ShouldCancel"], true);
|
||||
assert!(payload.get("ShutdownType").is_none());
|
||||
assert!(payload.get("ShutdownTimestamp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_field_rejects_floats() {
|
||||
let raw = into_map(json!({"PlayerId": "P", "ItemName": "X", "Quantity": 2.5}));
|
||||
let err = validate_and_build("AddItemToInventory", &raw).unwrap_err();
|
||||
assert!(matches!(err, ValidationError::WrongType(_, "int")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod build;
|
||||
pub mod specs;
|
||||
|
||||
pub use build::{build, validate_and_build, BuildKind};
|
||||
pub use specs::SPECS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FieldKind {
|
||||
String,
|
||||
Int,
|
||||
Float,
|
||||
Bool,
|
||||
Select,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Category {
|
||||
Items,
|
||||
Movement,
|
||||
Broadcast,
|
||||
Progression,
|
||||
Player,
|
||||
Journey,
|
||||
Exec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SelectOption {
|
||||
pub value: &'static str,
|
||||
pub label: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FieldSpec {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub kind: FieldKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub helper: Option<&'static str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<&'static [SelectOption]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub category: Category,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub destructive: Option<bool>,
|
||||
#[serde(rename = "needsPlayer")]
|
||||
pub needs_player: bool,
|
||||
#[serde(rename = "allowAllPlayers")]
|
||||
pub allow_all_players: bool,
|
||||
pub describe: &'static str,
|
||||
pub fields: &'static [FieldSpec],
|
||||
#[serde(skip)]
|
||||
pub build: BuildKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidationError {
|
||||
#[error("unknown command: {0}")]
|
||||
UnknownCommand(String),
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(String),
|
||||
#[error("field {0} must be {1}")]
|
||||
WrongType(String, &'static str),
|
||||
#[error("Generic broadcast requires Title and Body")]
|
||||
BroadcastNeedsTitleAndBody,
|
||||
}
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
SPECS.iter().find(|s| s.id == id)
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
use super::{BuildKind, Category, CommandSpec, FieldKind, FieldSpec, SelectOption};
|
||||
|
||||
const FIELD_PLAYER: FieldSpec = FieldSpec {
|
||||
key: "PlayerId",
|
||||
label: "Player",
|
||||
kind: FieldKind::String,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: Some("FLS player id, or \"*\" for all online"),
|
||||
options: None,
|
||||
};
|
||||
|
||||
// Journey commands removed 2026-05-26: published successfully but the
|
||||
// server-command handlers don't apply the state changes (live-tested).
|
||||
// `FIELD_STORY_NODE` + `PLAYER_AND_STORY` retired with them.
|
||||
|
||||
// XP category options were removed 2026-05-26 — live-testing showed the
|
||||
// server ignores Category and always grants generic player XP regardless of
|
||||
// which value is sent. Keeping AwardXP as a player+amount command only.
|
||||
|
||||
const BROADCAST_TYPES: &[SelectOption] = &[
|
||||
SelectOption {
|
||||
value: "Generic",
|
||||
label: "Generic",
|
||||
},
|
||||
SelectOption {
|
||||
value: "ServerShutdown",
|
||||
label: "ServerShutdown",
|
||||
},
|
||||
];
|
||||
|
||||
const SHUTDOWN_TYPES: &[SelectOption] = &[
|
||||
SelectOption {
|
||||
value: "Restart",
|
||||
label: "Restart",
|
||||
},
|
||||
SelectOption {
|
||||
value: "Maintenance",
|
||||
label: "Maintenance",
|
||||
},
|
||||
SelectOption {
|
||||
value: "Update",
|
||||
label: "Update",
|
||||
},
|
||||
];
|
||||
|
||||
const ADD_ITEM_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "ItemName",
|
||||
label: "ItemName",
|
||||
kind: FieldKind::String,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: Some("Internal FName, case-insensitive"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Quantity",
|
||||
label: "Quantity",
|
||||
kind: FieldKind::Int,
|
||||
required: None,
|
||||
default: Some(json_const_i(1)),
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Durability",
|
||||
label: "Durability",
|
||||
kind: FieldKind::Float,
|
||||
required: None,
|
||||
default: Some(json_const_f(1.0)),
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const SERVICE_BROADCAST_FIELDS: &[FieldSpec] = &[
|
||||
FieldSpec {
|
||||
key: "BroadcastType",
|
||||
label: "BroadcastType",
|
||||
kind: FieldKind::Select,
|
||||
required: Some(true),
|
||||
default: Some(json_const_s("Generic")),
|
||||
helper: None,
|
||||
options: Some(BROADCAST_TYPES),
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Title",
|
||||
label: "Title",
|
||||
kind: FieldKind::String,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: Some("required for Generic"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Body",
|
||||
label: "Body",
|
||||
kind: FieldKind::Text,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: Some("required for Generic"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "BroadcastDuration",
|
||||
label: "Display duration (in seconds)",
|
||||
kind: FieldKind::Int,
|
||||
required: None,
|
||||
default: Some(json_const_i(30)),
|
||||
helper: Some("How long each broadcast pulse stays on-screen"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "ShutdownType",
|
||||
label: "Shutdown type",
|
||||
kind: FieldKind::Select,
|
||||
required: None,
|
||||
default: Some(json_const_s("Restart")),
|
||||
helper: Some("ServerShutdown only"),
|
||||
options: Some(SHUTDOWN_TYPES),
|
||||
},
|
||||
FieldSpec {
|
||||
key: "ShutdownDuration",
|
||||
label: "Lead time (in seconds)",
|
||||
kind: FieldKind::Int,
|
||||
required: None,
|
||||
default: Some(json_const_i(600)),
|
||||
helper: Some("ServerShutdown only - seconds until the shutdown fires"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "BroadcastFrequency",
|
||||
label: "Repeat frequency (in seconds)",
|
||||
kind: FieldKind::Int,
|
||||
required: None,
|
||||
default: Some(json_const_i(60)),
|
||||
helper: Some("ServerShutdown only - how often the countdown re-broadcasts"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "ShouldCancel",
|
||||
label: "Cancel pending shutdown",
|
||||
kind: FieldKind::Bool,
|
||||
required: None,
|
||||
default: Some(json_const_b(false)),
|
||||
helper: Some("ServerShutdown only - cancels an in-flight countdown; ignores other fields"),
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const ONLY_PLAYER: &[FieldSpec] = &[FIELD_PLAYER];
|
||||
|
||||
const WATER_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "WaterAmount",
|
||||
label: "WaterAmount",
|
||||
kind: FieldKind::Int,
|
||||
required: None,
|
||||
default: Some(json_const_i(1_000_000)),
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const AWARD_XP_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "Experience",
|
||||
label: "Experience",
|
||||
kind: FieldKind::Int,
|
||||
required: Some(true),
|
||||
default: Some(json_const_i(1000)),
|
||||
helper: Some("Generic player XP — the server ignores any track/category fields."),
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
// AwardXPByEventTag was tried 2026-05-26 — server reports
|
||||
// `Deserialized message has unknown Server Command 'AwardXPByEventTag'`.
|
||||
// The binary has `ADuneCharacter::AwardXPByEventTag` but no MQ handler.
|
||||
|
||||
const SKILL_MODULE_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "Module",
|
||||
label: "Module",
|
||||
kind: FieldKind::String,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: Some("e.g. Swordmaster_T1"),
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Level",
|
||||
label: "Level",
|
||||
kind: FieldKind::Int,
|
||||
required: Some(true),
|
||||
default: Some(json_const_i(1)),
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const SKILL_POINTS_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "SkillPoints",
|
||||
label: "SkillPoints",
|
||||
kind: FieldKind::Int,
|
||||
required: Some(true),
|
||||
default: Some(json_const_i(0)),
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const TELEPORT_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "X",
|
||||
label: "X",
|
||||
kind: FieldKind::Float,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Y",
|
||||
label: "Y",
|
||||
kind: FieldKind::Float,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Z",
|
||||
label: "Z",
|
||||
kind: FieldKind::Float,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "Yaw",
|
||||
label: "Yaw",
|
||||
kind: FieldKind::Float,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "CamPitch",
|
||||
label: "CamPitch",
|
||||
kind: FieldKind::Float,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "CamYaw",
|
||||
label: "CamYaw",
|
||||
kind: FieldKind::Float,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
FieldSpec {
|
||||
key: "CamRoll",
|
||||
label: "CamRoll",
|
||||
kind: FieldKind::Float,
|
||||
required: None,
|
||||
default: None,
|
||||
helper: None,
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const CHEAT_SCRIPT_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec {
|
||||
key: "ScriptName",
|
||||
label: "ScriptName",
|
||||
kind: FieldKind::String,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: Some("[CheatScript.<name>] section from DefaultGame.ini (e.g. PlaytestSetupAdmin, UnlockAllSkills)"),
|
||||
options: None,
|
||||
},
|
||||
];
|
||||
|
||||
const SERVER_EXEC_FIELDS: &[FieldSpec] = &[FieldSpec {
|
||||
key: "Exec",
|
||||
label: "Exec",
|
||||
kind: FieldKind::String,
|
||||
required: Some(true),
|
||||
default: None,
|
||||
helper: Some("Raw console/exec command string"),
|
||||
options: None,
|
||||
}];
|
||||
|
||||
const SPAWN_VEHICLE_FIELDS: &[FieldSpec] = &[
|
||||
FIELD_PLAYER,
|
||||
FieldSpec { key: "ClassName", label: "Vehicle", kind: FieldKind::String, required: Some(true), default: None, helper: Some("DT_VehicleTemplates row key (e.g. Sandbike, Buggy)"), options: None },
|
||||
FieldSpec { key: "X", label: "X", kind: FieldKind::Float, required: Some(true), default: None, helper: None, options: None },
|
||||
FieldSpec { key: "Y", label: "Y", kind: FieldKind::Float, required: Some(true), default: None, helper: None, options: None },
|
||||
FieldSpec { key: "Z", label: "Z", kind: FieldKind::Float, required: Some(true), default: None, helper: None, options: None },
|
||||
FieldSpec { key: "Rotation", label: "Rotation", kind: FieldKind::Float, required: None, default: None, helper: None, options: None },
|
||||
FieldSpec { key: "TemplateName", label: "TemplateName", kind: FieldKind::String, required: Some(true), default: None, helper: Some("Template variant key from DT_VehicleTemplates (e.g. T6_Combat). Combobox above pre-fills the first valid one for the picked vehicle."), options: None },
|
||||
FieldSpec { key: "Persistent", label: "Persistent", kind: FieldKind::Float, required: None, default: Some(json_const_f(1.0)), helper: Some("0.0 = transient, 1.0 = persistent"), options: None },
|
||||
FieldSpec { key: "Faction", label: "Faction", kind: FieldKind::String, required: None, default: None, helper: Some("(blank = default)"), options: None },
|
||||
];
|
||||
|
||||
pub static SPECS: &[CommandSpec] = &[
|
||||
CommandSpec {
|
||||
id: "AddItemToInventory",
|
||||
label: "Grant item",
|
||||
category: Category::Items,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Adds an item to the targeted player(s) inventory.",
|
||||
fields: ADD_ITEM_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "ServiceBroadcast",
|
||||
label: "Broadcast",
|
||||
category: Category::Broadcast,
|
||||
destructive: None,
|
||||
needs_player: false,
|
||||
allow_all_players: false,
|
||||
describe: "Server-wide broadcast (Generic) or ServerShutdown notice.",
|
||||
fields: SERVICE_BROADCAST_FIELDS,
|
||||
build: BuildKind::ServiceBroadcast,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "KickPlayer",
|
||||
label: "Kick player",
|
||||
category: Category::Player,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Disconnects the targeted player(s).",
|
||||
fields: ONLY_PLAYER,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "CleanPlayerInventory",
|
||||
label: "Clean inventory",
|
||||
category: Category::Player,
|
||||
destructive: Some(true),
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Wipes the targeted player(s) inventory. Destructive.",
|
||||
fields: ONLY_PLAYER,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "ResetProgression",
|
||||
label: "Reset progression",
|
||||
category: Category::Player,
|
||||
destructive: Some(true),
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Wipes XP/skills/journey progress. Destructive.",
|
||||
fields: ONLY_PLAYER,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "UpdateAllWaterFillables",
|
||||
label: "Refill water",
|
||||
category: Category::Player,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Refills water in fillable containers carried by the player.",
|
||||
fields: WATER_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "AwardXP",
|
||||
label: "Award XP",
|
||||
category: Category::Progression,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Adds generic player XP (server ignores any track/category fields).",
|
||||
fields: AWARD_XP_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "SkillsSetModuleLevel",
|
||||
label: "Set skill module level",
|
||||
category: Category::Progression,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Sets the level of a skill module for the player.",
|
||||
fields: SKILL_MODULE_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "SkillsSetUnspentSkillPoints",
|
||||
label: "Set unspent skill points",
|
||||
category: Category::Progression,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Sets the unspent skill points pool.",
|
||||
fields: SKILL_POINTS_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "TeleportTo",
|
||||
label: "Teleport (safe)",
|
||||
category: Category::Movement,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: false,
|
||||
describe: "Teleports player to coordinates, snapping to safe location.",
|
||||
fields: TELEPORT_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "TeleportToExact",
|
||||
label: "Teleport (exact)",
|
||||
category: Category::Movement,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: false,
|
||||
describe: "Teleports to exact coordinates with no safe-location snap.",
|
||||
fields: TELEPORT_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "SpawnVehicleAt",
|
||||
label: "Spawn vehicle",
|
||||
category: Category::Movement,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: false,
|
||||
describe: "Spawns a vehicle at coordinates for the player.",
|
||||
fields: SPAWN_VEHICLE_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "CheatScript",
|
||||
label: "Cheat script (raw)",
|
||||
category: Category::Exec,
|
||||
destructive: None,
|
||||
needs_player: true,
|
||||
allow_all_players: true,
|
||||
describe: "Raw `CheatScript` MQ command. Live-tested no-op against seabass servers — the handler logs the call but applies no state. Kept for protocol parity / future Funcom fixes.",
|
||||
fields: CHEAT_SCRIPT_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "ServerExec",
|
||||
label: "Server exec (raw)",
|
||||
category: Category::Exec,
|
||||
destructive: None,
|
||||
needs_player: false,
|
||||
allow_all_players: false,
|
||||
describe: "Raw `ServerExec` MQ command. Live-tested no-op against seabass servers — publishes successfully but does not execute useful commands. Kept for protocol parity.",
|
||||
fields: SERVER_EXEC_FIELDS,
|
||||
build: BuildKind::Passthrough,
|
||||
},
|
||||
// Journey* commands removed 2026-05-26: published successfully and the
|
||||
// server-command handlers fire, but no observable state change in DB or
|
||||
// gameplay (live-tested). The `journey-nodes.json` data file remains in
|
||||
// case a working path resurfaces.
|
||||
//
|
||||
// RunLuaScriptFile still omitted — non-shipping path.
|
||||
];
|
||||
|
||||
const fn json_const_i(n: i64) -> serde_json::Value {
|
||||
// const fn body: we can't actually call serde_json::json! macro inside const yet;
|
||||
// workaround: rely on a Lazy at first use. Inline construction below.
|
||||
let _ = n;
|
||||
serde_json::Value::Null
|
||||
}
|
||||
const fn json_const_f(n: f64) -> serde_json::Value {
|
||||
let _ = n;
|
||||
serde_json::Value::Null
|
||||
}
|
||||
const fn json_const_s(s: &'static str) -> serde_json::Value {
|
||||
let _ = s;
|
||||
serde_json::Value::Null
|
||||
}
|
||||
const fn json_const_b(b: bool) -> serde_json::Value {
|
||||
let _ = b;
|
||||
serde_json::Value::Null
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ITEMS_JSON: &[u8] = include_bytes!("../../data/items.json");
|
||||
const VEHICLES_JSON: &[u8] = include_bytes!("../../data/vehicles.json");
|
||||
const SKILL_MODULES_JSON: &[u8] = include_bytes!("../../data/skill-modules.json");
|
||||
const JOURNEY_NODES_JSON: &[u8] = include_bytes!("../../data/journey-nodes.json");
|
||||
const XP_EVENT_TAGS_JSON: &[u8] = include_bytes!("../../data/xp-event-tags.json");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Vehicle {
|
||||
pub id: String,
|
||||
pub actor_class: String,
|
||||
#[serde(default)]
|
||||
pub templates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillModule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: String,
|
||||
#[serde(rename = "maxLevel", default = "default_max_level")]
|
||||
pub max_level: u32,
|
||||
}
|
||||
|
||||
fn default_max_level() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct XpEventTag {
|
||||
/// Full XP.* GameplayTag from DT_XPEventsDataTable.
|
||||
pub id: String,
|
||||
/// Family (e.g. "XP.Combat") for grouping in the picker.
|
||||
pub family: String,
|
||||
/// XP constant row name (e.g. "Small", "Large") — informational.
|
||||
pub constant: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JourneyNode {
|
||||
/// Full dotted path the server expects (e.g.
|
||||
/// `DA_MQ_ANewBeginning.Aql No 1.Craft the Stillsuit`).
|
||||
pub id: String,
|
||||
/// Display label — last segment of the path.
|
||||
pub label: String,
|
||||
/// Parent journey card name.
|
||||
pub card: String,
|
||||
/// Category folder name (MainQuests, SideQuests, …).
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
static ITEMS: Lazy<Vec<Item>> =
|
||||
Lazy::new(|| serde_json::from_slice(ITEMS_JSON).expect("embedded items.json is valid"));
|
||||
static VEHICLES: Lazy<Vec<Vehicle>> =
|
||||
Lazy::new(|| serde_json::from_slice(VEHICLES_JSON).expect("embedded vehicles.json is valid"));
|
||||
static SKILL_MODULES: Lazy<Vec<SkillModule>> = Lazy::new(|| {
|
||||
serde_json::from_slice(SKILL_MODULES_JSON).expect("embedded skill-modules.json is valid")
|
||||
});
|
||||
static JOURNEY_NODES: Lazy<Vec<JourneyNode>> = Lazy::new(|| {
|
||||
serde_json::from_slice(JOURNEY_NODES_JSON).expect("embedded journey-nodes.json is valid")
|
||||
});
|
||||
static XP_EVENT_TAGS: Lazy<Vec<XpEventTag>> = Lazy::new(|| {
|
||||
serde_json::from_slice(XP_EVENT_TAGS_JSON).expect("embedded xp-event-tags.json is valid")
|
||||
});
|
||||
|
||||
pub fn items() -> &'static [Item] {
|
||||
&ITEMS
|
||||
}
|
||||
|
||||
pub fn vehicles() -> &'static [Vehicle] {
|
||||
&VEHICLES
|
||||
}
|
||||
|
||||
pub fn skill_modules() -> &'static [SkillModule] {
|
||||
&SKILL_MODULES
|
||||
}
|
||||
|
||||
pub fn journey_nodes() -> &'static [JourneyNode] {
|
||||
&JOURNEY_NODES
|
||||
}
|
||||
|
||||
pub fn xp_event_tags() -> &'static [XpEventTag] {
|
||||
&XP_EVENT_TAGS
|
||||
}
|
||||
|
||||
pub fn search_items(query: &str, limit: u32) -> Vec<Item> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let cap = limit.clamp(1, 200) as usize;
|
||||
let all = items();
|
||||
if q.is_empty() {
|
||||
return all.iter().take(50.min(cap)).cloned().collect();
|
||||
}
|
||||
let mut scored: Vec<(u32, &Item)> = all
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let s = score_text(&q, &item.id, &item.name);
|
||||
if s > 0 {
|
||||
Some((s, item))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored
|
||||
.into_iter()
|
||||
.take(cap)
|
||||
.map(|(_, it)| it.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_xp_event_tags(query: &str, limit: u32) -> Vec<XpEventTag> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let cap = limit.clamp(1, 100) as usize;
|
||||
let all = xp_event_tags();
|
||||
if q.is_empty() {
|
||||
return all.iter().take(50.min(cap)).cloned().collect();
|
||||
}
|
||||
let mut scored: Vec<(u32, &XpEventTag)> = all
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let s = score_text(&q, &t.id, &t.constant);
|
||||
if s > 0 {
|
||||
Some((s, t))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored
|
||||
.into_iter()
|
||||
.take(cap)
|
||||
.map(|(_, t)| t.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_journey_nodes(query: &str, limit: u32) -> Vec<JourneyNode> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let cap = limit.clamp(1, 300) as usize;
|
||||
let all = journey_nodes();
|
||||
if q.is_empty() {
|
||||
return all.iter().take(80.min(cap)).cloned().collect();
|
||||
}
|
||||
let mut scored: Vec<(u32, &JourneyNode)> = all
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
let s = score_text(&q, &n.id, &n.label);
|
||||
if s > 0 {
|
||||
Some((s, n))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored
|
||||
.into_iter()
|
||||
.take(cap)
|
||||
.map(|(_, n)| n.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_skill_modules(query: &str, limit: u32) -> Vec<SkillModule> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let cap = limit.clamp(1, 200) as usize;
|
||||
let all = skill_modules();
|
||||
if q.is_empty() {
|
||||
return all.iter().take(50.min(cap)).cloned().collect();
|
||||
}
|
||||
let mut scored: Vec<(u32, &SkillModule)> = all
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let s = score_text(&q, &m.id, &m.name);
|
||||
if s > 0 {
|
||||
Some((s, m))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored
|
||||
.into_iter()
|
||||
.take(cap)
|
||||
.map(|(_, m)| m.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn search_vehicles(query: &str, limit: u32) -> Vec<Vehicle> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let cap = limit.clamp(1, 100) as usize;
|
||||
let all = vehicles();
|
||||
if q.is_empty() {
|
||||
return all.iter().take(20.min(cap)).cloned().collect();
|
||||
}
|
||||
let mut scored: Vec<(u32, &Vehicle)> = all
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let s = score_text(&q, &v.id, &v.actor_class);
|
||||
if s > 0 {
|
||||
Some((s, v))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored
|
||||
.into_iter()
|
||||
.take(cap)
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Faithful port of `scoreText` from `src/admin/data.ts`.
|
||||
/// 1000 = exact id, 500 = id prefix, 300 = name prefix, 200 = id contains,
|
||||
/// 100 = name contains, 50 = all words present.
|
||||
fn score_text(query: &str, id: &str, name: &str) -> u32 {
|
||||
let id_lower = id.to_lowercase();
|
||||
let name_lower = name.to_lowercase();
|
||||
if id_lower == query {
|
||||
return 1000;
|
||||
}
|
||||
let mut score = 0;
|
||||
if id_lower.starts_with(query) {
|
||||
score = score.max(500);
|
||||
}
|
||||
if name_lower.starts_with(query) {
|
||||
score = score.max(300);
|
||||
}
|
||||
if score == 0 && id_lower.contains(query) {
|
||||
score = 200;
|
||||
}
|
||||
if score == 0 && name_lower.contains(query) {
|
||||
score = 100;
|
||||
}
|
||||
if score == 0 {
|
||||
let words: Vec<&str> = query.split_whitespace().collect();
|
||||
if words.len() > 1
|
||||
&& words
|
||||
.iter()
|
||||
.all(|w| id_lower.contains(w) || name_lower.contains(w))
|
||||
{
|
||||
score = 50;
|
||||
}
|
||||
}
|
||||
score
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn items_load_and_have_rows() {
|
||||
assert!(!items().is_empty());
|
||||
assert!(!vehicles().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoring_prefers_exact_id() {
|
||||
let id = items()[0].id.clone();
|
||||
let results = search_items(&id, 10);
|
||||
assert!(!results.is_empty());
|
||||
assert_eq!(results[0].id, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_returns_default_slice() {
|
||||
let r = search_items("", 50);
|
||||
assert!(!r.is_empty());
|
||||
assert!(r.len() <= 50);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod commands;
|
||||
pub mod data;
|
||||
pub mod mq;
|
||||
pub mod players;
|
||||
|
||||
pub use commands::{validate_and_build, CommandSpec, FieldKind, FieldSpec, ValidationError, SPECS};
|
||||
pub use data::{
|
||||
search_items, search_journey_nodes, search_skill_modules, search_vehicles,
|
||||
search_xp_event_tags, Item, JourneyNode, SkillModule, Vehicle, XpEventTag,
|
||||
};
|
||||
pub use mq::{
|
||||
publish_inner, publish_server_shutdown, publish_server_shutdown_cancel,
|
||||
publish_service_broadcast, publish_whisper, MqPublisher, PublishResult, ShutdownType,
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use base64::Engine as _;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::kubectl::{ClusterCache, KubectlClient};
|
||||
|
||||
const EXCHANGE: &str = "heartbeats";
|
||||
const ROUTING_KEY: &str = "notifications";
|
||||
const WHISPER_EXCHANGE: &str = "chat.whispers";
|
||||
const USER_ID: &str = "fls";
|
||||
const APP_ID: &str = "fls_backend";
|
||||
|
||||
static SAFE_LABEL_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$").unwrap());
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ShutdownType {
|
||||
Restart,
|
||||
Maintenance,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl ShutdownType {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Restart => "Restart",
|
||||
Self::Maintenance => "Maintenance",
|
||||
Self::Update => "Update",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PublishResult {
|
||||
pub ok: bool,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MqPublisher {
|
||||
kubectl: KubectlClient,
|
||||
cluster: ClusterCache,
|
||||
token: Arc<String>,
|
||||
}
|
||||
|
||||
impl MqPublisher {
|
||||
pub fn new(kubectl: KubectlClient, cluster: ClusterCache, token: String) -> Self {
|
||||
Self {
|
||||
kubectl,
|
||||
cluster,
|
||||
token: Arc::new(token),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub async fn publish_inner(&self, inner: &Value, label: &str) -> Result<PublishResult> {
|
||||
publish_inner(self, inner, label).await
|
||||
}
|
||||
|
||||
pub async fn publish_whisper(
|
||||
&self,
|
||||
routing_key: &str,
|
||||
sender_fls_id: &str,
|
||||
body: &Value,
|
||||
label: &str,
|
||||
) -> Result<PublishResult> {
|
||||
publish_whisper(self, routing_key, sender_fls_id, body, label).await
|
||||
}
|
||||
|
||||
pub async fn publish_service_broadcast(
|
||||
&self,
|
||||
title: &str,
|
||||
body: &str,
|
||||
duration_secs: u64,
|
||||
) -> Result<PublishResult> {
|
||||
publish_service_broadcast(self, title, body, duration_secs).await
|
||||
}
|
||||
|
||||
pub async fn publish_server_shutdown(
|
||||
&self,
|
||||
shutdown_type: ShutdownType,
|
||||
shutdown_timestamp: i64,
|
||||
frequency_secs: u64,
|
||||
duration_secs: u64,
|
||||
broadcast_duration_secs: u64,
|
||||
) -> Result<PublishResult> {
|
||||
publish_server_shutdown(
|
||||
self,
|
||||
shutdown_type,
|
||||
shutdown_timestamp,
|
||||
frequency_secs,
|
||||
duration_secs,
|
||||
broadcast_duration_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn publish_server_shutdown_cancel(&self) -> Result<PublishResult> {
|
||||
publish_server_shutdown_cancel(self).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the base64-encoded outer envelope expected by the seabass server-
|
||||
/// command handler.
|
||||
pub fn envelope_for_command(inner: &Value, token: &str) -> String {
|
||||
let inner_str = serde_json::to_string(inner).unwrap_or_else(|_| "{}".to_string());
|
||||
let outer = json!({
|
||||
"Version": 2,
|
||||
"AuthToken": token,
|
||||
"MessageContent": inner_str,
|
||||
});
|
||||
let outer_bytes = serde_json::to_vec(&outer).unwrap_or_default();
|
||||
base64::engine::general_purpose::STANDARD.encode(outer_bytes)
|
||||
}
|
||||
|
||||
/// Build the Erlang expression that `rabbitmqctl eval` executes inside the MQ
|
||||
/// pod. Byte-equivalent to the publish snippet in `mq.ts` so the server-side
|
||||
/// log line stays consistent.
|
||||
pub fn build_erlang_publish(payload_b64: &str, label: &str) -> String {
|
||||
let label = if SAFE_LABEL_RE.is_match(label) {
|
||||
label
|
||||
} else {
|
||||
"smgmt"
|
||||
};
|
||||
format!(
|
||||
"Outer = base64:decode(<<\"{payload_b64}\">>),\n\
|
||||
XName = rabbit_misc:r(<<\"/\">>, exchange, <<\"{EXCHANGE}\">>),\n\
|
||||
X = rabbit_exchange:lookup_or_die(XName),\n\
|
||||
MsgId = list_to_binary(\"smgmt-{label}-\" ++ integer_to_list(erlang:system_time(millisecond))),\n\
|
||||
P = {{list_to_atom(\"P_basic\"), <<\"Content\">>, undefined, [], undefined, undefined, undefined, undefined, undefined, MsgId, undefined, undefined, <<\"{USER_ID}\">>, <<\"{APP_ID}\">>, undefined}},\n\
|
||||
Content = rabbit_basic:build_content(P, Outer),\n\
|
||||
{{ok, Msg}} = rabbit_basic:message(XName, <<\"{ROUTING_KEY}\">>, Content),\n\
|
||||
Result = rabbit_queue_type:publish_at_most_once(X, Msg),\n\
|
||||
io:format(\"publish=~p exchange={EXCHANGE} routing={ROUTING_KEY} app_id={APP_ID} user_id={USER_ID} label={label}~n\", [Result]).\n",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_erlang_whisper_publish(
|
||||
payload_b64: &str,
|
||||
routing_key_b64: &str,
|
||||
sender_fls_id_b64: &str,
|
||||
label: &str,
|
||||
) -> String {
|
||||
let label = if SAFE_LABEL_RE.is_match(label) {
|
||||
label
|
||||
} else {
|
||||
"smgmt"
|
||||
};
|
||||
let exchange_b64 = base64::engine::general_purpose::STANDARD.encode(WHISPER_EXCHANGE);
|
||||
format!(
|
||||
"Body = base64:decode(<<\"{payload_b64}\">>),\n\
|
||||
Exchange = base64:decode(<<\"{exchange_b64}\">>),\n\
|
||||
RoutingKey = base64:decode(<<\"{routing_key_b64}\">>),\n\
|
||||
Sender = base64:decode(<<\"{sender_fls_id_b64}\">>),\n\
|
||||
XName = rabbit_misc:r(<<\"/\">>, exchange, Exchange),\n\
|
||||
X = rabbit_exchange:lookup_or_die(XName),\n\
|
||||
MsgId = list_to_binary(\"smgmt-{label}-\" ++ integer_to_list(erlang:system_time(millisecond))),\n\
|
||||
P = {{list_to_atom(\"P_basic\"), <<\"Content\">>, undefined, [], undefined, undefined, undefined, undefined, undefined, MsgId, undefined, <<\"text_chat\">>, Sender, <<\"{APP_ID}\">>, undefined}},\n\
|
||||
Content = rabbit_basic:build_content(P, Body),\n\
|
||||
{{ok, Msg}} = rabbit_basic:message(XName, RoutingKey, Content),\n\
|
||||
Result = rabbit_queue_type:publish_at_most_once(X, Msg),\n\
|
||||
io:format(\"publish=~p exchange={WHISPER_EXCHANGE} routing=~s app_id={APP_ID} user_id=~s label={label}~n\", [Result, RoutingKey, Sender]).\n",
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn publish_inner(
|
||||
publisher: &MqPublisher,
|
||||
inner: &Value,
|
||||
label: &str,
|
||||
) -> Result<PublishResult> {
|
||||
let cluster = publisher.cluster.get().await?;
|
||||
let payload_b64 = envelope_for_command(inner, publisher.token());
|
||||
let erlang = build_erlang_publish(&payload_b64, label);
|
||||
|
||||
let shell = "set -eu; \
|
||||
export PATH=/opt/rabbitmq/sbin:/opt/erlang/lib/erlang/bin:/bin:/usr/bin:/usr/local/bin:$PATH; \
|
||||
cat > /tmp/dune-mq-publish.erl; \
|
||||
expr=$(cat /tmp/dune-mq-publish.erl); \
|
||||
/opt/rabbitmq/sbin/rabbitmqctl eval \"$expr\"; \
|
||||
rm -f /tmp/dune-mq-publish.erl";
|
||||
|
||||
let result = publisher
|
||||
.kubectl
|
||||
.run_timeout(
|
||||
&[
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
&cluster.namespace,
|
||||
&cluster.mq_pod,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
shell,
|
||||
],
|
||||
Some(&erlang),
|
||||
30,
|
||||
)
|
||||
.await
|
||||
.context("kubectl exec rabbitmqctl eval")?;
|
||||
|
||||
let combined = if result.stderr.trim().is_empty() {
|
||||
result.stdout.clone()
|
||||
} else {
|
||||
format!("{}\n{}", result.stdout, result.stderr)
|
||||
};
|
||||
let scrubbed = crate::logger::redact(&combined).into_owned();
|
||||
if !result.ok() {
|
||||
return Err(anyhow!(
|
||||
"rabbitmqctl eval exited {}: {scrubbed}",
|
||||
result.exit_code
|
||||
));
|
||||
}
|
||||
let ok = result.stdout.contains("publish=ok");
|
||||
Ok(PublishResult {
|
||||
ok,
|
||||
output: scrubbed,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish_whisper(
|
||||
publisher: &MqPublisher,
|
||||
routing_key: &str,
|
||||
sender_fls_id: &str,
|
||||
body: &Value,
|
||||
label: &str,
|
||||
) -> Result<PublishResult> {
|
||||
let cluster = publisher.cluster.get().await?;
|
||||
let payload_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(body).context("serializing whisper body")?);
|
||||
let routing_key_b64 = base64::engine::general_purpose::STANDARD.encode(routing_key);
|
||||
let sender_fls_id_b64 = base64::engine::general_purpose::STANDARD.encode(sender_fls_id);
|
||||
let erlang =
|
||||
build_erlang_whisper_publish(&payload_b64, &routing_key_b64, &sender_fls_id_b64, label);
|
||||
|
||||
let shell = "set -eu; \
|
||||
export PATH=/opt/rabbitmq/sbin:/opt/erlang/lib/erlang/bin:/bin:/usr/bin:/usr/local/bin:$PATH; \
|
||||
cat > /tmp/dune-mq-whisper.erl; \
|
||||
expr=$(cat /tmp/dune-mq-whisper.erl); \
|
||||
/opt/rabbitmq/sbin/rabbitmqctl eval \"$expr\"; \
|
||||
rm -f /tmp/dune-mq-whisper.erl";
|
||||
|
||||
let result = publisher
|
||||
.kubectl
|
||||
.run_timeout(
|
||||
&[
|
||||
"exec",
|
||||
"-i",
|
||||
"-n",
|
||||
&cluster.namespace,
|
||||
&cluster.mq_pod,
|
||||
"--",
|
||||
"sh",
|
||||
"-lc",
|
||||
shell,
|
||||
],
|
||||
Some(&erlang),
|
||||
30,
|
||||
)
|
||||
.await
|
||||
.context("kubectl exec rabbitmqctl eval whisper")?;
|
||||
|
||||
let combined = if result.stderr.trim().is_empty() {
|
||||
result.stdout.clone()
|
||||
} else {
|
||||
format!("{}\n{}", result.stdout, result.stderr)
|
||||
};
|
||||
let scrubbed = crate::logger::redact(&combined).into_owned();
|
||||
if !result.ok() {
|
||||
return Err(anyhow!(
|
||||
"rabbitmqctl eval exited {}: {scrubbed}",
|
||||
result.exit_code
|
||||
));
|
||||
}
|
||||
let ok = result.stdout.contains("publish=ok");
|
||||
Ok(PublishResult {
|
||||
ok,
|
||||
output: scrubbed,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn publish_service_broadcast(
|
||||
publisher: &MqPublisher,
|
||||
title: &str,
|
||||
body: &str,
|
||||
duration_secs: u64,
|
||||
) -> Result<PublishResult> {
|
||||
let entry = json!({"Title": title, "Body": body});
|
||||
let inner = json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "Generic",
|
||||
"BroadcastPayload": {
|
||||
"BroadcastDuration": duration_secs,
|
||||
"LocalizedText": [
|
||||
{"Key": "en", "Title": title, "Body": body},
|
||||
{"Key": "en-US", "Title": title, "Body": body},
|
||||
]
|
||||
}
|
||||
});
|
||||
let _ = entry;
|
||||
publish_inner(publisher, &inner, "service-broadcast").await
|
||||
}
|
||||
|
||||
pub async fn publish_server_shutdown(
|
||||
publisher: &MqPublisher,
|
||||
shutdown_type: ShutdownType,
|
||||
shutdown_timestamp: i64,
|
||||
frequency_secs: u64,
|
||||
duration_secs: u64,
|
||||
broadcast_duration_secs: u64,
|
||||
) -> Result<PublishResult> {
|
||||
let inner = json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"BroadcastPayload": {
|
||||
"ShutdownType": shutdown_type.as_str(),
|
||||
"DateTimestamp": chrono::Utc::now().timestamp(),
|
||||
"ShutdownDuration": duration_secs,
|
||||
"ShutdownTimestamp": shutdown_timestamp,
|
||||
"BroadcastFrequency": frequency_secs,
|
||||
"BroadcastDuration": broadcast_duration_secs,
|
||||
}
|
||||
});
|
||||
publish_inner(publisher, &inner, "server-shutdown").await
|
||||
}
|
||||
|
||||
/// Cancels an in-flight ServerShutdown countdown. The parser short-circuits
|
||||
/// on `ShouldCancel: true` so no other shutdown metadata is sent.
|
||||
pub async fn publish_server_shutdown_cancel(publisher: &MqPublisher) -> Result<PublishResult> {
|
||||
let inner = json!({
|
||||
"ServerCommand": "ServiceBroadcast",
|
||||
"BroadcastType": "ServerShutdown",
|
||||
"BroadcastPayload": { "ShouldCancel": true },
|
||||
});
|
||||
publish_inner(publisher, &inner, "server-shutdown-cancel").await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trips() {
|
||||
let inner = json!({"ServerCommand": "Foo", "X": 1});
|
||||
let b64 = envelope_for_command(&inner, "TOKEN123");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.unwrap();
|
||||
let outer: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(outer["Version"], 2);
|
||||
assert_eq!(outer["AuthToken"], "TOKEN123");
|
||||
let inner_str = outer["MessageContent"].as_str().unwrap();
|
||||
let recovered: Value = serde_json::from_str(inner_str).unwrap();
|
||||
assert_eq!(recovered, inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erlang_uses_safe_label() {
|
||||
let snippet = build_erlang_publish("PAYLOAD", "test-label");
|
||||
assert!(snippet.contains("smgmt-test-label-"));
|
||||
let snippet = build_erlang_publish("PAYLOAD", "weird; rm -rf /");
|
||||
assert!(snippet.contains("smgmt-smgmt-")); // unsafe label replaced
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erlang_contains_envelope_constants() {
|
||||
let s = build_erlang_publish("PAYLOAD", "x");
|
||||
assert!(s.contains("<<\"heartbeats\">>"));
|
||||
assert!(s.contains("<<\"notifications\">>"));
|
||||
assert!(s.contains("<<\"fls\">>"));
|
||||
assert!(s.contains("<<\"fls_backend\">>"));
|
||||
assert!(s.contains("<<\"Content\">>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whisper_erlang_uses_chat_whispers_and_sender() {
|
||||
let routing = base64::engine::general_purpose::STANDARD.encode("recipient-chat-id");
|
||||
let sender = base64::engine::general_purpose::STANDARD.encode("sender-fls-id");
|
||||
let s = build_erlang_whisper_publish("PAYLOAD", &routing, &sender, "whisper");
|
||||
assert!(s.contains("chat.whispers"));
|
||||
assert!(s.contains("<<\"text_chat\">>"));
|
||||
assert!(s.contains("Sender"));
|
||||
assert!(s.contains("RoutingKey"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::kubectl::ClusterCache;
|
||||
use crate::postgres::{search_players as pg_search_players, PgClient, Player};
|
||||
|
||||
/// Thin wrapper that resolves the current namespace from the cluster cache and
|
||||
/// delegates to the tokio-postgres query.
|
||||
pub async fn search_players(
|
||||
pg: &Arc<PgClient>,
|
||||
cluster: &ClusterCache,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<Player>> {
|
||||
let cluster = cluster.get().await?;
|
||||
pg_search_players(pg, &cluster.namespace, query, limit).await
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use crate::errors::ConfigError;
|
||||
|
||||
pub const BUILTIN_FALLBACK_TOKEN: &str = "Nu6VmPWUMvdPMeB7qErr";
|
||||
|
||||
pub const DEFAULT_BIN_DIR: &str = "/home/dune/.dune/bin";
|
||||
pub const DEFAULT_DASHBOARD_HOST: &str = "127.0.0.1";
|
||||
pub const DEFAULT_DASHBOARD_PORT: u16 = 29187;
|
||||
pub const DEFAULT_TIME_ZONE: &str = "Europe/Amsterdam";
|
||||
pub const DEFAULT_COMMAND_AUTH_TOKEN_FILE: &str = "/home/dune/.dune/state/command-auth-token";
|
||||
pub const DEFAULT_DB_PATH_LINUX: &str = "/home/dune/.dune/state/dune-server-service.sqlite";
|
||||
pub const DEFAULT_SERVICE_HOME_LINUX: &str = "/home/dune";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceConfig {
|
||||
pub bin_dir: PathBuf,
|
||||
pub dashboard_host: String,
|
||||
pub dashboard_port: u16,
|
||||
pub db_path: PathBuf,
|
||||
pub time_zone: Tz,
|
||||
pub command_auth_token_file: PathBuf,
|
||||
pub namespace_override: Option<String>,
|
||||
pub mq_pod_override: Option<String>,
|
||||
pub db_pod_override: Option<String>,
|
||||
pub pg_host_override: Option<String>,
|
||||
pub pg_user_override: Option<String>,
|
||||
pub pg_db_override: Option<String>,
|
||||
pub kubectl_use_sudo: bool,
|
||||
pub service_home: PathBuf,
|
||||
pub steamcmd_path: Option<PathBuf>,
|
||||
pub steamcmd_download_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ServiceConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
load_dotenv(Path::new(".env"))?;
|
||||
|
||||
let dashboard_host =
|
||||
env::var("DUNE_DASHBOARD_HOST").unwrap_or_else(|_| DEFAULT_DASHBOARD_HOST.to_string());
|
||||
let allow_external_bind = matches!(
|
||||
env::var("DUNE_ALLOW_EXTERNAL_BIND").ok().as_deref(),
|
||||
Some("1") | Some("true")
|
||||
);
|
||||
if !is_loopback_host(&dashboard_host) && !allow_external_bind {
|
||||
return Err(ConfigError::NonLoopbackBindForbidden(dashboard_host).into());
|
||||
}
|
||||
|
||||
let dashboard_port = match env::var("DUNE_DASHBOARD_PORT").ok().as_deref() {
|
||||
None | Some("") => DEFAULT_DASHBOARD_PORT,
|
||||
Some(raw) => parse_port(raw)?,
|
||||
};
|
||||
|
||||
let tz_raw =
|
||||
env::var("DUNE_SERVICE_TIME_ZONE").unwrap_or_else(|_| DEFAULT_TIME_ZONE.to_string());
|
||||
let time_zone = Tz::from_str(&tz_raw).map_err(|_| ConfigError::InvalidTimeZone(tz_raw))?;
|
||||
|
||||
let db_path = env::var("DUNE_SERVICE_DB_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| default_db_path());
|
||||
|
||||
let service_home = default_service_home();
|
||||
|
||||
let command_auth_token_file = env::var("DUNE_COMMAND_AUTH_TOKEN_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
service_home
|
||||
.join(".dune")
|
||||
.join("state")
|
||||
.join("command-auth-token")
|
||||
});
|
||||
|
||||
// Default to sudo because k3s installs `kubectl` with a kubeconfig
|
||||
// (`/etc/rancher/k3s/k3s.yaml`) that is only root-readable. The
|
||||
// existing manager + tunnel commands already shell out as `sudo
|
||||
// kubectl` for the same reason. Operators can override with
|
||||
// `DUNE_KUBECTL_USE_SUDO=0`.
|
||||
let kubectl_use_sudo = match env::var("DUNE_KUBECTL_USE_SUDO").ok().as_deref() {
|
||||
Some("0") | Some("false") => false,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
bin_dir: env::var("DUNE_BIN_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| service_home.join(".dune").join("bin")),
|
||||
dashboard_host,
|
||||
dashboard_port,
|
||||
db_path,
|
||||
time_zone,
|
||||
command_auth_token_file,
|
||||
namespace_override: nonempty_env("DUNE_NAMESPACE"),
|
||||
mq_pod_override: nonempty_env("DUNE_MQ_POD"),
|
||||
db_pod_override: nonempty_env("DUNE_DB_POD"),
|
||||
pg_host_override: nonempty_env("DUNE_PG_HOST"),
|
||||
pg_user_override: nonempty_env("DUNE_PG_USER"),
|
||||
pg_db_override: nonempty_env("DUNE_PG_DB"),
|
||||
kubectl_use_sudo,
|
||||
service_home: service_home.clone(),
|
||||
steamcmd_path: nonempty_env("DUNE_STEAMCMD_PATH").map(PathBuf::from),
|
||||
steamcmd_download_path: nonempty_env("DUNE_STEAMCMD_DOWNLOAD_PATH").map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active command-auth token: env -> file -> builtin fallback.
|
||||
/// The builtin is Funcom-confirmed harmless (see project memory).
|
||||
pub fn resolve_command_auth_token(token_file: &Path) -> String {
|
||||
if let Ok(raw) = env::var("DUNE_COMMAND_AUTH_TOKEN") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(contents) = fs::read_to_string(token_file) {
|
||||
let trimmed = contents.trim_end_matches(['\r', '\n']).trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
BUILTIN_FALLBACK_TOKEN.to_string()
|
||||
}
|
||||
|
||||
fn nonempty_env(key: &str) -> Option<String> {
|
||||
env::var(key).ok().and_then(|raw| {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_port(raw: &str) -> Result<u16, ConfigError> {
|
||||
raw.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|p| *p >= 1)
|
||||
.ok_or_else(|| ConfigError::InvalidPort(raw.to_string()))
|
||||
}
|
||||
|
||||
fn is_loopback_host(host: &str) -> bool {
|
||||
if host == "localhost" {
|
||||
return true;
|
||||
}
|
||||
IpAddr::from_str(host)
|
||||
.map(|ip| ip.is_loopback())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn default_db_path() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
if let Ok(local) = env::var("LOCALAPPDATA") {
|
||||
return PathBuf::from(local)
|
||||
.join("dune-server-service")
|
||||
.join("state.sqlite");
|
||||
}
|
||||
PathBuf::from(".data").join("dune-server-service.sqlite")
|
||||
} else {
|
||||
default_service_home()
|
||||
.join(".dune")
|
||||
.join("state")
|
||||
.join("dune-server-service.sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_service_home() -> PathBuf {
|
||||
env::var("DUNE_SERVICE_HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::var("HOME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_SERVICE_HOME_LINUX))
|
||||
}
|
||||
|
||||
fn load_dotenv(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let contents =
|
||||
fs::read_to_string(path).with_context(|| format!("reading dotenv {}", path.display()))?;
|
||||
for line in contents.split('\n') {
|
||||
let Some((key, value)) = parse_dotenv_line(line) else {
|
||||
continue;
|
||||
};
|
||||
if env::var_os(&key).is_none() {
|
||||
// SAFETY: setting env vars at startup, before threads are spawned.
|
||||
unsafe { env::set_var(&key, &value) };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_dotenv_line(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let eq = trimmed.find('=')?;
|
||||
if eq == 0 {
|
||||
return None;
|
||||
}
|
||||
let key = trimmed[..eq].trim().to_string();
|
||||
let value = trimmed[eq + 1..].trim();
|
||||
let value = unquote(value).to_string();
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
fn unquote(value: &str) -> &str {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() >= 2
|
||||
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
|
||||
{
|
||||
&value[1..value.len() - 1]
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loopback_host_recognizes_common_forms() {
|
||||
assert!(is_loopback_host("127.0.0.1"));
|
||||
assert!(is_loopback_host("::1"));
|
||||
assert!(is_loopback_host("localhost"));
|
||||
assert!(!is_loopback_host("0.0.0.0"));
|
||||
assert!(!is_loopback_host("10.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_port_validates_range() {
|
||||
assert_eq!(parse_port("29187").unwrap(), 29187);
|
||||
assert!(parse_port("0").is_err());
|
||||
assert!(parse_port("99999").is_err());
|
||||
assert!(parse_port("abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unquote_strips_matching_quotes() {
|
||||
assert_eq!(unquote(r#""value""#), "value");
|
||||
assert_eq!(unquote("'value'"), "value");
|
||||
assert_eq!(unquote("value"), "value");
|
||||
assert_eq!(unquote(r#""mismatch'"#), r#""mismatch'"#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("invalid DUNE_DASHBOARD_PORT: {0}")]
|
||||
InvalidPort(String),
|
||||
#[error("invalid DUNE_SERVICE_TIME_ZONE: {0}")]
|
||||
InvalidTimeZone(String),
|
||||
#[error(
|
||||
"refusing to bind DUNE_DASHBOARD_HOST={0}; set DUNE_ALLOW_EXTERNAL_BIND=1 to override"
|
||||
)]
|
||||
NonLoopbackBindForbidden(String),
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use futures::FutureExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::admin::{commands, data, players, MqPublisher};
|
||||
use crate::store::AdminHistoryFilter;
|
||||
|
||||
use super::api_runs::ApiError;
|
||||
use super::AppState;
|
||||
|
||||
pub async fn list_commands() -> impl IntoResponse {
|
||||
Json(commands::SPECS)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn search_items(Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(50);
|
||||
Json(data::search_items(&query, limit))
|
||||
}
|
||||
|
||||
pub async fn search_vehicles(Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(20);
|
||||
Json(data::search_vehicles(&query, limit))
|
||||
}
|
||||
|
||||
pub async fn search_skill_modules(Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(50);
|
||||
Json(data::search_skill_modules(&query, limit))
|
||||
}
|
||||
|
||||
pub async fn search_journey_nodes(Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(80);
|
||||
Json(data::search_journey_nodes(&query, limit))
|
||||
}
|
||||
|
||||
pub async fn search_xp_event_tags(Query(q): Query<SearchQuery>) -> impl IntoResponse {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(50);
|
||||
Json(data::search_xp_event_tags(&query, limit))
|
||||
}
|
||||
|
||||
pub async fn search_players(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SearchQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = q.q.unwrap_or_default();
|
||||
let limit = q.limit.unwrap_or(50);
|
||||
let result = std::panic::AssertUnwindSafe(players::search_players(
|
||||
&state.env.pg,
|
||||
&state.env.cluster,
|
||||
&query,
|
||||
limit,
|
||||
))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
let rows = match result {
|
||||
Ok(Ok(rows)) => rows,
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => {
|
||||
tracing::error!("admin players route panicked");
|
||||
return Err(ApiError::internal("admin players route panicked"));
|
||||
}
|
||||
};
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlayerLocationQuery {
|
||||
#[serde(rename = "flsId")]
|
||||
pub fls_id: String,
|
||||
}
|
||||
|
||||
pub async fn player_location(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PlayerLocationQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use crate::postgres::PositionProbe;
|
||||
let cluster = state.env.cluster.get().await?;
|
||||
let probe =
|
||||
crate::postgres::get_player_location(&state.env.pg, &cluster.namespace, &q.fls_id).await?;
|
||||
match probe {
|
||||
PositionProbe::Found(p) => Ok(Json(p).into_response()),
|
||||
PositionProbe::NoRow => Err(ApiError::not_found(format!(
|
||||
"no live pawn for fls_id {} — player may be offline",
|
||||
q.fls_id
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cluster(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let c = state.env.cluster.get().await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"namespace": c.namespace,
|
||||
"mqPod": c.mq_pod,
|
||||
"dbPod": c.db_pod,
|
||||
"serviceVersion": super::VERSION,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HistoryQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn history(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<HistoryQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let list = state
|
||||
.store
|
||||
.list_admin_commands(AdminHistoryFilter { limit: q.limit })?;
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
pub async fn welcome_grants(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<HistoryQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let list = state.store.list_welcome_grants(q.limit.unwrap_or(100))?;
|
||||
Ok(Json(list))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RetryWelcomeGrantRequest {
|
||||
pub player_id: String,
|
||||
pub package_version: String,
|
||||
pub account_id: i64,
|
||||
}
|
||||
|
||||
/// Clears a failed welcome-grant ledger row so the next scan re-attempts it.
|
||||
pub async fn retry_welcome_grant(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RetryWelcomeGrantRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let player_id = req.player_id.trim();
|
||||
if player_id.is_empty() {
|
||||
return Err(ApiError::bad_request("playerId must not be empty"));
|
||||
}
|
||||
let package_version = req.package_version.trim();
|
||||
if package_version.is_empty() {
|
||||
return Err(ApiError::bad_request("packageVersion must not be empty"));
|
||||
}
|
||||
let removed =
|
||||
state
|
||||
.store
|
||||
.delete_welcome_grant(player_id, package_version, req.account_id)?;
|
||||
Ok(Json(serde_json::json!({ "ok": removed > 0, "removed": removed })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WelcomeWhisperRequest {
|
||||
pub recipient_player_id: String,
|
||||
#[serde(default)]
|
||||
pub source_player_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn welcome_whisper(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WelcomeWhisperRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let recipient = req.recipient_player_id.trim();
|
||||
if recipient.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"recipient_player_id must not be empty",
|
||||
));
|
||||
}
|
||||
let message = req.message.trim();
|
||||
if message.is_empty() {
|
||||
return Err(ApiError::bad_request("message must not be empty"));
|
||||
}
|
||||
if message.len() > 1000 {
|
||||
return Err(ApiError::bad_request("message must be <= 1000 characters"));
|
||||
}
|
||||
|
||||
let cluster = state.env.cluster.get().await?;
|
||||
let result = crate::tasks::welcome_package::send_welcome_whisper_now(
|
||||
&state.env,
|
||||
&cluster.namespace,
|
||||
req.source_player_id.trim(),
|
||||
recipient,
|
||||
message,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (ok, output, error) = match result {
|
||||
Ok(pr) => (pr.ok, pr.output, None),
|
||||
Err(err) => {
|
||||
let scrubbed = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
(false, String::new(), Some(scrubbed))
|
||||
}
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"sourcePlayerId": req.source_player_id.trim(),
|
||||
"recipientPlayerId": recipient,
|
||||
"message": message,
|
||||
});
|
||||
let _ = state.store.record_admin_command(
|
||||
"WelcomePackage.SendWelcomeWhisper",
|
||||
&payload,
|
||||
ok,
|
||||
error
|
||||
.as_deref()
|
||||
.or(if ok { None } else { Some(output.as_str()) }),
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": ok,
|
||||
"command": "WelcomePackage.SendWelcomeWhisper",
|
||||
"output": output,
|
||||
"error": error,
|
||||
"inner": payload,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PublishRequest {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub fields: Map<String, Value>,
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PublishRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let inner = commands::validate_and_build(&req.command, &req.fields)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
|
||||
let publisher: &MqPublisher = &state.env.mq;
|
||||
let result = publisher.publish_inner(&inner, &req.command).await;
|
||||
|
||||
let (ok, output, error) = match result {
|
||||
Ok(pr) => (pr.ok, pr.output, None),
|
||||
Err(err) => {
|
||||
let scrubbed = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
(false, String::new(), Some(scrubbed))
|
||||
}
|
||||
};
|
||||
|
||||
let _ = state.store.record_admin_command(
|
||||
&req.command,
|
||||
&inner,
|
||||
ok,
|
||||
error
|
||||
.as_deref()
|
||||
.or(if ok { None } else { Some(output.as_str()) }),
|
||||
);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": ok,
|
||||
"command": req.command,
|
||||
"output": output,
|
||||
"error": error,
|
||||
"inner": inner,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::scheduler::Task;
|
||||
use crate::store::TaskTrigger;
|
||||
|
||||
use super::{AppState, VERSION};
|
||||
|
||||
pub async fn root() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
"name": "dune-server-service",
|
||||
"version": VERSION,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HealthResponse {
|
||||
pub ok: bool,
|
||||
pub version: &'static str,
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
pub async fn health() -> impl IntoResponse {
|
||||
Json(HealthResponse {
|
||||
ok: true,
|
||||
version: VERSION,
|
||||
now: Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RunsQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub task: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_runs(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<RunsQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let runs = state
|
||||
.store
|
||||
.list_runs(q.limit.unwrap_or(50), q.task.as_deref())?;
|
||||
Ok(Json(runs))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LogsQuery {
|
||||
pub limit: Option<u32>,
|
||||
#[serde(rename = "runId")]
|
||||
pub run_id: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<LogsQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let logs = state.store.list_logs(q.limit.unwrap_or(200), q.run_id)?;
|
||||
Ok(Json(logs))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TriggerRequest {
|
||||
pub task: String,
|
||||
#[serde(default)]
|
||||
pub options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub async fn trigger(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<TriggerRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let tasks: Vec<Arc<dyn Task>> = crate::tasks::build_all(state.env.clone());
|
||||
let task = tasks
|
||||
.into_iter()
|
||||
.find(|t| t.id() == req.task)
|
||||
.ok_or_else(|| ApiError::not_found(format!("unknown task: {}", req.task)))?;
|
||||
|
||||
let runner = state.runner.clone();
|
||||
let options = req.options.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = runner.run(task, TaskTrigger::Manual, false, options).await {
|
||||
tracing::error!(error = %err, "manual trigger failed");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "task": req.task})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigResponse {
|
||||
pub restart_hour: u32,
|
||||
pub restart_minute: u32,
|
||||
pub restart_warning_frequency_secs: u64,
|
||||
pub restart_warning_duration_secs: u64,
|
||||
pub update_lead_secs: i64,
|
||||
pub restart_tz: String,
|
||||
/// Master switches for the daily restart, update loop, and scheduled
|
||||
/// backups. Default true; backups also require `backup_cron`.
|
||||
pub restart_enabled: bool,
|
||||
pub update_enabled: bool,
|
||||
pub backup_enabled: bool,
|
||||
/// `None` means scheduled backups are disabled — manual triggers still
|
||||
/// work. When set, it is the exact 5-field cron string the operator typed,
|
||||
/// evaluated in `restart_tz`.
|
||||
pub backup_cron: Option<String>,
|
||||
pub welcome_message_enabled: bool,
|
||||
pub welcome_package_enabled: bool,
|
||||
pub welcome_package_version: String,
|
||||
pub welcome_package_actions_json: String,
|
||||
pub welcome_package_items_json: String,
|
||||
pub welcome_whisper_source_player: String,
|
||||
pub welcome_message: String,
|
||||
/// True if any saved override differs from the active TaskEnv values —
|
||||
/// signals to the UI that a service restart is needed to pick them up.
|
||||
pub restart_required: bool,
|
||||
}
|
||||
|
||||
pub async fn get_config(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let env = &state.env;
|
||||
let stored_hour = state
|
||||
.store
|
||||
.get_config_i64("restart_hour")?
|
||||
.map(|v| v as u32);
|
||||
let stored_minute = state
|
||||
.store
|
||||
.get_config_i64("restart_minute")?
|
||||
.map(|v| v as u32);
|
||||
let stored_freq = state
|
||||
.store
|
||||
.get_config_i64("restart_warning_frequency_secs")?
|
||||
.map(|v| v as u64);
|
||||
let stored_dur = state
|
||||
.store
|
||||
.get_config_i64("restart_warning_duration_secs")?
|
||||
.map(|v| v as u64);
|
||||
let stored_lead = state.store.get_config_i64("update_lead_secs")?;
|
||||
let stored_restart_enabled = state
|
||||
.store
|
||||
.get_config_i64("restart_enabled")?
|
||||
.map(|v| v != 0);
|
||||
let stored_update_enabled = state
|
||||
.store
|
||||
.get_config_i64("update_enabled")?
|
||||
.map(|v| v != 0);
|
||||
let stored_backup_enabled = state
|
||||
.store
|
||||
.get_config_i64("backup_enabled")?
|
||||
.map(|v| v != 0);
|
||||
let stored_backup_cron = state
|
||||
.store
|
||||
.get_config("backup_cron")?
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
|
||||
let stored_tz = state.store.get_config("restart_tz")?;
|
||||
let stored_welcome_enabled = state
|
||||
.store
|
||||
.get_config_i64("welcome_package_enabled")?
|
||||
.map(|v| v != 0);
|
||||
let stored_welcome_message_enabled = state
|
||||
.store
|
||||
.get_config_i64("welcome_message_enabled")?
|
||||
.map(|v| v != 0);
|
||||
let stored_welcome_actions_json = state.store.get_config("welcome_package_actions_json")?;
|
||||
let stored_welcome_items_json = state.store.get_config("welcome_package_items_json")?;
|
||||
let stored_welcome_whisper_source = state.store.get_config("welcome_whisper_source_player")?;
|
||||
let stored_welcome_message = state.store.get_config("welcome_message")?;
|
||||
|
||||
let restart_required = stored_hour.map(|v| v != env.restart_hour).unwrap_or(false)
|
||||
|| stored_minute
|
||||
.map(|v| v != env.restart_minute)
|
||||
.unwrap_or(false)
|
||||
|| stored_freq
|
||||
.map(|v| v != env.restart_warning_frequency_secs)
|
||||
.unwrap_or(false)
|
||||
|| stored_dur
|
||||
.map(|v| v != env.restart_warning_duration_secs)
|
||||
.unwrap_or(false)
|
||||
|| stored_lead
|
||||
.map(|v| v != env.update_lead_secs)
|
||||
.unwrap_or(false)
|
||||
|| stored_restart_enabled
|
||||
.map(|v| v != env.restart_enabled)
|
||||
.unwrap_or(false)
|
||||
|| stored_update_enabled
|
||||
.map(|v| v != env.update_enabled)
|
||||
.unwrap_or(false)
|
||||
|| stored_backup_enabled
|
||||
.map(|v| v != env.backup_enabled)
|
||||
.unwrap_or(false)
|
||||
|| stored_backup_cron.as_deref() != env.backup_cron_raw.as_deref()
|
||||
|| stored_tz
|
||||
.as_deref()
|
||||
.map(|v| v != env.restart_tz.name())
|
||||
.unwrap_or(false)
|
||||
|| stored_welcome_enabled
|
||||
.map(|v| v != env.welcome_package_enabled)
|
||||
.unwrap_or(false)
|
||||
|| stored_welcome_message_enabled
|
||||
.map(|v| v != env.welcome_message_enabled)
|
||||
.unwrap_or(false)
|
||||
|| stored_welcome_actions_json
|
||||
.as_deref()
|
||||
.map(|v| v != env.welcome_package_actions_json)
|
||||
.unwrap_or_else(|| {
|
||||
stored_welcome_items_json
|
||||
.as_deref()
|
||||
.map(|v| v != env.welcome_package_actions_json)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
|| stored_welcome_whisper_source
|
||||
.as_deref()
|
||||
.map(|v| v != env.welcome_whisper_source_player)
|
||||
.unwrap_or(false)
|
||||
|| stored_welcome_message
|
||||
.as_deref()
|
||||
.map(|v| v != env.welcome_message)
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Json(ConfigResponse {
|
||||
restart_hour: env.restart_hour,
|
||||
restart_minute: env.restart_minute,
|
||||
restart_warning_frequency_secs: env.restart_warning_frequency_secs,
|
||||
restart_warning_duration_secs: env.restart_warning_duration_secs,
|
||||
update_lead_secs: env.update_lead_secs,
|
||||
restart_tz: env.restart_tz.name().to_string(),
|
||||
restart_enabled: env.restart_enabled,
|
||||
update_enabled: env.update_enabled,
|
||||
backup_enabled: env.backup_enabled,
|
||||
backup_cron: env.backup_cron_raw.clone(),
|
||||
welcome_message_enabled: env.welcome_message_enabled,
|
||||
welcome_package_enabled: env.welcome_package_enabled,
|
||||
welcome_package_version: env.welcome_package_version.clone(),
|
||||
welcome_package_actions_json: env.welcome_package_actions_json.clone(),
|
||||
welcome_package_items_json: env.welcome_package_actions_json.clone(),
|
||||
welcome_whisper_source_player: env.welcome_whisper_source_player.clone(),
|
||||
welcome_message: env.welcome_message.clone(),
|
||||
restart_required,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigUpdate {
|
||||
pub restart_hour: Option<u32>,
|
||||
pub restart_minute: Option<u32>,
|
||||
pub restart_warning_frequency_secs: Option<u64>,
|
||||
pub restart_warning_duration_secs: Option<u64>,
|
||||
pub update_lead_secs: Option<i64>,
|
||||
pub restart_tz: Option<String>,
|
||||
pub restart_enabled: Option<bool>,
|
||||
pub update_enabled: Option<bool>,
|
||||
pub backup_enabled: Option<bool>,
|
||||
/// Empty string clears the cron schedule (= disabled); non-empty strings
|
||||
/// are validated by `parse_cron` before being persisted.
|
||||
pub backup_cron: Option<String>,
|
||||
pub welcome_message_enabled: Option<bool>,
|
||||
pub welcome_package_enabled: Option<bool>,
|
||||
pub welcome_package_version: Option<String>,
|
||||
pub welcome_package_actions_json: Option<String>,
|
||||
pub welcome_package_items_json: Option<String>,
|
||||
pub welcome_whisper_source_player: Option<String>,
|
||||
pub welcome_message: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn set_config(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ConfigUpdate>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if let Some(h) = req.restart_hour {
|
||||
if h > 23 {
|
||||
return Err(ApiError::bad_request("restart_hour must be 0..=23"));
|
||||
}
|
||||
state.store.set_config("restart_hour", &h.to_string())?;
|
||||
}
|
||||
if let Some(m) = req.restart_minute {
|
||||
if m > 59 {
|
||||
return Err(ApiError::bad_request("restart_minute must be 0..=59"));
|
||||
}
|
||||
state.store.set_config("restart_minute", &m.to_string())?;
|
||||
}
|
||||
if let Some(s) = req.restart_warning_frequency_secs {
|
||||
if s == 0 {
|
||||
return Err(ApiError::bad_request(
|
||||
"restart_warning_frequency_secs must be greater than 0",
|
||||
));
|
||||
}
|
||||
state
|
||||
.store
|
||||
.set_config("restart_warning_frequency_secs", &s.to_string())?;
|
||||
}
|
||||
if let Some(s) = req.restart_warning_duration_secs {
|
||||
if s == 0 {
|
||||
return Err(ApiError::bad_request(
|
||||
"restart_warning_duration_secs must be greater than 0",
|
||||
));
|
||||
}
|
||||
state
|
||||
.store
|
||||
.set_config("restart_warning_duration_secs", &s.to_string())?;
|
||||
}
|
||||
if let Some(s) = req.update_lead_secs {
|
||||
if s < 0 {
|
||||
return Err(ApiError::bad_request("update_lead_secs must be >= 0"));
|
||||
}
|
||||
state.store.set_config("update_lead_secs", &s.to_string())?;
|
||||
}
|
||||
if let Some(tz) = req.restart_tz.as_deref() {
|
||||
if tz.parse::<chrono_tz::Tz>().is_err() {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"invalid IANA timezone: {tz}"
|
||||
)));
|
||||
}
|
||||
state.store.set_config("restart_tz", tz)?;
|
||||
}
|
||||
if let Some(enabled) = req.restart_enabled {
|
||||
state
|
||||
.store
|
||||
.set_config("restart_enabled", if enabled { "1" } else { "0" })?;
|
||||
}
|
||||
if let Some(enabled) = req.update_enabled {
|
||||
state
|
||||
.store
|
||||
.set_config("update_enabled", if enabled { "1" } else { "0" })?;
|
||||
}
|
||||
if let Some(enabled) = req.backup_enabled {
|
||||
state
|
||||
.store
|
||||
.set_config("backup_enabled", if enabled { "1" } else { "0" })?;
|
||||
}
|
||||
if let Some(expr) = req.backup_cron.as_deref() {
|
||||
let trimmed = expr.trim();
|
||||
if trimmed.is_empty() {
|
||||
// Empty -> clear the row so the service treats backups as disabled.
|
||||
state.store.set_config("backup_cron", "")?;
|
||||
} else {
|
||||
crate::scheduler::schedule::parse_cron(trimmed)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
state.store.set_config("backup_cron", trimmed)?;
|
||||
}
|
||||
}
|
||||
if let Some(enabled) = req.welcome_package_enabled {
|
||||
state
|
||||
.store
|
||||
.set_config("welcome_package_enabled", if enabled { "1" } else { "0" })?;
|
||||
}
|
||||
if let Some(enabled) = req.welcome_message_enabled {
|
||||
state
|
||||
.store
|
||||
.set_config("welcome_message_enabled", if enabled { "1" } else { "0" })?;
|
||||
}
|
||||
if let Some(version) = req.welcome_package_version.as_deref() {
|
||||
let trimmed = version.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"welcome_package_version must not be empty",
|
||||
));
|
||||
}
|
||||
// Pinned to the daemon's current env value while the feature is
|
||||
// experimental. A no-op when the client echoes the same value back
|
||||
// (e.g. from a prior GET); a 400 on mismatch so silent drift is
|
||||
// visible instead of looking like a successful save.
|
||||
if trimmed != state.env.welcome_package_version {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"welcome_package_version is currently fixed to {} and cannot be changed",
|
||||
state.env.welcome_package_version
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(raw) = req.welcome_package_actions_json.as_deref() {
|
||||
let trimmed = raw.trim();
|
||||
crate::tasks::welcome_package::parse_welcome_actions(trimmed)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
state
|
||||
.store
|
||||
.set_config("welcome_package_actions_json", trimmed)?;
|
||||
} else if let Some(raw) = req.welcome_package_items_json.as_deref() {
|
||||
// Legacy alias: only applied when actions_json is absent so a mixed
|
||||
// payload from a stale client can't clobber the canonical field.
|
||||
let trimmed = raw.trim();
|
||||
crate::tasks::welcome_package::parse_welcome_actions(trimmed)
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
state
|
||||
.store
|
||||
.set_config("welcome_package_actions_json", trimmed)?;
|
||||
}
|
||||
if let Some(source) = req.welcome_whisper_source_player.as_deref() {
|
||||
state
|
||||
.store
|
||||
.set_config("welcome_whisper_source_player", source.trim())?;
|
||||
}
|
||||
if let Some(message) = req.welcome_message.as_deref() {
|
||||
if message.len() > 1000 {
|
||||
return Err(ApiError::bad_request(
|
||||
"welcome_message must be <= 1000 characters",
|
||||
));
|
||||
}
|
||||
state.store.set_config("welcome_message", message)?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CronPreviewQuery {
|
||||
pub expr: String,
|
||||
/// Number of upcoming fire times to return. Capped at 20.
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Validates a cron expression and returns the next few upcoming fire times
|
||||
/// (in the service's configured `restart_tz`) so the operator gets a sanity
|
||||
/// check while typing it. Returns `{ok: false, error}` on parse failure.
|
||||
pub async fn cron_preview(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<CronPreviewQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let count = q.count.unwrap_or(5).clamp(1, 20) as usize;
|
||||
match crate::scheduler::schedule::parse_cron(&q.expr) {
|
||||
Ok(schedule) => {
|
||||
let tz = state.env.restart_tz;
|
||||
// Pre-format in the operator's tz so the UI doesn't have to
|
||||
// figure out timezone conversion. RFC3339 with the tz offset gets
|
||||
// accidentally rendered as UTC by `Date.toISOString()` callers.
|
||||
let next: Vec<String> = schedule
|
||||
.upcoming(tz)
|
||||
.take(count)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S %Z").to_string())
|
||||
.collect();
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"tz": tz.name(),
|
||||
"next": next,
|
||||
}))
|
||||
}
|
||||
Err(err) => Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_timezones() -> impl IntoResponse {
|
||||
let names: Vec<&'static str> = chrono_tz::TZ_VARIANTS.iter().map(|tz| tz.name()).collect();
|
||||
Json(names)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DumpPruneItem {
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
pub action: String,
|
||||
pub backup: Option<String>,
|
||||
pub phase: String,
|
||||
pub created_at: String,
|
||||
pub age_days: i64,
|
||||
}
|
||||
|
||||
/// Phase values the prune endpoint considers eligible for deletion. We
|
||||
/// allow Succeeded (the artifact, if any, is already on disk; the CR is
|
||||
/// just bookkeeping) and Failed (no artifact produced; pure cluster
|
||||
/// clutter). In-progress / Pending / unknown phases are kept so we never
|
||||
/// race the operator.
|
||||
fn is_prunable_phase(phase: &str) -> bool {
|
||||
matches!(phase, "Succeeded" | "Failed")
|
||||
}
|
||||
|
||||
/// Actions the prune endpoint considers eligible. Both `dump` and `import`
|
||||
/// CRs are pure historical records once they reach a terminal phase —
|
||||
/// deleting them does not undo the database state they produced. Unknown
|
||||
/// action strings are excluded by default so future Funcom additions don't
|
||||
/// get reaped accidentally.
|
||||
fn is_prunable_action(action: &str) -> bool {
|
||||
matches!(action, "dump" | "import")
|
||||
}
|
||||
|
||||
/// Lists `DatabaseOperation` CRs across all namespaces that are safe to
|
||||
/// delete: `status.phase` is terminal (Succeeded/Failed) AND `spec.action`
|
||||
/// is one of the known actions (dump/import). The on-disk `.backup` files
|
||||
/// are not affected by deleting the CR.
|
||||
async fn list_prunable_dumps(state: &AppState) -> Result<Vec<DumpPruneItem>, ApiError> {
|
||||
let result = state
|
||||
.env
|
||||
.kubectl
|
||||
.run(&["get", "databaseoperations", "-A", "-o", "json"])
|
||||
.await
|
||||
.map_err(|err| ApiError::internal(format!("listing database operations: {err}")))?;
|
||||
if !result.ok() {
|
||||
return Err(ApiError::internal(format!(
|
||||
"kubectl get databaseoperations exited {}: {}",
|
||||
result.exit_code,
|
||||
result.stderr.trim()
|
||||
)));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(&result.stdout)
|
||||
.map_err(|err| ApiError::internal(format!("parsing operations json: {err}")))?;
|
||||
let items = value
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let now = chrono::Utc::now();
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
let phase = item["status"]["phase"].as_str().unwrap_or_default();
|
||||
let action = item["spec"]["action"].as_str().unwrap_or_default();
|
||||
if !is_prunable_action(action) || !is_prunable_phase(phase) {
|
||||
continue;
|
||||
}
|
||||
let namespace = item["metadata"]["namespace"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let name = item["metadata"]["name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if namespace.is_empty() || name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let backup = item["spec"]["backup"].as_str().map(|s| s.to_string());
|
||||
let created_at_raw = item["metadata"]["creationTimestamp"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let age_days = chrono::DateTime::parse_from_rfc3339(&created_at_raw)
|
||||
.map(|dt| (now - dt.with_timezone(&chrono::Utc)).num_days())
|
||||
.unwrap_or(0);
|
||||
out.push(DumpPruneItem {
|
||||
namespace,
|
||||
name,
|
||||
action: action.to_string(),
|
||||
backup,
|
||||
phase: phase.to_string(),
|
||||
created_at: created_at_raw,
|
||||
age_days,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn dump_prune_preview(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let items = list_prunable_dumps(&state).await?;
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DumpPruneTarget {
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DumpPruneRequest {
|
||||
pub items: Vec<DumpPruneTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DumpPruneSkip {
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DumpPruneResult {
|
||||
pub deleted: Vec<String>,
|
||||
pub skipped: Vec<DumpPruneSkip>,
|
||||
}
|
||||
|
||||
/// Deletes the requested DatabaseOperation CRs after re-validating each one
|
||||
/// against the same Succeeded+dump filter — never trust the client. The
|
||||
/// Funcom operator garbage-collects the owned pod via ownerReferences once
|
||||
/// the operation CR is gone. The `.backup` files on disk are NOT touched.
|
||||
pub async fn dump_prune_execute(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<DumpPruneRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let current = list_prunable_dumps(&state).await?;
|
||||
let mut deleted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for target in req.items {
|
||||
let eligible = current
|
||||
.iter()
|
||||
.any(|item| item.namespace == target.namespace && item.name == target.name);
|
||||
if !eligible {
|
||||
skipped.push(DumpPruneSkip {
|
||||
namespace: target.namespace,
|
||||
name: target.name,
|
||||
reason: "no longer eligible (not a Succeeded dump, or already removed)".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let result = state
|
||||
.env
|
||||
.kubectl
|
||||
.run(&[
|
||||
"delete",
|
||||
"databaseoperation",
|
||||
&target.name,
|
||||
"-n",
|
||||
&target.namespace,
|
||||
"--ignore-not-found",
|
||||
])
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) if r.ok() => {
|
||||
tracing::info!(
|
||||
namespace = %target.namespace,
|
||||
name = %target.name,
|
||||
"deleted DatabaseOperation"
|
||||
);
|
||||
deleted.push(format!("{}/{}", target.namespace, target.name));
|
||||
}
|
||||
Ok(r) => skipped.push(DumpPruneSkip {
|
||||
namespace: target.namespace.clone(),
|
||||
name: target.name.clone(),
|
||||
reason: format!("kubectl exit {}: {}", r.exit_code, r.stderr.trim()),
|
||||
}),
|
||||
Err(err) => skipped.push(DumpPruneSkip {
|
||||
namespace: target.namespace.clone(),
|
||||
name: target.name.clone(),
|
||||
reason: format!("kubectl error: {err}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(Json(DumpPruneResult { deleted, skipped }))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
pub fn bad_request(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
pub fn not_implemented(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_IMPLEMENTED,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
let scrubbed = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
Self::internal(scrubbed)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
self.status,
|
||||
Json(serde_json::json!({"error": self.message})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::Router;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::config::ServiceConfig;
|
||||
use crate::scheduler::TaskRunner;
|
||||
use crate::store::Store;
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
pub mod api_admin;
|
||||
pub mod api_runs;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub store: Store,
|
||||
pub env: Arc<TaskEnv>,
|
||||
pub runner: Arc<TaskRunner>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(store: Store, env: Arc<TaskEnv>, runner: Arc<TaskRunner>) -> Self {
|
||||
Self { store, env, runner }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", axum::routing::get(api_runs::root))
|
||||
.route("/api/health", axum::routing::get(api_runs::health))
|
||||
.route("/api/runs", axum::routing::get(api_runs::list_runs))
|
||||
.route("/api/logs", axum::routing::get(api_runs::list_logs))
|
||||
.route("/api/runs/trigger", axum::routing::post(api_runs::trigger))
|
||||
.route(
|
||||
"/api/config",
|
||||
axum::routing::get(api_runs::get_config).post(api_runs::set_config),
|
||||
)
|
||||
.route(
|
||||
"/api/timezones",
|
||||
axum::routing::get(api_runs::list_timezones),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/preview",
|
||||
axum::routing::get(api_runs::cron_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/maintenance/dump-prune",
|
||||
axum::routing::get(api_runs::dump_prune_preview).post(api_runs::dump_prune_execute),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/commands",
|
||||
axum::routing::get(api_admin::list_commands),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/items",
|
||||
axum::routing::get(api_admin::search_items),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/vehicles",
|
||||
axum::routing::get(api_admin::search_vehicles),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/skill-modules",
|
||||
axum::routing::get(api_admin::search_skill_modules),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/journey-nodes",
|
||||
axum::routing::get(api_admin::search_journey_nodes),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/xp-event-tags",
|
||||
axum::routing::get(api_admin::search_xp_event_tags),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/players",
|
||||
axum::routing::get(api_admin::search_players),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/player-location",
|
||||
axum::routing::get(api_admin::player_location),
|
||||
)
|
||||
.route("/api/admin/cluster", axum::routing::get(api_admin::cluster))
|
||||
.route("/api/admin/history", axum::routing::get(api_admin::history))
|
||||
.route(
|
||||
"/api/admin/welcome-grants",
|
||||
axum::routing::get(api_admin::welcome_grants),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/welcome-grants/retry",
|
||||
axum::routing::post(api_admin::retry_welcome_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/welcome-whisper",
|
||||
axum::routing::post(api_admin::welcome_whisper),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/publish",
|
||||
axum::routing::post(api_admin::publish),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
pub fn version() -> &'static str {
|
||||
VERSION
|
||||
}
|
||||
|
||||
pub async fn serve(cfg: &ServiceConfig, state: AppState, cancel: CancellationToken) -> Result<()> {
|
||||
let addr = build_bind_address(&cfg.dashboard_host, cfg.dashboard_port)?;
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("binding {addr}"))?;
|
||||
tracing::info!(%addr, "http server listening");
|
||||
|
||||
let app = router(state);
|
||||
|
||||
let shutdown = async move {
|
||||
cancel.cancelled().await;
|
||||
tracing::info!("http server shutdown signal received");
|
||||
};
|
||||
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await
|
||||
.context("axum::serve")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_bind_address(host: &str, port: u16) -> Result<SocketAddr> {
|
||||
let candidate = if host == "localhost" {
|
||||
format!("127.0.0.1:{port}")
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
SocketAddr::from_str(&candidate)
|
||||
.map_err(|err| anyhow!("invalid bind address {candidate}: {err}"))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{KubectlClient, ProcessResult};
|
||||
|
||||
/// Return the BattleGroup name in a given namespace (first match — the typical
|
||||
/// install only has one per namespace).
|
||||
pub async fn bg_name(kubectl: &KubectlClient, namespace: &str) -> Result<String> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"battlegroups",
|
||||
"-n",
|
||||
namespace,
|
||||
"--no-headers",
|
||||
"-o",
|
||||
"custom-columns=NAME:.metadata.name",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get battlegroups -n {namespace}"))?;
|
||||
let name = result
|
||||
.stdout
|
||||
.split('\n')
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.ok_or_else(|| anyhow!("no battlegroup found in namespace {namespace}"))?;
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Read a JSON field from the BattleGroup CRD via `kubectl ... -o jsonpath`.
|
||||
/// `path` should be e.g. `{.spec.stop}` or `{.spec.database.template.spec.user}`.
|
||||
pub async fn bg_field(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
bg_name: &str,
|
||||
jsonpath: &str,
|
||||
) -> Result<String> {
|
||||
let path_arg = format!("jsonpath={jsonpath}");
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"battlegroup",
|
||||
bg_name,
|
||||
"-n",
|
||||
namespace,
|
||||
"-o",
|
||||
path_arg.as_str(),
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!(
|
||||
"kubectl get battlegroup {bg_name} jsonpath={jsonpath}"
|
||||
))?;
|
||||
Ok(result.stdout.trim().to_string())
|
||||
}
|
||||
|
||||
/// Read the full BattleGroup spec as JSON.
|
||||
pub async fn bg_json(kubectl: &KubectlClient, namespace: &str, bg_name: &str) -> Result<Value> {
|
||||
let result = kubectl
|
||||
.run(&["get", "battlegroup", bg_name, "-n", namespace, "-o", "json"])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get battlegroup {bg_name} -o json"))?;
|
||||
let value: Value = serde_json::from_str(&result.stdout)
|
||||
.with_context(|| format!("parsing battlegroup {bg_name} json"))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Count running pods in a namespace whose name matches a substring.
|
||||
pub async fn count_pods_matching(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
substring: &str,
|
||||
) -> Result<usize> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"pods",
|
||||
"-n",
|
||||
namespace,
|
||||
"--no-headers",
|
||||
"-o",
|
||||
"custom-columns=NAME:.metadata.name",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get pods -n {namespace}"))?;
|
||||
let n = result
|
||||
.stdout
|
||||
.split('\n')
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && line.contains(substring))
|
||||
.count();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Get the value of a Kubernetes Secret data key (base64-decoded). Returns None
|
||||
/// if the secret or key is missing.
|
||||
pub async fn secret_value(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
secret_name: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let path_arg = format!("jsonpath={{.data.{key}}}");
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"secret",
|
||||
secret_name,
|
||||
"-n",
|
||||
namespace,
|
||||
"-o",
|
||||
path_arg.as_str(),
|
||||
])
|
||||
.await?;
|
||||
if !result.ok() {
|
||||
return Ok(None);
|
||||
}
|
||||
let encoded = result.stdout.trim();
|
||||
if encoded.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
use base64::Engine as _;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.context("decoding base64 secret value")?;
|
||||
Ok(Some(String::from_utf8_lossy(&decoded).into_owned()))
|
||||
}
|
||||
|
||||
/// Helper to interpret the structured "ok=…" output line emitted by the Erlang
|
||||
/// rabbitmq publish snippet (admin/mq.rs).
|
||||
pub fn extract_publish_status(result: &ProcessResult) -> bool {
|
||||
result.ok() && result.stdout.contains("publish=ok")
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use tokio::time::{sleep, Instant};
|
||||
|
||||
use super::{battlegroup, run_process, KubectlClient};
|
||||
|
||||
/// Wraps the vendor `battlegroup` helper at `${DUNE_BIN_DIR}/battlegroup` plus
|
||||
/// the readiness-polling utilities both the daily-restart and update-apply
|
||||
/// shell scripts hand-rolled.
|
||||
#[derive(Clone)]
|
||||
pub struct BattlegroupCli {
|
||||
bin: PathBuf,
|
||||
}
|
||||
|
||||
impl BattlegroupCli {
|
||||
pub fn new(bin_dir: &std::path::Path) -> Self {
|
||||
Self {
|
||||
bin: bin_dir.join("battlegroup"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bin_str(&self) -> String {
|
||||
self.bin.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["stop"], None, 120).await?;
|
||||
result.require_ok("battlegroup stop")
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["start"], None, 120).await?;
|
||||
result.require_ok("battlegroup start")
|
||||
}
|
||||
|
||||
pub async fn restart(&self) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["restart"], None, 1200).await?;
|
||||
result.require_ok("battlegroup restart")
|
||||
}
|
||||
|
||||
pub async fn update(&self) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["update"], None, 3600).await?;
|
||||
result.require_ok("battlegroup update")
|
||||
}
|
||||
|
||||
pub async fn backup(&self, backup_name: &str) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["backup", backup_name], None, 600).await?;
|
||||
result.require_ok(&format!("battlegroup backup {backup_name}"))
|
||||
}
|
||||
|
||||
pub async fn update_from_downloads(&self) -> Result<()> {
|
||||
let bin = self.bin_str();
|
||||
let result = run_process(&bin, &["update-from-downloads"], None, 600).await?;
|
||||
result.require_ok("battlegroup update-from-downloads")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the battlegroup to reach a fully stopped state: `spec.stop=true`
|
||||
/// AND no server pods (matching `-sg-...-pod-`) present.
|
||||
pub async fn wait_until_stopped(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
bg_name: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_secs(10);
|
||||
while start.elapsed() < timeout {
|
||||
let stop_value = battlegroup::bg_field(kubectl, namespace, bg_name, "{.spec.stop}")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let pod_count = count_server_pods(kubectl, namespace).await.unwrap_or(0);
|
||||
tracing::info!(
|
||||
stop = %stop_value,
|
||||
pods = pod_count,
|
||||
elapsed_s = start.elapsed().as_secs(),
|
||||
"waiting for battlegroup stop"
|
||||
);
|
||||
if stop_value == "true" && pod_count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(interval).await;
|
||||
}
|
||||
Err(anyhow!(
|
||||
"timeout waiting for battlegroup {bg_name} to stop after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
}
|
||||
|
||||
/// Wait for the battlegroup to reach a fully-running state: serverGroupPhase
|
||||
/// is "Running" AND all servers report ready=true with phase=Running.
|
||||
pub async fn wait_until_running(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
bg_name: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<ReadySummary> {
|
||||
let start = Instant::now();
|
||||
let interval = Duration::from_secs(10);
|
||||
while start.elapsed() < timeout {
|
||||
if let Ok(summary) = ready_summary(kubectl, namespace, bg_name).await {
|
||||
tracing::info!(
|
||||
phase = %summary.phase,
|
||||
server_group_phase = %summary.server_group_phase,
|
||||
ready = %format!("{}/{}", summary.ready, summary.size),
|
||||
elapsed_s = start.elapsed().as_secs(),
|
||||
"waiting for battlegroup run"
|
||||
);
|
||||
if summary.is_running() {
|
||||
return Ok(summary);
|
||||
}
|
||||
}
|
||||
sleep(interval).await;
|
||||
}
|
||||
Err(anyhow!(
|
||||
"timeout waiting for battlegroup {bg_name} to become ready after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn count_server_pods(kubectl: &KubectlClient, namespace: &str) -> Result<usize> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"pods",
|
||||
"-n",
|
||||
namespace,
|
||||
"--no-headers",
|
||||
"-o",
|
||||
"custom-columns=NAME:.metadata.name,DEL:.metadata.deletionTimestamp",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get pods -n {namespace}"))?;
|
||||
let mut count = 0;
|
||||
for line in result.stdout.split('\n') {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let name = parts.next().unwrap_or("");
|
||||
let deletion = parts.next().unwrap_or("");
|
||||
if name.contains("-sg-")
|
||||
&& name.contains("-pod-")
|
||||
&& (deletion.is_empty() || deletion == "<none>")
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadySummary {
|
||||
pub phase: String,
|
||||
pub server_group_phase: String,
|
||||
pub ready: u32,
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
impl ReadySummary {
|
||||
/// A battlegroup counts as back up once both the overall phase and the
|
||||
/// server-group phase report a started-ish state. This mirrors the desktop
|
||||
/// UI classifier (`is_started_phase` in dune-manager-core) which treats
|
||||
/// `Reconciling` as up: a BG can linger at `phase=Reconciling` with
|
||||
/// `serverGroupPhase=Running` while every map server is already reachable.
|
||||
/// Holding out for `serverGroupPhase=="Running"` + all per-server `ready`
|
||||
/// flags produced false restart-timeout failures (issue #20), so we gate on
|
||||
/// the phases only and keep `ready`/`size` purely for logging.
|
||||
pub fn is_running(&self) -> bool {
|
||||
is_started_phase(&self.phase) && is_started_phase(&self.server_group_phase)
|
||||
}
|
||||
}
|
||||
|
||||
/// Phases that mean "the battlegroup is started / serving", matching the
|
||||
/// desktop UI's `is_started_phase`. `Reconciling` is included on purpose: the
|
||||
/// servers are reachable in that state even though the controller has not
|
||||
/// settled back to `Running`.
|
||||
fn is_started_phase(phase: &str) -> bool {
|
||||
matches!(
|
||||
phase.trim().to_ascii_lowercase().as_str(),
|
||||
"running" | "ready" | "healthy" | "available" | "reconciling"
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn ready_summary(
|
||||
kubectl: &KubectlClient,
|
||||
namespace: &str,
|
||||
bg_name: &str,
|
||||
) -> Result<ReadySummary> {
|
||||
let bg = battlegroup::bg_json(kubectl, namespace, bg_name).await?;
|
||||
let status = bg
|
||||
.get("status")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let phase = status
|
||||
.get("phase")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let server_group_phase = status
|
||||
.get("serverGroupPhase")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let servers = status
|
||||
.get("servers")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let ready = servers
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.get("ready").and_then(|v| v.as_bool()).unwrap_or(false)
|
||||
&& s.get("phase").and_then(|v| v.as_str()) == Some("Running")
|
||||
})
|
||||
.count() as u32;
|
||||
let size = status
|
||||
.get("size")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32)
|
||||
.unwrap_or_else(|| servers.len() as u32);
|
||||
Ok(ReadySummary {
|
||||
phase,
|
||||
server_group_phase,
|
||||
ready,
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn summary(phase: &str, sgp: &str, ready: u32, size: u32) -> ReadySummary {
|
||||
ReadySummary {
|
||||
phase: phase.to_string(),
|
||||
server_group_phase: sgp.to_string(),
|
||||
ready,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciling_bg_is_running_even_with_lagging_ready_flags() {
|
||||
// Issue #20: real payload was phase=Reconciling, serverGroupPhase=Running
|
||||
// with per-server ready flags not yet flipped. Servers were reachable, so
|
||||
// the gate must accept it instead of timing out at 1200s.
|
||||
assert!(summary("Reconciling", "Running", 0, 3).is_running());
|
||||
assert!(summary("Reconciling", "Reconciling", 1, 3).is_running());
|
||||
assert!(summary("Running", "Running", 3, 3).is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_or_empty_phase_is_not_running() {
|
||||
assert!(!summary("Stopped", "Stopped", 0, 0).is_running());
|
||||
assert!(!summary("", "", 0, 0).is_running());
|
||||
// serverGroupPhase still tearing down -> not up yet.
|
||||
assert!(!summary("Reconciling", "Stopped", 0, 3).is_running());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::KubectlClient;
|
||||
|
||||
const CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
static MQ_POD_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"-mq-game-sts-0$").unwrap());
|
||||
static DB_POD_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"-db-dbdepl-sts-0$").unwrap());
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Cluster {
|
||||
pub namespace: String,
|
||||
#[serde(rename = "mqPod")]
|
||||
pub mq_pod: String,
|
||||
#[serde(rename = "dbPod")]
|
||||
pub db_pod: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClusterCache {
|
||||
inner: Arc<Mutex<Option<CachedCluster>>>,
|
||||
kubectl: KubectlClient,
|
||||
}
|
||||
|
||||
struct CachedCluster {
|
||||
value: Cluster,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
impl ClusterCache {
|
||||
pub fn new(kubectl: KubectlClient) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
kubectl,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Result<Cluster> {
|
||||
self.get_with(false).await
|
||||
}
|
||||
|
||||
pub async fn refresh(&self) -> Result<Cluster> {
|
||||
self.get_with(true).await
|
||||
}
|
||||
|
||||
async fn get_with(&self, force: bool) -> Result<Cluster> {
|
||||
{
|
||||
let guard = self.inner.lock().await;
|
||||
if !force {
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
if cached.at.elapsed() < CACHE_TTL {
|
||||
return Ok(cached.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let value = self.detect().await?;
|
||||
let mut guard = self.inner.lock().await;
|
||||
*guard = Some(CachedCluster {
|
||||
value: value.clone(),
|
||||
at: Instant::now(),
|
||||
});
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn detect(&self) -> Result<Cluster> {
|
||||
let namespace = match self.kubectl.namespace_override() {
|
||||
Some(ns) => ns.to_string(),
|
||||
None => detect_namespace(&self.kubectl).await?,
|
||||
};
|
||||
|
||||
let mq_pod = match self.kubectl.mq_pod_override() {
|
||||
Some(p) => p.to_string(),
|
||||
None => detect_pod(&self.kubectl, &namespace, &MQ_POD_PATTERN).await?,
|
||||
};
|
||||
|
||||
let db_pod = match self.kubectl.db_pod_override() {
|
||||
Some(p) => Some(p.to_string()),
|
||||
None => detect_pod(&self.kubectl, &namespace, &DB_POD_PATTERN)
|
||||
.await
|
||||
.ok(),
|
||||
};
|
||||
|
||||
Ok(Cluster {
|
||||
namespace,
|
||||
mq_pod,
|
||||
db_pod,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn detect_namespace(kubectl: &KubectlClient) -> Result<String> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"pods",
|
||||
"-A",
|
||||
"--no-headers",
|
||||
"-o",
|
||||
"custom-columns=NS:.metadata.namespace,NAME:.metadata.name",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok("kubectl get pods -A")?;
|
||||
|
||||
let mut candidates = std::collections::BTreeSet::<String>::new();
|
||||
for line in result.stdout.split('\n') {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let ns = parts.next().unwrap_or("");
|
||||
let name = parts.next().unwrap_or("");
|
||||
if ns.starts_with("funcom-seabass-") && MQ_POD_PATTERN.is_match(name) {
|
||||
candidates.insert(ns.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match candidates.len() {
|
||||
0 => Err(anyhow!(
|
||||
"no funcom-seabass-* namespace with a Game RMQ pod found"
|
||||
)),
|
||||
1 => Ok(candidates.into_iter().next().unwrap()),
|
||||
_ => Err(anyhow!(
|
||||
"multiple candidate namespaces: {}; set DUNE_NAMESPACE",
|
||||
candidates.into_iter().collect::<Vec<_>>().join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn detect_pod(kubectl: &KubectlClient, namespace: &str, pattern: &Regex) -> Result<String> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"pods",
|
||||
"-n",
|
||||
namespace,
|
||||
"--no-headers",
|
||||
"-o",
|
||||
"custom-columns=NAME:.metadata.name",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get pods -n {namespace}"))?;
|
||||
|
||||
for line in result.stdout.split('\n') {
|
||||
let name = line.split_whitespace().next().unwrap_or("");
|
||||
if !name.is_empty() && pattern.is_match(name) {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"no pod matching {} in namespace {namespace}",
|
||||
pattern.as_str()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub mod battlegroup;
|
||||
pub mod battlegroup_cli;
|
||||
pub mod cluster;
|
||||
pub mod steam;
|
||||
|
||||
pub use battlegroup_cli::{BattlegroupCli, ReadySummary};
|
||||
pub use cluster::{Cluster, ClusterCache};
|
||||
pub use steam::SteamCmd;
|
||||
|
||||
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KubectlClient {
|
||||
use_sudo: bool,
|
||||
namespace_override: Option<String>,
|
||||
mq_pod_override: Option<String>,
|
||||
db_pod_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessResult {
|
||||
pub exit_code: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl ProcessResult {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.exit_code == 0
|
||||
}
|
||||
|
||||
pub fn require_ok(&self, ctx: &str) -> Result<()> {
|
||||
if self.ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = self.stderr.trim();
|
||||
let detail = if stderr.is_empty() {
|
||||
"no detail".to_string()
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
Err(anyhow!(
|
||||
"{ctx} failed (exit {}): {}",
|
||||
self.exit_code,
|
||||
detail
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl KubectlClient {
|
||||
pub fn new(
|
||||
use_sudo: bool,
|
||||
namespace_override: Option<String>,
|
||||
mq_pod_override: Option<String>,
|
||||
db_pod_override: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
use_sudo,
|
||||
namespace_override,
|
||||
mq_pod_override,
|
||||
db_pod_override,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn namespace_override(&self) -> Option<&str> {
|
||||
self.namespace_override.as_deref()
|
||||
}
|
||||
|
||||
pub fn mq_pod_override(&self) -> Option<&str> {
|
||||
self.mq_pod_override.as_deref()
|
||||
}
|
||||
|
||||
pub fn db_pod_override(&self) -> Option<&str> {
|
||||
self.db_pod_override.as_deref()
|
||||
}
|
||||
|
||||
pub async fn run(&self, args: &[&str]) -> Result<ProcessResult> {
|
||||
self.run_with(args, None, DEFAULT_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn run_with_stdin(&self, args: &[&str], stdin: &str) -> Result<ProcessResult> {
|
||||
self.run_with(args, Some(stdin), DEFAULT_TIMEOUT).await
|
||||
}
|
||||
|
||||
pub async fn run_timeout(
|
||||
&self,
|
||||
args: &[&str],
|
||||
stdin: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ProcessResult> {
|
||||
self.run_with(args, stdin, Duration::from_secs(timeout_secs))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_with(
|
||||
&self,
|
||||
args: &[&str],
|
||||
stdin: Option<&str>,
|
||||
dur: Duration,
|
||||
) -> Result<ProcessResult> {
|
||||
let (program, full_args): (&str, Vec<&str>) = if self.use_sudo {
|
||||
(
|
||||
"sudo",
|
||||
std::iter::once("kubectl")
|
||||
.chain(args.iter().copied())
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
("kubectl", args.to_vec())
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(&full_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
tracing::debug!(
|
||||
program = program,
|
||||
args = %full_args.join(" "),
|
||||
path = %std::env::var("PATH").unwrap_or_default(),
|
||||
"spawning subprocess"
|
||||
);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {program} {}", full_args.join(" ")))?;
|
||||
|
||||
if let Some(input) = stdin {
|
||||
if let Some(mut writer) = child.stdin.take() {
|
||||
writer
|
||||
.write_all(input.as_bytes())
|
||||
.await
|
||||
.context("writing stdin to kubectl")?;
|
||||
writer.shutdown().await.ok();
|
||||
}
|
||||
} else {
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let wait_fut = child.wait_with_output();
|
||||
match timeout(dur, wait_fut).await {
|
||||
Ok(Ok(out)) => Ok(ProcessResult {
|
||||
exit_code: out.status.code().unwrap_or(-1),
|
||||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
}),
|
||||
Ok(Err(err)) => Err(err).context("kubectl process error"),
|
||||
Err(_) => Err(anyhow!(
|
||||
"kubectl timed out after {}s: {}",
|
||||
dur.as_secs(),
|
||||
full_args.join(" ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a non-kubectl process (for `battlegroup` wrapper, `steamcmd`, etc.).
|
||||
/// Uses the same capture + timeout semantics.
|
||||
pub async fn run_process(
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
stdin: Option<&str>,
|
||||
timeout_secs: u64,
|
||||
) -> Result<ProcessResult> {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {program} {}", args.join(" ")))?;
|
||||
|
||||
if let Some(input) = stdin {
|
||||
if let Some(mut writer) = child.stdin.take() {
|
||||
writer
|
||||
.write_all(input.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("writing stdin to {program}"))?;
|
||||
writer.shutdown().await.ok();
|
||||
}
|
||||
} else {
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let dur = Duration::from_secs(timeout_secs);
|
||||
match timeout(dur, child.wait_with_output()).await {
|
||||
Ok(Ok(out)) => Ok(ProcessResult {
|
||||
exit_code: out.status.code().unwrap_or(-1),
|
||||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
}),
|
||||
Ok(Err(err)) => Err(err).with_context(|| format!("{program} process error")),
|
||||
Err(_) => Err(anyhow!(
|
||||
"{program} timed out after {}s: {}",
|
||||
dur.as_secs(),
|
||||
args.join(" ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::systemd_compat;
|
||||
|
||||
use super::run_process;
|
||||
|
||||
pub const DUNE_APP_ID: u32 = 4_754_530;
|
||||
|
||||
/// Result of `steamcmd ... +app_info_print <id>` parsing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppInfoBuild {
|
||||
pub buildid: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SteamCmd {
|
||||
bin: PathBuf,
|
||||
download_path: PathBuf,
|
||||
home_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SteamCmd {
|
||||
pub fn new(bin: PathBuf, download_path: PathBuf, home_path: PathBuf) -> Self {
|
||||
Self {
|
||||
bin,
|
||||
download_path,
|
||||
home_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bin_path(&self) -> &Path {
|
||||
&self.bin
|
||||
}
|
||||
|
||||
/// Ensure a steamcmd shell wrapper exists at `bin_path`. Idempotent. The
|
||||
/// wrapper probes for the registered user's Steam install first, then
|
||||
/// falls back to a system `steamcmd` (`/usr/bin/steamcmd`, common on
|
||||
/// Alpine). It always cd's into `download_path` so the vendor's
|
||||
/// relative-path assumptions hold.
|
||||
pub fn ensure_wrapper(&self) -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
if self.bin.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = self.bin.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
std::fs::create_dir_all(&self.download_path)
|
||||
.with_context(|| format!("creating {}", self.download_path.display()))?;
|
||||
|
||||
let home_path = shell_single_quoted(&self.home_path.to_string_lossy());
|
||||
let download_path = shell_single_quoted(&self.download_path.to_string_lossy());
|
||||
let steamcmd_sh = self.home_path.join("Steam").join("steamcmd.sh");
|
||||
let steamcmd_sh_quoted = shell_single_quoted(&steamcmd_sh.to_string_lossy());
|
||||
let body = format!(
|
||||
"#!/usr/bin/env sh\n\
|
||||
# Auto-generated by dune-server-service. Multi-backend steamcmd wrapper.\n\
|
||||
set -eu\n\
|
||||
export HOME={home_path}\n\
|
||||
cd {download_path}\n\
|
||||
if [ -x {steamcmd_sh_quoted} ]; then\n \
|
||||
exec {steamcmd_sh_quoted} \"$@\"\n\
|
||||
elif [ -x /usr/bin/steamcmd ]; then\n \
|
||||
exec /usr/bin/steamcmd \"$@\"\n\
|
||||
else\n \
|
||||
echo \"no steamcmd backend found (need {steamcmd_sh} or /usr/bin/steamcmd)\" >&2\n \
|
||||
exit 1\n\
|
||||
fi\n",
|
||||
home_path = home_path,
|
||||
download_path = download_path,
|
||||
steamcmd_sh = steamcmd_sh.display(),
|
||||
steamcmd_sh_quoted = steamcmd_sh_quoted,
|
||||
);
|
||||
|
||||
std::fs::write(&self.bin, body)
|
||||
.with_context(|| format!("writing wrapper {}", self.bin.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&self.bin)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&self.bin, perms)?;
|
||||
}
|
||||
tracing::info!(path = %self.bin.display(), "created steamcmd wrapper");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn manifest_path(&self) -> PathBuf {
|
||||
self.download_path
|
||||
.join("steamapps")
|
||||
.join(format!("appmanifest_{DUNE_APP_ID}.acf"))
|
||||
}
|
||||
|
||||
pub fn downloaded_version_path(&self) -> PathBuf {
|
||||
self.download_path
|
||||
.join("images")
|
||||
.join("battlegroup")
|
||||
.join("version.txt")
|
||||
}
|
||||
|
||||
/// Run steamcmd against the public branch and pluck the `buildid` out of
|
||||
/// the `app_info_print` output for the Dune app. Retries once on cold-cache
|
||||
/// runs where the first `app_info_update` returns an empty payload.
|
||||
pub async fn latest_public_build(&self) -> Result<AppInfoBuild> {
|
||||
let bin = self.bin.to_string_lossy().into_owned();
|
||||
let id = DUNE_APP_ID.to_string();
|
||||
let mut last_stdout = String::new();
|
||||
let mut last_stderr = String::new();
|
||||
let mut last_exit: i32 = 0;
|
||||
for attempt in 1..=2 {
|
||||
let result = run_process(
|
||||
&bin,
|
||||
&[
|
||||
"+login",
|
||||
"anonymous",
|
||||
"+app_info_update",
|
||||
"1",
|
||||
"+app_info_print",
|
||||
&id,
|
||||
"+quit",
|
||||
],
|
||||
None,
|
||||
180,
|
||||
)
|
||||
.await
|
||||
.context("invoking steamcmd app_info_print")?;
|
||||
last_exit = result.exit_code;
|
||||
last_stdout = result.stdout;
|
||||
last_stderr = result.stderr;
|
||||
let combined = format!("{last_stdout}\n{last_stderr}");
|
||||
if systemd_compat::steamcmd_relocation_blocked(&combined) {
|
||||
match systemd_compat::repair_after_steamcmd_relocation_failure() {
|
||||
Ok(true) => {
|
||||
return Err(anyhow!(
|
||||
"steamcmd was blocked by systemd MemoryDenyWriteExecute; installed compatibility override and requested dune-server-service restart"
|
||||
));
|
||||
}
|
||||
Ok(false) => {
|
||||
return Err(anyhow!(
|
||||
"steamcmd was blocked by systemd MemoryDenyWriteExecute, but no systemd repair was applied; reinstall or update the management service unit"
|
||||
));
|
||||
}
|
||||
Err(repair_err) => {
|
||||
return Err(anyhow!(
|
||||
"steamcmd was blocked by systemd MemoryDenyWriteExecute and automatic repair failed: {repair_err:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(buildid) = parse_buildid_from_app_info(&last_stdout)
|
||||
.or_else(|| parse_buildid_from_app_info(&last_stderr))
|
||||
{
|
||||
return Ok(AppInfoBuild { buildid });
|
||||
}
|
||||
if attempt == 1 {
|
||||
// Cold-cache: first app_info_update sometimes just primes Steam's
|
||||
// local cache without returning branch detail. A second pass picks
|
||||
// it up.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"could not determine latest Steam buildid from app_info_print (exit {last_exit}); stdout_tail: {tail}; stderr_tail: {err_tail}",
|
||||
tail = tail(&last_stdout, 1200),
|
||||
err_tail = tail(&last_stderr, 400),
|
||||
))
|
||||
}
|
||||
|
||||
/// Read the local appmanifest_*.acf and pluck the local buildid.
|
||||
pub async fn local_build(&self) -> Result<Option<String>> {
|
||||
let path = self.manifest_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
Ok(parse_buildid_from_acf(&contents))
|
||||
}
|
||||
|
||||
/// Read the downloaded `images/battlegroup/version.txt` file produced by
|
||||
/// the vendor download flow. None when missing/empty.
|
||||
pub async fn downloaded_version(&self) -> Result<Option<String>> {
|
||||
let path = self.downloaded_version_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let trimmed: String = contents.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
/// `steamcmd +force_install_dir <path> +login anonymous +app_update <id> +quit`.
|
||||
pub async fn download_update(&self) -> Result<()> {
|
||||
let bin = self.bin.to_string_lossy().into_owned();
|
||||
let install_dir = self.download_path.to_string_lossy().into_owned();
|
||||
let id = DUNE_APP_ID.to_string();
|
||||
let result = run_process(
|
||||
&bin,
|
||||
&[
|
||||
"+set_spew_level",
|
||||
"1",
|
||||
"1",
|
||||
"+force_install_dir",
|
||||
&install_dir,
|
||||
"+login",
|
||||
"anonymous",
|
||||
"+app_update",
|
||||
&id,
|
||||
"+logoff",
|
||||
"+quit",
|
||||
],
|
||||
None,
|
||||
1800,
|
||||
)
|
||||
.await
|
||||
.context("invoking steamcmd app_update")?;
|
||||
result.require_ok("steamcmd app_update")
|
||||
}
|
||||
|
||||
pub fn download_path(&self) -> &Path {
|
||||
&self.download_path
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_single_quoted(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
/// Last `max` bytes of `s`, trimmed and prefixed with `…` if truncated. Used
|
||||
/// to include steamcmd output in error messages without flooding the log.
|
||||
/// UTF-8 safe — snaps to the next char boundary if `max` lands inside one.
|
||||
fn tail(s: &str, max: usize) -> String {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.len() <= max {
|
||||
return trimmed.replace('\n', " ⏎ ");
|
||||
}
|
||||
let mut start = trimmed.len() - max;
|
||||
while start < trimmed.len() && !trimmed.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let mut out = String::from("…");
|
||||
out.push_str(&trimmed[start..].replace('\n', " ⏎ "));
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the `buildid` value from `steamcmd +app_info_print` stdout. We rely
|
||||
/// on the same minimal state-machine the original awk script used: locate the
|
||||
/// "public" key inside the "branches" block, then the next "buildid" line.
|
||||
fn parse_buildid_from_app_info(stdout: &str) -> Option<String> {
|
||||
let mut in_branches = false;
|
||||
let mut in_public = false;
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains("\"branches\"") {
|
||||
in_branches = true;
|
||||
continue;
|
||||
}
|
||||
if in_branches && trimmed.contains("\"public\"") {
|
||||
in_public = true;
|
||||
continue;
|
||||
}
|
||||
if in_public && trimmed.contains("\"buildid\"") {
|
||||
// line shape: "buildid" "12345"
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
parts.next(); // "buildid"
|
||||
if let Some(val) = parts.next() {
|
||||
let cleaned = val.trim_matches('"');
|
||||
if !cleaned.is_empty() {
|
||||
return Some(cleaned.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse the `buildid` value from an `appmanifest_*.acf` file.
|
||||
/// VDF format: `"buildid" "12345"` with whitespace between key and value.
|
||||
fn parse_buildid_from_acf(text: &str) -> Option<String> {
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("\"buildid\"") {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
parts.next();
|
||||
if let Some(val) = parts.next() {
|
||||
let cleaned = val.trim_matches('"');
|
||||
if !cleaned.is_empty() {
|
||||
return Some(cleaned.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the seabass-server image version tag (e.g. `12345-0-abcde`) from a
|
||||
/// BattleGroup JSON spec. Walks any nested object looking for `image:` strings
|
||||
/// pointing at seabass-server, regex-matches the version suffix, returns the
|
||||
/// minimum (lexicographic) version it finds — mirrors the python helper used
|
||||
/// by `cron-battlegroup-update-check`.
|
||||
pub fn extract_live_version(bg_json: &serde_json::Value) -> Option<String> {
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
static IMAGE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"/seabass-server(?::|[^:]*:)([0-9]+-0-[A-Za-z0-9_-]+)$").unwrap());
|
||||
let mut versions: Vec<String> = Vec::new();
|
||||
walk(bg_json, &mut |v| {
|
||||
if let serde_json::Value::String(s) = v {
|
||||
if let Some(caps) = IMAGE_RE.captures(s) {
|
||||
versions.push(caps[1].to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
versions.sort();
|
||||
versions.into_iter().next()
|
||||
}
|
||||
|
||||
fn walk<F: FnMut(&serde_json::Value)>(v: &serde_json::Value, f: &mut F) {
|
||||
f(v);
|
||||
match v {
|
||||
serde_json::Value::Object(m) => {
|
||||
for (_, v2) in m {
|
||||
walk(v2, f);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(a) => {
|
||||
for v2 in a {
|
||||
walk(v2, f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_buildid_from_app_info_output() {
|
||||
let sample = r#"
|
||||
"4754530"
|
||||
{
|
||||
"branches"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"buildid" "12345678"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
assert_eq!(
|
||||
parse_buildid_from_app_info(sample),
|
||||
Some("12345678".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_buildid_from_acf() {
|
||||
let sample = r#"
|
||||
"AppState"
|
||||
{
|
||||
"appid" "4754530"
|
||||
"buildid" "99999"
|
||||
}"#;
|
||||
assert_eq!(parse_buildid_from_acf(sample), Some("99999".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_live_version_from_image_tag() {
|
||||
let bg = serde_json::json!({
|
||||
"spec": {
|
||||
"serverGroup": {"template": {"spec": {"image": "registry.example/seabass-server:99-0-rev-abc"}}}
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_live_version(&bg), Some("99-0-rev-abc".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod admin;
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod http;
|
||||
pub mod kubectl;
|
||||
pub mod logger;
|
||||
pub mod postgres;
|
||||
pub mod scheduler;
|
||||
pub mod store;
|
||||
pub mod systemd_compat;
|
||||
pub mod tasks;
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
static ACTIVE_TOKEN: OnceLock<String> = OnceLock::new();
|
||||
|
||||
pub fn init() {
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,dune_server_service=debug"));
|
||||
|
||||
// Colour escapes look fine in an interactive terminal but render as raw
|
||||
// `[2m[32m...[0m` noise when journalctl / a tail panel reads the log
|
||||
// file. Only emit ANSI when stdout is actually a TTY.
|
||||
let with_ansi = std::io::stdout().is_terminal();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_ansi(with_ansi),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Register the active command-auth token so it can be scrubbed from log/error
|
||||
/// strings. Callers must invoke `redact` before emitting any string that may
|
||||
/// have come from a command-publish error path.
|
||||
///
|
||||
/// Only the first call wins; subsequent token loads (e.g. after a manual
|
||||
/// refresh) are ignored. Token rotation requires a daemon restart.
|
||||
pub fn register_token(token: &str) {
|
||||
let _ = ACTIVE_TOKEN.set(token.to_string());
|
||||
}
|
||||
|
||||
/// Replace every occurrence of the registered token with `***` so accidental
|
||||
/// inclusion in error/log strings does not leak it through journald.
|
||||
pub fn redact(input: &str) -> std::borrow::Cow<'_, str> {
|
||||
match ACTIVE_TOKEN.get() {
|
||||
Some(token) if !token.is_empty() && input.contains(token) => {
|
||||
std::borrow::Cow::Owned(input.replace(token, "***"))
|
||||
}
|
||||
_ => std::borrow::Cow::Borrowed(input),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use dune_server_service::admin::MqPublisher;
|
||||
use dune_server_service::config::{resolve_command_auth_token, ServiceConfig};
|
||||
use dune_server_service::http::{self, AppState};
|
||||
use dune_server_service::kubectl::{BattlegroupCli, ClusterCache, KubectlClient, SteamCmd};
|
||||
use dune_server_service::logger;
|
||||
use dune_server_service::postgres::{PgClient, PgConfig};
|
||||
use dune_server_service::scheduler::{Scheduler, TaskRunner};
|
||||
use dune_server_service::store::Store;
|
||||
use dune_server_service::systemd_compat;
|
||||
use dune_server_service::tasks::TaskEnv;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const DEFAULT_PATH_EXTRAS: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
for arg in std::env::args().skip(1) {
|
||||
match arg.as_str() {
|
||||
"--version" | "-V" | "version" => {
|
||||
println!("dune-server-service {VERSION}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"dune-server-service {VERSION}\n\
|
||||
usage: dune-server-service [--version] [--help]\n\
|
||||
With no flags, runs the daemon (see env vars + systemd unit)."
|
||||
);
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: set_var requires no other threads to be running. We are still
|
||||
// single-threaded here (before the tokio runtime is built below). Inject a
|
||||
// sane PATH that covers common kubectl / battlegroup / steamcmd locations
|
||||
// so the daemon's subprocesses don't depend on the init system's PATH.
|
||||
unsafe {
|
||||
let merged = match std::env::var_os("PATH") {
|
||||
Some(existing) if !existing.is_empty() => {
|
||||
let mut v = std::ffi::OsString::from(DEFAULT_PATH_EXTRAS);
|
||||
v.push(":");
|
||||
v.push(existing);
|
||||
v
|
||||
}
|
||||
_ => DEFAULT_PATH_EXTRAS.into(),
|
||||
};
|
||||
std::env::set_var("PATH", merged);
|
||||
}
|
||||
|
||||
logger::init();
|
||||
|
||||
match systemd_compat::repair_on_startup_if_needed() {
|
||||
Ok(true) => {
|
||||
tracing::warn!(
|
||||
"systemd sandbox blocked steamcmd text relocations; compatibility override installed, exiting for restart"
|
||||
);
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to verify systemd steamcmd compatibility; steam update checks may fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to build tokio runtime");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(async {
|
||||
match run().await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "dune-server-service exiting with error");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let cfg = ServiceConfig::from_env().context("loading config")?;
|
||||
let token = resolve_command_auth_token(&cfg.command_auth_token_file);
|
||||
logger::register_token(&token);
|
||||
|
||||
tracing::info!(
|
||||
version = VERSION,
|
||||
bind = %format!("{}:{}", cfg.dashboard_host, cfg.dashboard_port),
|
||||
db_path = %cfg.db_path.display(),
|
||||
time_zone = %cfg.time_zone,
|
||||
"dune-server-service starting"
|
||||
);
|
||||
|
||||
let store = Store::open(&cfg.db_path).context("opening sqlite store")?;
|
||||
|
||||
let kubectl = KubectlClient::new(
|
||||
cfg.kubectl_use_sudo,
|
||||
cfg.namespace_override.clone(),
|
||||
cfg.mq_pod_override.clone(),
|
||||
cfg.db_pod_override.clone(),
|
||||
);
|
||||
let cluster = ClusterCache::new(kubectl.clone());
|
||||
|
||||
let bg_cli = BattlegroupCli::new(&cfg.bin_dir);
|
||||
let download_path = cfg
|
||||
.steamcmd_download_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.service_home.join(".dune").join("download"));
|
||||
let steamcmd_bin = cfg
|
||||
.steamcmd_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.service_home.join(".local").join("bin").join("steamcmd"));
|
||||
let steamcmd = SteamCmd::new(
|
||||
steamcmd_bin,
|
||||
download_path.clone(),
|
||||
cfg.service_home.clone(),
|
||||
);
|
||||
if let Err(err) = steamcmd.ensure_wrapper() {
|
||||
tracing::warn!(error = %err, "could not ensure steamcmd wrapper; update-check will fail until resolved");
|
||||
}
|
||||
|
||||
let mq = Arc::new(MqPublisher::new(
|
||||
kubectl.clone(),
|
||||
cluster.clone(),
|
||||
token.clone(),
|
||||
));
|
||||
let pg = Arc::new(PgClient::new(
|
||||
kubectl.clone(),
|
||||
PgConfig {
|
||||
host_override: cfg.pg_host_override.clone(),
|
||||
user_override: cfg.pg_user_override.clone(),
|
||||
db_override: cfg.pg_db_override.clone(),
|
||||
},
|
||||
));
|
||||
|
||||
// Defaults; operator can override any of these via POST /api/config which
|
||||
// upserts into the `task_config` KV table. We apply them at startup only —
|
||||
// a change to /api/config requires a service restart to take effect.
|
||||
// Master enable switches default to ON so existing installs (no stored
|
||||
// row) keep their prior behavior. Backups stay gated behind a cron too.
|
||||
let mut restart_enabled = true;
|
||||
let mut update_enabled = true;
|
||||
let mut backup_enabled = true;
|
||||
let mut update_lead_secs: i64 = 30 * 60;
|
||||
let mut restart_hour: u32 = 5;
|
||||
let mut restart_minute: u32 = 0;
|
||||
let mut restart_warning_frequency_secs: u64 = 600;
|
||||
let mut restart_warning_duration_secs: u64 = 1800;
|
||||
let mut welcome_package_enabled = false;
|
||||
let mut welcome_message_enabled = false;
|
||||
let welcome_package_version = String::from("v1");
|
||||
let mut welcome_package_actions_json = String::from("[]");
|
||||
let mut welcome_whisper_source_player = String::new();
|
||||
let mut welcome_message = String::new();
|
||||
// Backups default to OFF. Operator opts in by POSTing /api/config with a
|
||||
// cron expression in `backupCron`.
|
||||
let mut backup_cron: Option<cron::Schedule> = None;
|
||||
let mut backup_cron_raw: Option<String> = None;
|
||||
if let Ok(Some(v)) = store.get_config_i64("restart_enabled") {
|
||||
restart_enabled = v != 0;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("update_enabled") {
|
||||
update_enabled = v != 0;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("backup_enabled") {
|
||||
backup_enabled = v != 0;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("update_lead_secs") {
|
||||
update_lead_secs = v;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("restart_hour") {
|
||||
restart_hour = v as u32;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("restart_minute") {
|
||||
restart_minute = v as u32;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("restart_warning_frequency_secs") {
|
||||
restart_warning_frequency_secs = v as u64;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("restart_warning_duration_secs") {
|
||||
restart_warning_duration_secs = v as u64;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("welcome_package_enabled") {
|
||||
welcome_package_enabled = v != 0;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config_i64("welcome_message_enabled") {
|
||||
welcome_message_enabled = v != 0;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config("welcome_package_actions_json") {
|
||||
welcome_package_actions_json = v;
|
||||
} else if let Ok(Some(v)) = store.get_config("welcome_package_items_json") {
|
||||
welcome_package_actions_json = v;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config("welcome_whisper_source_player") {
|
||||
welcome_whisper_source_player = v;
|
||||
}
|
||||
if let Ok(Some(v)) = store.get_config("welcome_message") {
|
||||
welcome_message = v;
|
||||
}
|
||||
if let Ok(Some(expr)) = store.get_config("backup_cron") {
|
||||
let trimmed = expr.trim();
|
||||
if !trimmed.is_empty() {
|
||||
match dune_server_service::scheduler::schedule::parse_cron(trimmed) {
|
||||
Ok(schedule) => {
|
||||
backup_cron = Some(schedule);
|
||||
backup_cron_raw = Some(trimmed.to_string());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(stored = %trimmed, error = %err, "ignoring invalid stored backup_cron");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let welcome_package_actions =
|
||||
match dune_server_service::tasks::welcome_package::parse_welcome_actions(
|
||||
&welcome_package_actions_json,
|
||||
) {
|
||||
Ok(actions) => actions,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"ignoring invalid welcome_package_actions_json; welcome package disabled for this daemon run"
|
||||
);
|
||||
welcome_package_enabled = false;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut effective_tz = cfg.time_zone;
|
||||
if let Ok(Some(tz_name)) = store.get_config("restart_tz") {
|
||||
match tz_name.parse::<chrono_tz::Tz>() {
|
||||
Ok(tz) => effective_tz = tz,
|
||||
Err(err) => {
|
||||
tracing::warn!(stored = %tz_name, error = %err, "ignoring invalid stored restart_tz, falling back to env");
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
restart_enabled,
|
||||
update_enabled,
|
||||
backup_enabled,
|
||||
update_lead_secs,
|
||||
restart_hour,
|
||||
restart_minute,
|
||||
restart_warning_frequency_secs,
|
||||
restart_warning_duration_secs,
|
||||
backup_cron = backup_cron_raw.as_deref().unwrap_or("(disabled)"),
|
||||
welcome_package_enabled,
|
||||
welcome_message_enabled,
|
||||
welcome_package_version = %welcome_package_version,
|
||||
welcome_package_scan_secs = 2_u64,
|
||||
welcome_message_scan_secs = 60_u64,
|
||||
welcome_package_actions = welcome_package_actions.len(),
|
||||
welcome_whisper_source_player = %welcome_whisper_source_player,
|
||||
welcome_message_configured = !welcome_message.trim().is_empty(),
|
||||
tz = %effective_tz.name(),
|
||||
"task schedule resolved"
|
||||
);
|
||||
|
||||
let env = Arc::new(TaskEnv {
|
||||
kubectl: kubectl.clone(),
|
||||
cluster: cluster.clone(),
|
||||
bg_cli,
|
||||
steamcmd,
|
||||
mq,
|
||||
pg,
|
||||
bin_dir: cfg.bin_dir.clone(),
|
||||
download_path,
|
||||
restart_enabled,
|
||||
update_enabled,
|
||||
backup_enabled,
|
||||
update_lead_secs,
|
||||
restart_hour,
|
||||
restart_minute,
|
||||
restart_warning_frequency_secs,
|
||||
restart_warning_duration_secs,
|
||||
restart_tz: effective_tz,
|
||||
backup_cron,
|
||||
backup_cron_raw,
|
||||
welcome_package_enabled,
|
||||
welcome_message_enabled,
|
||||
welcome_package_version,
|
||||
welcome_package_actions,
|
||||
welcome_package_actions_json,
|
||||
welcome_whisper_source_player,
|
||||
welcome_message,
|
||||
});
|
||||
|
||||
let runner = Arc::new(TaskRunner::new(store.clone(), env.clone()));
|
||||
let mut scheduler = Scheduler::new(runner.clone(), effective_tz);
|
||||
for task in dune_server_service::tasks::build_all(env.clone()) {
|
||||
scheduler.add(task);
|
||||
}
|
||||
let cancel = scheduler.cancel_token();
|
||||
scheduler.start();
|
||||
|
||||
let state = AppState::new(store, env, runner);
|
||||
let server_cancel = cancel.clone();
|
||||
|
||||
let http_handle = tokio::spawn(async move {
|
||||
if let Err(err) = http::serve(&cfg, state, server_cancel).await {
|
||||
tracing::error!(error = %err, "http server exited with error");
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("ctrl-c received; shutting down");
|
||||
}
|
||||
_ = wait_sigterm() => {
|
||||
tracing::info!("SIGTERM received; shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
cancel.cancel();
|
||||
scheduler.shutdown().await;
|
||||
let _ = http_handle.await;
|
||||
tracing::info!("dune-server-service stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn wait_sigterm() {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
if let Ok(mut sig) = signal(SignalKind::terminate()) {
|
||||
sig.recv().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn wait_sigterm() {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_postgres::{Client, NoTls};
|
||||
|
||||
use crate::kubectl::{battlegroup, KubectlClient};
|
||||
|
||||
const DEFAULT_HOST_PORT: u16 = 15432;
|
||||
const DEFAULT_CLUSTER_PORT: u16 = 5432;
|
||||
const DEFAULT_DB: &str = "dune";
|
||||
const DEFAULT_USER: &str = "dune";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// User-supplied overrides for the Postgres connection. Any field left as None
|
||||
/// is resolved from the BattleGroup CRD / loopback probe / ClusterIP lookup.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PgConfig {
|
||||
pub host_override: Option<String>,
|
||||
pub user_override: Option<String>,
|
||||
pub db_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PgCredentials {
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
/// Pooled-ish Postgres handle. Holds a single connected `Client`; on connection
|
||||
/// loss the next call reconnects.
|
||||
pub struct PgClient {
|
||||
kubectl: KubectlClient,
|
||||
config: PgConfig,
|
||||
inner: Mutex<Option<Arc<ClientState>>>,
|
||||
}
|
||||
|
||||
pub struct ClientState {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl PgClient {
|
||||
pub fn new(kubectl: KubectlClient, config: PgConfig) -> Self {
|
||||
Self {
|
||||
kubectl,
|
||||
config,
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a connected client, reconnecting if the previous connection has
|
||||
/// closed.
|
||||
pub async fn client(&self, namespace: &str) -> Result<Arc<ClientState>> {
|
||||
{
|
||||
let guard = self.inner.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
if !state.client.is_closed() {
|
||||
return Ok(state.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state = self.connect(namespace).await?;
|
||||
let arc = Arc::new(state);
|
||||
let mut guard = self.inner.lock().await;
|
||||
*guard = Some(arc.clone());
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
/// Create a fresh connection for operations that need exclusive session
|
||||
/// state, such as an explicit SQL transaction.
|
||||
pub async fn dedicated_client(&self, namespace: &str) -> Result<ClientState> {
|
||||
self.connect(namespace).await
|
||||
}
|
||||
|
||||
async fn connect(&self, namespace: &str) -> Result<ClientState> {
|
||||
let creds = self.resolve_credentials(namespace).await?;
|
||||
let endpoint = self.resolve_endpoint(namespace).await?;
|
||||
|
||||
let conninfo = format!(
|
||||
"host={} port={} user={} password={} dbname={} connect_timeout=5 application_name=dune-server-service",
|
||||
endpoint.host,
|
||||
endpoint.port,
|
||||
shell_escape_keyvalue(&creds.user),
|
||||
shell_escape_keyvalue(&creds.password),
|
||||
shell_escape_keyvalue(&creds.database),
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
host = endpoint.host.as_str(),
|
||||
port = endpoint.port,
|
||||
user = creds.user.as_str(),
|
||||
db = creds.database.as_str(),
|
||||
"connecting to postgres"
|
||||
);
|
||||
|
||||
let (client, connection) = tokio_postgres::connect(&conninfo, NoTls)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"connecting to postgres at {}:{}",
|
||||
endpoint.host, endpoint.port
|
||||
)
|
||||
})?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = connection.await {
|
||||
tracing::error!(error = %err, "postgres connection task ended");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ClientState { client })
|
||||
}
|
||||
|
||||
async fn resolve_credentials(&self, namespace: &str) -> Result<PgCredentials> {
|
||||
let bg = battlegroup::bg_name(&self.kubectl, namespace).await?;
|
||||
let user = match &self.config.user_override {
|
||||
Some(u) if !u.is_empty() => u.clone(),
|
||||
_ => {
|
||||
let raw = battlegroup::bg_field(
|
||||
&self.kubectl,
|
||||
namespace,
|
||||
&bg,
|
||||
"{.spec.database.template.spec.deployment.spec.user}",
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if raw.is_empty() {
|
||||
DEFAULT_USER.to_string()
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
};
|
||||
let database = match &self.config.db_override {
|
||||
Some(d) if !d.is_empty() => d.clone(),
|
||||
_ => {
|
||||
let raw = battlegroup::bg_field(
|
||||
&self.kubectl,
|
||||
namespace,
|
||||
&bg,
|
||||
"{.spec.database.template.spec.deployment.spec.gameDatabaseName}",
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if raw.is_empty() {
|
||||
DEFAULT_DB.to_string()
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
};
|
||||
let password = battlegroup::bg_field(
|
||||
&self.kubectl,
|
||||
namespace,
|
||||
&bg,
|
||||
"{.spec.database.template.spec.deployment.spec.password}",
|
||||
)
|
||||
.await?;
|
||||
if password.is_empty() {
|
||||
return Err(anyhow!("could not read DB password from BattleGroup {bg}"));
|
||||
}
|
||||
Ok(PgCredentials {
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_endpoint(&self, namespace: &str) -> Result<PgEndpoint> {
|
||||
if let Some(host) = &self.config.host_override {
|
||||
if !host.is_empty() {
|
||||
return Ok(parse_host(host));
|
||||
}
|
||||
}
|
||||
|
||||
// Probe 127.0.0.1:15432 (the host port the desktop's database tunnel targets).
|
||||
if probe_loopback(DEFAULT_HOST_PORT).await {
|
||||
return Ok(PgEndpoint {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: DEFAULT_HOST_PORT,
|
||||
});
|
||||
}
|
||||
tracing::info!(
|
||||
"loopback 127.0.0.1:{} unavailable; falling back to k3s ClusterIP",
|
||||
DEFAULT_HOST_PORT
|
||||
);
|
||||
|
||||
// Fall back to k3s ClusterIP for the database service.
|
||||
let cluster_ip = lookup_db_cluster_ip(&self.kubectl, namespace).await?;
|
||||
Ok(PgEndpoint {
|
||||
host: cluster_ip,
|
||||
port: DEFAULT_CLUSTER_PORT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
pub fn client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
pub fn client_mut(&mut self) -> &mut Client {
|
||||
&mut self.client
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_host(raw: &str) -> PgEndpoint {
|
||||
if let Some((host, port)) = raw.rsplit_once(':') {
|
||||
if let Ok(p) = port.parse::<u16>() {
|
||||
return PgEndpoint {
|
||||
host: host.to_string(),
|
||||
port: p,
|
||||
};
|
||||
}
|
||||
}
|
||||
PgEndpoint {
|
||||
host: raw.to_string(),
|
||||
port: DEFAULT_HOST_PORT,
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_loopback(port: u16) -> bool {
|
||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
|
||||
Ok(Ok(_)) => true,
|
||||
Ok(Err(_)) | Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn lookup_db_cluster_ip(kubectl: &KubectlClient, namespace: &str) -> Result<String> {
|
||||
let result = kubectl
|
||||
.run(&[
|
||||
"get",
|
||||
"svc",
|
||||
"-n",
|
||||
namespace,
|
||||
"-o",
|
||||
"custom-columns=NAME:.metadata.name,IP:.spec.clusterIP",
|
||||
"--no-headers",
|
||||
])
|
||||
.await?;
|
||||
result.require_ok(&format!("kubectl get svc -n {namespace}"))?;
|
||||
|
||||
let mut candidate: Option<String> = None;
|
||||
for line in result.stdout.split('\n') {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let name = parts.next().unwrap_or("");
|
||||
let ip = parts.next().unwrap_or("");
|
||||
if ip == "None" || ip.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if name.contains("db")
|
||||
&& (name.contains("postgres") || name.contains("dbdepl") || name.contains("database"))
|
||||
{
|
||||
candidate = Some(ip.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
candidate.ok_or_else(|| {
|
||||
anyhow!("could not locate database service ClusterIP in namespace {namespace}")
|
||||
})
|
||||
}
|
||||
|
||||
/// Escape a value for use in a libpq key=value connection string. Wraps in
|
||||
/// single quotes and escapes embedded backslashes/quotes.
|
||||
fn shell_escape_keyvalue(value: &str) -> String {
|
||||
let escaped = value.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_host_picks_up_explicit_port() {
|
||||
let e = parse_host("10.0.0.5:25432");
|
||||
assert_eq!(e.host, "10.0.0.5");
|
||||
assert_eq!(e.port, 25432);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_host_defaults_when_no_port() {
|
||||
let e = parse_host("postgres.example");
|
||||
assert_eq!(e.host, "postgres.example");
|
||||
assert_eq!(e.port, DEFAULT_HOST_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_escape_wraps_and_escapes() {
|
||||
assert_eq!(shell_escape_keyvalue("simple"), "'simple'");
|
||||
assert_eq!(shell_escape_keyvalue("with'quote"), "'with\\'quote'");
|
||||
assert_eq!(shell_escape_keyvalue("back\\slash"), "'back\\\\slash'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod conn;
|
||||
pub mod queries;
|
||||
|
||||
pub use conn::{PgClient, PgConfig, PgCredentials, PgEndpoint};
|
||||
pub use queries::{
|
||||
get_player_location, insert_items_to_backpack, list_welcome_accounts, resolve_account_backpack,
|
||||
resolve_chat_player, search_players, AccountBackpack, BackpackGrantItem, ChatPlayer, Player,
|
||||
PlayerLocation, PositionProbe, WelcomeAccount,
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::conn::PgClient;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlayerLocation {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
#[serde(rename = "dimensionIndex")]
|
||||
pub dimension_index: Option<i32>,
|
||||
#[serde(rename = "partitionId")]
|
||||
pub partition_id: Option<i64>,
|
||||
/// Pawn actor class — useful sanity for the UI ("…DunePlayerCharacter_C").
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
// The live player position is on `dune.actors`, not `dune.player_state`. The
|
||||
// pawn actor is referenced from `player_state.player_pawn_id`. Its `transform`
|
||||
// is a composite `(location:(x,y,z), rotation:(x,y,z,w))`. Confirmed via
|
||||
// schema probe 2026-05-26 against funcom-seabass-sh-* on the LAN test host.
|
||||
const PLAYER_POSITION_SQL: &str = "
|
||||
SELECT
|
||||
((a.transform).location).x::float8 AS x,
|
||||
((a.transform).location).y::float8 AS y,
|
||||
((a.transform).location).z::float8 AS z,
|
||||
a.dimension_index,
|
||||
a.partition_id,
|
||||
a.class
|
||||
FROM dune.player_state ps
|
||||
JOIN dune.actors a ON a.id = ps.player_pawn_id
|
||||
JOIN dune.encrypted_accounts acct ON acct.id = ps.account_id
|
||||
WHERE acct.\"user\"::text = $1
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Player {
|
||||
#[serde(rename = "flsId")]
|
||||
pub fls_id: String,
|
||||
pub name: String,
|
||||
pub online: String,
|
||||
#[serde(rename = "lastSeen")]
|
||||
pub last_seen: String,
|
||||
pub level: Option<i32>,
|
||||
#[serde(rename = "partitionId")]
|
||||
pub partition_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WelcomeAccount {
|
||||
pub account_id: i64,
|
||||
pub fls_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountBackpack {
|
||||
pub inventory_id: i64,
|
||||
pub character_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatPlayer {
|
||||
pub account_id: i64,
|
||||
pub fls_id: String,
|
||||
pub funcom_id: String,
|
||||
pub character_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackpackGrantItem {
|
||||
pub template_id: String,
|
||||
pub quantity: i64,
|
||||
pub stats_json: String,
|
||||
}
|
||||
|
||||
const PLAYER_STATE_COLUMN_SQL: &str = "
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'dune'
|
||||
AND table_name = 'player_state'
|
||||
AND column_name = ANY($1)
|
||||
";
|
||||
|
||||
const WELCOME_ACCOUNTS_SQL: &str = "
|
||||
SELECT
|
||||
acct.id::int8 AS account_id,
|
||||
COALESCE(acct.\"user\"::text, '') AS fls_id
|
||||
FROM dune.encrypted_accounts acct
|
||||
WHERE COALESCE(acct.\"user\"::text, '') <> ''
|
||||
ORDER BY acct.id ASC
|
||||
";
|
||||
|
||||
const PLAYER_BACKPACK_INVENTORY_SQL: &str = "
|
||||
SELECT
|
||||
inv.id::int8 AS inventory_id,
|
||||
NULLIF(ps.character_name, '') AS character_name
|
||||
FROM dune.player_state ps
|
||||
JOIN dune.actors pawn ON pawn.id = ps.player_pawn_id
|
||||
JOIN dune.inventories inv ON inv.actor_id = ps.player_pawn_id
|
||||
AND inv.inventory_type = 0
|
||||
WHERE ps.account_id = $1::int8
|
||||
AND pawn.class = '/Game/Dune/Characters/Player/BP_DunePlayerCharacter.BP_DunePlayerCharacter_C'
|
||||
ORDER BY ps.last_login_time DESC NULLS LAST, inv.id DESC
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
const PLAYER_BACKPACK_FREE_SLOTS_SQL: &str = "
|
||||
SELECT gs::int8 AS position_index
|
||||
FROM generate_series(0, 10000) AS gs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM dune.items i
|
||||
WHERE i.inventory_id = $1::int8
|
||||
AND i.position_index = gs
|
||||
)
|
||||
ORDER BY gs
|
||||
LIMIT $2
|
||||
";
|
||||
|
||||
const PLAYER_BACKPACK_INSERT_ITEM_SQL: &str = "
|
||||
INSERT INTO dune.items (
|
||||
inventory_id,
|
||||
stack_size,
|
||||
position_index,
|
||||
template_id,
|
||||
is_new,
|
||||
acquisition_time,
|
||||
stats,
|
||||
quality_level
|
||||
)
|
||||
VALUES (
|
||||
$1::int8,
|
||||
$2::int8,
|
||||
$3::int8,
|
||||
$4::text,
|
||||
TRUE,
|
||||
EXTRACT(EPOCH FROM now())::int8,
|
||||
$5::text::jsonb,
|
||||
0
|
||||
)
|
||||
RETURNING id::int8
|
||||
";
|
||||
|
||||
const CHAT_PLAYER_SQL: &str = "
|
||||
SELECT
|
||||
acct.id::int8 AS account_id,
|
||||
COALESCE(acct.\"user\"::text, '') AS fls_id,
|
||||
COALESCE(acct.funcom_id::text, '') AS funcom_id,
|
||||
COALESCE(ps.character_name, '') AS character_name
|
||||
FROM dune.player_state ps
|
||||
JOIN dune.encrypted_accounts enc ON enc.id = ps.account_id
|
||||
LEFT JOIN dune.accounts acct ON acct.id = ps.account_id
|
||||
WHERE lower(COALESCE(enc.\"user\"::text, '')) = lower($1)
|
||||
OR lower(COALESCE(acct.funcom_id::text, '')) = lower($1)
|
||||
OR lower(COALESCE(ps.character_name, '')) = lower($1)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(COALESCE(enc.\"user\"::text, '')) = lower($1) THEN 0
|
||||
WHEN lower(COALESCE(acct.funcom_id::text, '')) = lower($1) THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
ps.last_login_time DESC NULLS LAST
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
const LEVEL_COLUMN_CANDIDATES: &[&str] = &[
|
||||
"level",
|
||||
"character_level",
|
||||
"player_level",
|
||||
"experience_level",
|
||||
"current_level",
|
||||
"total_level",
|
||||
];
|
||||
|
||||
fn players_sql(level_expr: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
WITH matches AS (
|
||||
SELECT DISTINCT
|
||||
COALESCE(enc."user"::text, '') AS fls_id,
|
||||
COALESCE(ps.character_name, '') AS character_name,
|
||||
COALESCE(ps.online_status::text, '') AS online_status,
|
||||
COALESCE(
|
||||
to_char(ps.last_avatar_activity AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'),
|
||||
''
|
||||
) AS last_seen,
|
||||
{level_expr} AS player_level,
|
||||
a.partition_id
|
||||
FROM dune.player_state ps
|
||||
LEFT JOIN dune.accounts acct ON acct.id = ps.account_id
|
||||
LEFT JOIN dune.encrypted_accounts enc ON enc.id = ps.account_id
|
||||
LEFT JOIN dune.actors a ON a.id = ps.player_pawn_id
|
||||
WHERE lower(ps.character_name) LIKE lower($1)
|
||||
OR lower(COALESCE(enc."user"::text, '')) LIKE lower($1)
|
||||
OR lower(COALESCE(acct.funcom_id::text, '')) LIKE lower($1)
|
||||
)
|
||||
SELECT fls_id, character_name, online_status, last_seen, player_level, partition_id
|
||||
FROM matches
|
||||
WHERE fls_id <> ''
|
||||
ORDER BY
|
||||
CASE WHEN lower(online_status) = 'online' THEN 0 ELSE 1 END,
|
||||
last_seen DESC,
|
||||
character_name ASC
|
||||
LIMIT $2;
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Outcome of a player-position probe.
|
||||
pub enum PositionProbe {
|
||||
Found(PlayerLocation),
|
||||
/// No row matched — usually means the player is offline (no live pawn),
|
||||
/// or the fls_id doesn't exist on this server.
|
||||
NoRow,
|
||||
}
|
||||
|
||||
/// Look up the live world position for a player. Joins `player_state` →
|
||||
/// `actors` on `player_pawn_id` and deconstructs the composite
|
||||
/// `actors.transform` (`(location:(x,y,z), rotation:(x,y,z,w))`).
|
||||
pub async fn get_player_location(
|
||||
pg: &PgClient,
|
||||
namespace: &str,
|
||||
fls_id: &str,
|
||||
) -> Result<PositionProbe> {
|
||||
let state = pg.client(namespace).await?;
|
||||
let rows = state
|
||||
.client()
|
||||
.query(PLAYER_POSITION_SQL, &[&fls_id])
|
||||
.await
|
||||
.context("querying player pawn position")?;
|
||||
let Some(row) = rows.into_iter().next() else {
|
||||
return Ok(PositionProbe::NoRow);
|
||||
};
|
||||
Ok(PositionProbe::Found(PlayerLocation {
|
||||
x: row.get::<_, f64>(0),
|
||||
y: row.get::<_, f64>(1),
|
||||
z: row.get::<_, f64>(2),
|
||||
dimension_index: row.try_get::<_, i32>(3).ok(),
|
||||
partition_id: row.try_get::<_, i64>(4).ok(),
|
||||
source: row.try_get::<_, String>(5).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_players(
|
||||
pg: &PgClient,
|
||||
namespace: &str,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<Player>> {
|
||||
let safe_limit = limit.clamp(1, 200) as i64;
|
||||
let pattern = format!("%{}%", query);
|
||||
|
||||
let state = pg.client(namespace).await?;
|
||||
let level_column = player_level_column(state.client()).await?;
|
||||
let level_expr = level_column
|
||||
.as_deref()
|
||||
.map(|column| format!("ps.\"{column}\"::int"))
|
||||
.unwrap_or_else(|| "NULL::int".to_string());
|
||||
let sql = players_sql(&level_expr);
|
||||
let rows = state
|
||||
.client()
|
||||
.query(&sql, &[&pattern, &safe_limit])
|
||||
.await
|
||||
.context("running player search query")?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
out.push(Player {
|
||||
fls_id: row.try_get::<_, String>(0).unwrap_or_default(),
|
||||
name: row.try_get::<_, String>(1).unwrap_or_default(),
|
||||
online: row.try_get::<_, String>(2).unwrap_or_default(),
|
||||
last_seen: row.try_get::<_, String>(3).unwrap_or_default(),
|
||||
level: row.try_get::<_, Option<i32>>(4).ok().flatten(),
|
||||
partition_id: row.try_get::<_, Option<i64>>(5).ok().flatten(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn list_welcome_accounts(pg: &PgClient, namespace: &str) -> Result<Vec<WelcomeAccount>> {
|
||||
let state = pg.client(namespace).await?;
|
||||
let rows = state
|
||||
.client()
|
||||
.query(WELCOME_ACCOUNTS_SQL, &[])
|
||||
.await
|
||||
.context("querying welcome package accounts")?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
out.push(WelcomeAccount {
|
||||
account_id: row.try_get::<_, i64>(0).unwrap_or_default(),
|
||||
fls_id: row.try_get::<_, String>(1).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn resolve_account_backpack(
|
||||
pg: &PgClient,
|
||||
namespace: &str,
|
||||
account_id: i64,
|
||||
) -> Result<Option<AccountBackpack>> {
|
||||
let state = pg.client(namespace).await?;
|
||||
let row = state
|
||||
.client()
|
||||
.query_opt(PLAYER_BACKPACK_INVENTORY_SQL, &[&account_id])
|
||||
.await
|
||||
.context("resolving account backpack inventory")?;
|
||||
Ok(row.map(|row| AccountBackpack {
|
||||
inventory_id: row.try_get::<_, i64>(0).unwrap_or_default(),
|
||||
character_name: row.try_get::<_, Option<String>>(1).ok().flatten(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn insert_items_to_backpack(
|
||||
pg: &PgClient,
|
||||
namespace: &str,
|
||||
inventory_id: i64,
|
||||
items: &[BackpackGrantItem],
|
||||
) -> Result<Vec<i64>> {
|
||||
let mut state = pg.dedicated_client(namespace).await?;
|
||||
let tx = state
|
||||
.client_mut()
|
||||
.transaction()
|
||||
.await
|
||||
.context("starting welcome item grant transaction")?;
|
||||
|
||||
let slot_limit = items.len() as i64;
|
||||
let slot_rows = tx
|
||||
.query(
|
||||
PLAYER_BACKPACK_FREE_SLOTS_SQL,
|
||||
&[&inventory_id, &slot_limit],
|
||||
)
|
||||
.await
|
||||
.context("finding free backpack slots")?;
|
||||
if slot_rows.len() != items.len() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"not enough free backpack slots for welcome package: needed {}, found {}",
|
||||
items.len(),
|
||||
slot_rows.len()
|
||||
));
|
||||
}
|
||||
|
||||
let insert = tx
|
||||
.prepare(PLAYER_BACKPACK_INSERT_ITEM_SQL)
|
||||
.await
|
||||
.context("preparing welcome item insert")?;
|
||||
let mut inserted_ids = Vec::with_capacity(items.len());
|
||||
for (item, slot) in items.iter().zip(slot_rows.iter()) {
|
||||
let position_index = slot.try_get::<_, i64>(0).unwrap_or_default();
|
||||
let row = tx
|
||||
.query_one(
|
||||
&insert,
|
||||
&[
|
||||
&inventory_id,
|
||||
&item.quantity,
|
||||
&position_index,
|
||||
&item.template_id,
|
||||
&item.stats_json,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("inserting welcome item {}", item.template_id))?;
|
||||
inserted_ids.push(row.try_get::<_, i64>(0).unwrap_or_default());
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.context("committing welcome item grant transaction")?;
|
||||
Ok(inserted_ids)
|
||||
}
|
||||
|
||||
pub async fn resolve_chat_player(
|
||||
pg: &PgClient,
|
||||
namespace: &str,
|
||||
lookup: &str,
|
||||
) -> Result<Option<ChatPlayer>> {
|
||||
let state = pg.client(namespace).await?;
|
||||
let rows = state
|
||||
.client()
|
||||
.query(CHAT_PLAYER_SQL, &[&lookup.trim()])
|
||||
.await
|
||||
.with_context(|| format!("resolving chat player {lookup}"))?;
|
||||
let Some(row) = rows.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ChatPlayer {
|
||||
account_id: row.try_get::<_, i64>(0).unwrap_or_default(),
|
||||
fls_id: row.try_get::<_, String>(1).unwrap_or_default(),
|
||||
funcom_id: row.try_get::<_, String>(2).unwrap_or_default(),
|
||||
character_name: row.try_get::<_, String>(3).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn player_level_column(client: &tokio_postgres::Client) -> Result<Option<String>> {
|
||||
let rows = client
|
||||
.query(PLAYER_STATE_COLUMN_SQL, &[&LEVEL_COLUMN_CANDIDATES])
|
||||
.await
|
||||
.context("checking player level column")?;
|
||||
let available = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<_, String>(0).ok())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
Ok(LEVEL_COLUMN_CANDIDATES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| available.contains(*candidate))
|
||||
.map(str::to_string))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono_tz::Tz;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub mod runner;
|
||||
pub mod schedule;
|
||||
pub mod task;
|
||||
pub mod timezone;
|
||||
|
||||
pub use runner::TaskRunner;
|
||||
pub use schedule::Schedule;
|
||||
pub use task::{Task, TaskCtx, TaskOutcome};
|
||||
|
||||
use crate::store::TaskTrigger;
|
||||
|
||||
/// Owns the per-task tick loops. Each task gets one tokio task that sleeps
|
||||
/// until its next scheduled fire and dispatches into [`TaskRunner::run`].
|
||||
pub struct Scheduler {
|
||||
runner: Arc<TaskRunner>,
|
||||
tz: Tz,
|
||||
tasks: Vec<Arc<dyn Task>>,
|
||||
handles: Vec<JoinHandle<()>>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub fn new(runner: Arc<TaskRunner>, tz: Tz) -> Self {
|
||||
Self {
|
||||
runner,
|
||||
tz,
|
||||
tasks: Vec::new(),
|
||||
handles: Vec::new(),
|
||||
cancel: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, task: Arc<dyn Task>) {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub fn cancel_token(&self) -> CancellationToken {
|
||||
self.cancel.clone()
|
||||
}
|
||||
|
||||
/// Spawn one loop per task. Returns once spawn is complete; the loops run
|
||||
/// in the background until [`Scheduler::shutdown`] is called.
|
||||
pub fn start(&mut self) {
|
||||
for task in self.tasks.clone() {
|
||||
let runner = self.runner.clone();
|
||||
let tz = self.tz;
|
||||
let cancel = self.cancel.clone();
|
||||
|
||||
tracing::info!(
|
||||
task = task.id(),
|
||||
schedule = %task.schedule().describe(tz),
|
||||
"scheduling task"
|
||||
);
|
||||
|
||||
if task.schedule().is_disabled() {
|
||||
// No tick loop. Manual triggers still resolve via build_all().
|
||||
continue;
|
||||
}
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let next = task.schedule().next_fire(tz, Utc::now());
|
||||
let wait = (next - Utc::now())
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::ZERO);
|
||||
tracing::debug!(task = task.id(), next = %next, wait_ms = wait.as_millis() as u64, "next fire");
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
tracing::info!(task = task.id(), "scheduler loop cancelled");
|
||||
return;
|
||||
}
|
||||
_ = tokio::time::sleep(wait) => {}
|
||||
}
|
||||
|
||||
if let Err(err) = runner
|
||||
.run(task.clone(), TaskTrigger::Scheduled, false, None)
|
||||
.await
|
||||
{
|
||||
tracing::error!(task = task.id(), error = %err, "scheduled run failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.handles.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.cancel.cancel();
|
||||
for handle in self.handles.drain(..) {
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::store::{NewLogEntry, Store, TaskRunStatus, TaskTrigger};
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
use super::task::{Task, TaskCtx, TaskOutcome};
|
||||
|
||||
/// Coordinates task execution: assigns run IDs, persists status transitions,
|
||||
/// and enforces the single-instance-per-task overlap guard.
|
||||
pub struct TaskRunner {
|
||||
store: Store,
|
||||
env: Arc<TaskEnv>,
|
||||
running: Mutex<HashSet<&'static str>>,
|
||||
}
|
||||
|
||||
impl TaskRunner {
|
||||
pub fn new(store: Store, env: Arc<TaskEnv>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
env,
|
||||
running: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&self) -> &Store {
|
||||
&self.store
|
||||
}
|
||||
|
||||
pub fn env(&self) -> &Arc<TaskEnv> {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// Execute `task`, recording start/finish/error in the store and routing
|
||||
/// `Noop` outcomes to a row delete so the history stays clean.
|
||||
pub async fn run(
|
||||
self: &Arc<Self>,
|
||||
task: Arc<dyn Task>,
|
||||
trigger: TaskTrigger,
|
||||
dry_run: bool,
|
||||
options: Option<Value>,
|
||||
) -> Result<TaskOutcome> {
|
||||
let id = task.id();
|
||||
|
||||
let already_running = {
|
||||
let mut guard = self.running.lock().await;
|
||||
if guard.contains(id) {
|
||||
true
|
||||
} else {
|
||||
guard.insert(id);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if already_running {
|
||||
let run_id = self.store.start_run(id, trigger, dry_run)?;
|
||||
self.store.log(&NewLogEntry {
|
||||
level: crate::store::LogLevel::Warn,
|
||||
message: &format!("{id} is still running; skipping overlapping run."),
|
||||
task_id: Some(id),
|
||||
run_id: Some(run_id),
|
||||
})?;
|
||||
self.store
|
||||
.finish_run(run_id, TaskRunStatus::Skipped, Some("overlap"))?;
|
||||
tracing::warn!(task = id, "overlap; skipping");
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
let run_id = self.store.start_run(id, trigger, dry_run)?;
|
||||
let ctx = TaskCtx {
|
||||
run_id,
|
||||
dry_run,
|
||||
trigger,
|
||||
store: self.store.clone(),
|
||||
env: self.env.clone(),
|
||||
options,
|
||||
};
|
||||
ctx.log_info(&format!("Starting task {id}."))?;
|
||||
|
||||
let result = task.run(&ctx).await;
|
||||
|
||||
{
|
||||
let mut guard = self.running.lock().await;
|
||||
guard.remove(id);
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(TaskOutcome::Done) => {
|
||||
ctx.log_info(&format!("Finished task {id}."))?;
|
||||
self.store
|
||||
.finish_run(run_id, TaskRunStatus::Success, None)?;
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
Ok(TaskOutcome::Noop) => {
|
||||
if trigger == TaskTrigger::Manual {
|
||||
// A manual trigger is an explicit operator action, so it
|
||||
// always leaves a visible record — otherwise the run flashes
|
||||
// up and vanishes, looking broken (#9). Scheduled/startup
|
||||
// Noops are still deleted to keep recent-runs uncluttered.
|
||||
ctx.log_info(&format!("{id} had nothing to do."))?;
|
||||
self.store
|
||||
.finish_run(run_id, TaskRunStatus::Skipped, None)?;
|
||||
} else {
|
||||
self.store.delete_run(run_id)?;
|
||||
}
|
||||
Ok(TaskOutcome::Noop)
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
ctx.log_error(&format!("{id} failed: {msg}"))?;
|
||||
self.store
|
||||
.finish_run(run_id, TaskRunStatus::Failed, Some(&msg))?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use cron::Schedule as CronSchedule;
|
||||
|
||||
use super::timezone;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Schedule {
|
||||
/// Fire every `every` from the moment the scheduler starts.
|
||||
Interval { every: Duration },
|
||||
/// Fire daily at `hour:minute` in the configured IANA timezone.
|
||||
Daily { hour: u32, minute: u32 },
|
||||
/// Fire on the cadence described by a (5-, 6-, or 7-field) cron
|
||||
/// expression, evaluated in the operator's TZ.
|
||||
Cron(Box<CronSchedule>),
|
||||
/// Never fire automatically. Manual triggers still work.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Parses a user-supplied cron expression. Only the standard 5-field form
|
||||
/// (`min hour dom mon dow`) is accepted; the underlying parser wants 6 fields
|
||||
/// (seconds first) so we prepend `0` seconds before handing it off. Anything
|
||||
/// other than exactly 5 fields is rejected with a clear error.
|
||||
pub fn parse_cron(expr: &str) -> Result<CronSchedule> {
|
||||
let trimmed = expr.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("empty cron expression"));
|
||||
}
|
||||
let field_count = trimmed.split_whitespace().count();
|
||||
if field_count != 5 {
|
||||
return Err(anyhow!(
|
||||
"cron must have exactly 5 fields (min hour dom mon dow); got {field_count}"
|
||||
));
|
||||
}
|
||||
let normalized = format!("0 {trimmed}");
|
||||
CronSchedule::from_str(&normalized).map_err(|err| anyhow!("invalid cron expression: {err}"))
|
||||
}
|
||||
|
||||
impl Schedule {
|
||||
pub fn interval_secs(secs: u64) -> Self {
|
||||
if secs == 0 {
|
||||
Self::Disabled
|
||||
} else {
|
||||
Self::Interval {
|
||||
every: Duration::from_secs(secs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn daily(hour: u32, minute: u32) -> Self {
|
||||
Self::Daily { hour, minute }
|
||||
}
|
||||
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
matches!(self, Self::Disabled)
|
||||
}
|
||||
|
||||
pub fn next_fire(&self, tz: Tz, now: DateTime<Utc>) -> DateTime<Utc> {
|
||||
match self {
|
||||
Self::Interval { every } => {
|
||||
now + chrono::Duration::from_std(*every).expect("interval fits in chrono::Duration")
|
||||
}
|
||||
Self::Daily { hour, minute } => timezone::next_daily_at(tz, *hour, *minute, now),
|
||||
Self::Cron(schedule) => {
|
||||
let now_tz = now.with_timezone(&tz);
|
||||
schedule
|
||||
.after(&now_tz)
|
||||
.next()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|| now + chrono::Duration::days(365 * 100))
|
||||
}
|
||||
// Sentinel "very far future" so the loop sleeps until cancellation
|
||||
// even if a caller forgets to check `is_disabled`.
|
||||
Self::Disabled => now + chrono::Duration::days(365 * 100),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe(&self, tz: Tz) -> String {
|
||||
match self {
|
||||
Self::Interval { every } => format!("every {}s", every.as_secs()),
|
||||
Self::Daily { hour, minute } => {
|
||||
format!("daily {:02}:{:02} {}", hour, minute, tz.name())
|
||||
}
|
||||
Self::Cron(schedule) => format!("cron `{schedule}` {}", tz.name()),
|
||||
Self::Disabled => "disabled (manual-only)".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_cron_accepts_5_fields() {
|
||||
let s = parse_cron("0 4 * * *").expect("parse");
|
||||
// Sanity: should produce a valid CronSchedule
|
||||
let next: Vec<_> = s.upcoming(chrono::Utc).take(2).collect();
|
||||
assert_eq!(next.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_rejects_bad_field_count() {
|
||||
assert!(parse_cron("1 2 3 4").is_err());
|
||||
assert!(parse_cron("").is_err());
|
||||
assert!(parse_cron("not cron at all").is_err());
|
||||
// 6-field with seconds is also rejected; we keep the surface 5-only.
|
||||
assert!(parse_cron("0 0 4 * * *").is_err());
|
||||
// 7-field with year is also rejected.
|
||||
assert!(parse_cron("0 0 4 * * * 2026").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cron_rejects_invalid_field() {
|
||||
assert!(parse_cron("99 4 * * *").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::store::{LogLevel, NewLogEntry, Store, TaskTrigger};
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
use super::schedule::Schedule;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TaskOutcome {
|
||||
/// Task ran successfully and the run row should be retained.
|
||||
Done,
|
||||
/// Task decided there was nothing to do; the run row will be deleted so
|
||||
/// history is not polluted. Mirrors the exit-code-75 semantics of the
|
||||
/// original shell scripts.
|
||||
Noop,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Task: Send + Sync + 'static {
|
||||
fn id(&self) -> &'static str;
|
||||
fn schedule(&self) -> Schedule;
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome>;
|
||||
}
|
||||
|
||||
/// Context handed to each task invocation. Holds run-scoped state (run_id,
|
||||
/// dry_run, trigger), a handle to the shared store, and the `TaskEnv` bundle
|
||||
/// of external resources (kubectl, postgres, mq publisher, etc.).
|
||||
#[derive(Clone)]
|
||||
pub struct TaskCtx {
|
||||
pub run_id: i64,
|
||||
pub dry_run: bool,
|
||||
pub trigger: TaskTrigger,
|
||||
pub store: Store,
|
||||
pub env: Arc<TaskEnv>,
|
||||
/// Per-trigger overrides, populated from `POST /api/runs/trigger`'s
|
||||
/// optional `options` body. Tasks may inspect this to override their
|
||||
/// schedule-defaults; scheduled fires get `None`.
|
||||
pub options: Option<Value>,
|
||||
}
|
||||
|
||||
impl TaskCtx {
|
||||
pub fn log_info(&self, message: &str) -> Result<()> {
|
||||
tracing::info!(task_run = self.run_id, "{}", message);
|
||||
self.store.log(&NewLogEntry {
|
||||
level: LogLevel::Info,
|
||||
message,
|
||||
task_id: None,
|
||||
run_id: Some(self.run_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log_warn(&self, message: &str) -> Result<()> {
|
||||
tracing::warn!(task_run = self.run_id, "{}", message);
|
||||
self.store.log(&NewLogEntry {
|
||||
level: LogLevel::Warn,
|
||||
message,
|
||||
task_id: None,
|
||||
run_id: Some(self.run_id),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log_error(&self, message: &str) -> Result<()> {
|
||||
tracing::error!(task_run = self.run_id, "{}", message);
|
||||
self.store.log(&NewLogEntry {
|
||||
level: LogLevel::Error,
|
||||
message,
|
||||
task_id: None,
|
||||
run_id: Some(self.run_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use chrono::{DateTime, Duration, LocalResult, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
/// Compute the next UTC instant at which the local wall-clock `hour:minute`
|
||||
/// occurs in `tz`, strictly after `now`.
|
||||
///
|
||||
/// Handles DST transitions:
|
||||
/// - **Spring-forward gap** (the wall time skipped, e.g. 02:30 on transition
|
||||
/// day in Europe): advance to the next non-gap day.
|
||||
/// - **Fall-back overlap** (the wall time happens twice): use the latest of
|
||||
/// the two so the schedule fires once per day, not twice.
|
||||
pub fn next_daily_at(tz: Tz, hour: u32, minute: u32, now: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let local_now = now.with_timezone(&tz);
|
||||
let mut candidate_date = local_now.date_naive();
|
||||
|
||||
for _ in 0..14 {
|
||||
if let Some(target) = build_target(tz, candidate_date, hour, minute) {
|
||||
if target > now {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
candidate_date = candidate_date
|
||||
.succ_opt()
|
||||
.expect("date arithmetic always succeeds");
|
||||
}
|
||||
|
||||
// Defensive: if 14 days of DST gaps somehow occurred (impossible in
|
||||
// practice), fall back to now + 24h.
|
||||
now + Duration::hours(24)
|
||||
}
|
||||
|
||||
fn build_target(tz: Tz, date: NaiveDate, hour: u32, minute: u32) -> Option<DateTime<Utc>> {
|
||||
let naive = date.and_hms_opt(hour, minute, 0)?;
|
||||
match tz.from_local_datetime(&naive) {
|
||||
LocalResult::Single(dt) => Some(dt.with_timezone(&Utc)),
|
||||
LocalResult::Ambiguous(_earliest, latest) => Some(latest.with_timezone(&Utc)),
|
||||
LocalResult::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
fn ams() -> Tz {
|
||||
chrono_tz::Europe::Amsterdam
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_today_when_target_still_ahead() {
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 1, 1, 0, 0).unwrap();
|
||||
let next = next_daily_at(ams(), 5, 0, now);
|
||||
let local = next.with_timezone(&ams());
|
||||
assert_eq!(
|
||||
local.date_naive(),
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()
|
||||
);
|
||||
assert_eq!((local.time().hour(), local.time().minute()), (5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_to_next_day_when_target_already_passed() {
|
||||
// 09:00 UTC = 11:00 in Amsterdam (CEST). 05:00 local is already in the past.
|
||||
let now = Utc.with_ymd_and_hms(2026, 6, 1, 9, 0, 0).unwrap();
|
||||
let next = next_daily_at(ams(), 5, 0, now);
|
||||
let local = next.with_timezone(&ams());
|
||||
assert_eq!(
|
||||
local.date_naive(),
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 6, 2).unwrap()
|
||||
);
|
||||
assert_eq!((local.time().hour(), local.time().minute()), (5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_over_dst_spring_forward_gap() {
|
||||
// 2026-03-29 02:00..03:00 doesn't exist in Amsterdam (clocks jump to 03:00).
|
||||
let before_gap = Utc.with_ymd_and_hms(2026, 3, 29, 0, 50, 0).unwrap();
|
||||
let next = next_daily_at(ams(), 2, 30, before_gap);
|
||||
// Either the next-day 02:30, or another defined point — must not panic, must be after.
|
||||
assert!(next > before_gap);
|
||||
let local = next.with_timezone(&ams());
|
||||
assert_eq!((local.time().hour(), local.time().minute()), (2, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_latest_for_dst_fall_back_overlap() {
|
||||
// 2026-10-25 02:00..03:00 happens twice in Amsterdam. Daily 02:30 must be picked once.
|
||||
let before = Utc.with_ymd_and_hms(2026, 10, 25, 0, 0, 0).unwrap();
|
||||
let next = next_daily_at(ams(), 2, 30, before);
|
||||
assert!(next > before);
|
||||
// Sanity: when interpreted back to local, hour+minute match.
|
||||
let local = next.with_timezone(&ams());
|
||||
assert_eq!((local.time().hour(), local.time().minute()), (2, 30));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Store;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AdminHistoryEntry {
|
||||
pub id: i64,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
pub command: String,
|
||||
pub payload: Value,
|
||||
pub ok: bool,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct AdminHistoryFilter {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn record_admin_command(
|
||||
&self,
|
||||
command: &str,
|
||||
payload: &Value,
|
||||
ok: bool,
|
||||
message: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let payload_json = serde_json::to_string(payload)?;
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO admin_commands (created_at, command, payload_json, ok, message)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![now, command, payload_json, ok as i32, message],
|
||||
)?;
|
||||
Ok(c.last_insert_rowid())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_admin_commands(
|
||||
&self,
|
||||
filter: AdminHistoryFilter,
|
||||
) -> Result<Vec<AdminHistoryEntry>> {
|
||||
let limit = filter.limit.unwrap_or(50).clamp(1, 500) as i64;
|
||||
self.with_conn(|c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id, created_at, command, payload_json, ok, message
|
||||
FROM admin_commands ORDER BY created_at DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
let payload_text: String = row.get(3)?;
|
||||
let payload = serde_json::from_str(&payload_text).unwrap_or(Value::Null);
|
||||
let ok_raw: i64 = row.get(4)?;
|
||||
Ok(AdminHistoryEntry {
|
||||
id: row.get(0)?,
|
||||
created_at: row.get(1)?,
|
||||
command: row.get(2)?,
|
||||
payload,
|
||||
ok: ok_raw != 0,
|
||||
message: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tests::tempdir;
|
||||
|
||||
#[test]
|
||||
fn admin_history_roundtrip() {
|
||||
let s = Store::open(&tempdir().join("s.sqlite")).unwrap();
|
||||
let payload = serde_json::json!({"ServerCommand": "ServiceBroadcast", "Title": "hi"});
|
||||
let id = s
|
||||
.record_admin_command("ServiceBroadcast", &payload, true, None)
|
||||
.unwrap();
|
||||
assert!(id > 0);
|
||||
let list = s
|
||||
.list_admin_commands(AdminHistoryFilter::default())
|
||||
.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].command, "ServiceBroadcast");
|
||||
assert!(list[0].ok);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
|
||||
pub mod admin_history;
|
||||
pub mod pending;
|
||||
pub mod runs;
|
||||
pub mod welcome;
|
||||
|
||||
pub use admin_history::{AdminHistoryEntry, AdminHistoryFilter};
|
||||
pub use pending::PendingUpdateRecord;
|
||||
pub use runs::{LogEntry, LogLevel, NewLogEntry, TaskRun, TaskRunStatus, TaskTrigger};
|
||||
pub use welcome::{
|
||||
WelcomeActionRecord, WelcomeActionStatus, WelcomeGrantRecord, WelcomeGrantStatus,
|
||||
};
|
||||
|
||||
/// Shared store handle. Wraps a single SQLite connection behind a mutex so the
|
||||
/// async layer can call into it from `spawn_blocking` closures.
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
inner: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating db parent dir {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("opening sqlite at {}", path.display()))?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
migrate_schema(&conn)?;
|
||||
let orphaned_update_apply = count_running_update_apply(&conn)?;
|
||||
let orphaned = mark_orphaned_runs(&conn)?;
|
||||
if orphaned > 0 {
|
||||
tracing::warn!(orphaned, "marked orphaned running task_runs as failed");
|
||||
}
|
||||
if orphaned_update_apply > 0 {
|
||||
defer_pending_update_after_orphan(&conn, 5 * 60)?;
|
||||
tracing::warn!(
|
||||
orphaned = orphaned_update_apply,
|
||||
"deferred pending update after orphaned update-apply run"
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn with_conn<R, F>(&self, f: F) -> Result<R>
|
||||
where
|
||||
F: FnOnce(&Connection) -> rusqlite::Result<R>,
|
||||
{
|
||||
let guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("store mutex poisoned"))?;
|
||||
f(&guard).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Read a config value from the `task_config` KV table.
|
||||
pub fn get_config(&self, key: &str) -> Result<Option<String>> {
|
||||
self.with_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT value FROM task_config WHERE key = ?1",
|
||||
rusqlite::params![key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
/// Upsert a config value.
|
||||
pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO task_config(key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
rusqlite::params![key, value],
|
||||
)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience for reading a value parseable as an integer.
|
||||
pub fn get_config_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
Ok(self
|
||||
.get_config(key)?
|
||||
.and_then(|raw| raw.parse::<i64>().ok()))
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"welcome_grants",
|
||||
"first_online_at",
|
||||
"ALTER TABLE welcome_grants ADD COLUMN first_online_at TEXT",
|
||||
)?;
|
||||
migrate_welcome_ledger_account_id_key(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_welcome_ledger_account_id_key(conn: &Connection) -> rusqlite::Result<()> {
|
||||
let grants_pk = primary_key_columns(conn, "welcome_grants")?;
|
||||
let actions_pk = primary_key_columns(conn, "welcome_grant_actions")?;
|
||||
if grants_pk == ["player_id", "package_version", "account_id"]
|
||||
&& actions_pk == ["player_id", "package_version", "account_id", "action_index"]
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA foreign_keys=OFF;
|
||||
ALTER TABLE welcome_grant_actions RENAME TO welcome_grant_actions_old;
|
||||
ALTER TABLE welcome_grants RENAME TO welcome_grants_old;
|
||||
|
||||
CREATE TABLE welcome_grants (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
character_name TEXT,
|
||||
status TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
granted_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_online_status TEXT,
|
||||
first_online_at TEXT,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version, account_id)
|
||||
);
|
||||
|
||||
CREATE TABLE welcome_grant_actions (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
action_index INTEGER NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
published_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
item_name TEXT,
|
||||
baseline_quantity INTEGER,
|
||||
expected_quantity INTEGER,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version, account_id, action_index),
|
||||
FOREIGN KEY (player_id, package_version, account_id)
|
||||
REFERENCES welcome_grants(player_id, package_version, account_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Drop legacy 'pending' rows. Older builds wrote a 'pending' grant for
|
||||
-- every scanned player whether or not the package was ever delivered; the
|
||||
-- new worker only writes 'granted'/'failed' and skips any account with an
|
||||
-- existing row, so carrying pending rows over would permanently block those
|
||||
-- players with no UI recovery. Dropping them lets the new worker re-evaluate.
|
||||
INSERT OR IGNORE INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_online_status,
|
||||
first_online_at, last_error
|
||||
)
|
||||
SELECT
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_online_status,
|
||||
first_online_at, last_error
|
||||
FROM welcome_grants_old
|
||||
WHERE status <> 'pending';
|
||||
|
||||
-- Only carry over actions whose parent grant survived, so no FK orphans.
|
||||
INSERT OR IGNORE INTO welcome_grant_actions (
|
||||
player_id, package_version, account_id, action_index, action_type, status,
|
||||
created_at, updated_at, published_at, confirmed_at, attempts,
|
||||
item_name, baseline_quantity, expected_quantity, last_error
|
||||
)
|
||||
SELECT
|
||||
a.player_id, a.package_version, g.account_id,
|
||||
a.action_index, a.action_type, a.status, a.created_at, a.updated_at,
|
||||
a.published_at, a.confirmed_at, a.attempts,
|
||||
a.item_name, a.baseline_quantity, a.expected_quantity, a.last_error
|
||||
FROM welcome_grant_actions_old a
|
||||
JOIN welcome_grants_old g
|
||||
ON g.player_id = a.player_id
|
||||
AND g.package_version = a.package_version
|
||||
WHERE g.status <> 'pending';
|
||||
|
||||
DROP TABLE welcome_grant_actions_old;
|
||||
DROP TABLE welcome_grants_old;
|
||||
CREATE INDEX IF NOT EXISTS idx_welcome_grants_status ON welcome_grants(status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_welcome_grant_actions_status ON welcome_grant_actions(status, updated_at DESC);
|
||||
PRAGMA foreign_keys=ON;
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
fn primary_key_columns(conn: &Connection, table: &str) -> rusqlite::Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?))
|
||||
})?;
|
||||
let mut cols = Vec::new();
|
||||
for row in rows {
|
||||
let (name, pk) = row?;
|
||||
if pk > 0 {
|
||||
cols.push((pk, name));
|
||||
}
|
||||
}
|
||||
cols.sort_by_key(|(pk, _)| *pk);
|
||||
Ok(cols.into_iter().map(|(_, name)| name).collect())
|
||||
}
|
||||
|
||||
fn add_column_if_missing(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
sql: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let exists = {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
||||
let mut exists = false;
|
||||
for row in rows {
|
||||
if row? == column {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
exists
|
||||
};
|
||||
if !exists {
|
||||
match conn.execute(sql, []) {
|
||||
Ok(_) => {}
|
||||
Err(err) if err.to_string().contains("duplicate column name") => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_running_update_apply(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM task_runs WHERE status = 'running' AND task_id = 'update-apply'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count as usize)
|
||||
}
|
||||
|
||||
fn mark_orphaned_runs(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
conn.execute(
|
||||
"UPDATE task_runs
|
||||
SET status = 'failed',
|
||||
finished_at = ?1,
|
||||
error = COALESCE(error, 'orphaned by daemon restart')
|
||||
WHERE status = 'running'",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
}
|
||||
|
||||
fn defer_pending_update_after_orphan(
|
||||
conn: &Connection,
|
||||
delay_secs: i64,
|
||||
) -> rusqlite::Result<usize> {
|
||||
conn.execute(
|
||||
"UPDATE pending_update SET due_ts = ?1 WHERE id = 1",
|
||||
rusqlite::params![chrono::Utc::now().timestamp() + delay_secs],
|
||||
)
|
||||
}
|
||||
|
||||
const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
dry_run INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS log_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
run_id INTEGER,
|
||||
FOREIGN KEY (run_id) REFERENCES task_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
message TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_update (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
battlegroup TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
latest_steam_build TEXT,
|
||||
local_steam_build TEXT,
|
||||
live_version TEXT,
|
||||
downloaded_version TEXT NOT NULL,
|
||||
due_ts INTEGER NOT NULL,
|
||||
created_ts INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS welcome_grants (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
character_name TEXT,
|
||||
status TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
granted_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_online_status TEXT,
|
||||
first_online_at TEXT,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version, account_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS welcome_grant_actions (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
action_index INTEGER NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
published_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
item_name TEXT,
|
||||
baseline_quantity INTEGER,
|
||||
expected_quantity INTEGER,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version, account_id, action_index),
|
||||
FOREIGN KEY (player_id, package_version, account_id)
|
||||
REFERENCES welcome_grants(player_id, package_version, account_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_started_at ON task_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_log_entries_created_at ON log_entries(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_log_entries_run_id ON log_entries(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_commands_created_at ON admin_commands(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_welcome_grants_status ON welcome_grants(status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_welcome_grant_actions_status ON welcome_grant_actions(status, updated_at DESC);
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_creates_schema_and_pragmas() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("s.sqlite");
|
||||
let store = Store::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|c| {
|
||||
let mode: String = c.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
|
||||
assert_eq!(mode.to_lowercase(), "wal");
|
||||
let count: i64 = c.query_row(
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN \
|
||||
('task_runs','log_entries','admin_commands','pending_update','welcome_grants','welcome_grant_actions')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 6);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_migrates_welcome_ledger_to_account_scoped_key() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("s.sqlite");
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE welcome_grants (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
character_name TEXT,
|
||||
status TEXT NOT NULL,
|
||||
detected_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
granted_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_online_status TEXT,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version)
|
||||
);
|
||||
CREATE TABLE welcome_grant_actions (
|
||||
player_id TEXT NOT NULL,
|
||||
package_version TEXT NOT NULL,
|
||||
action_index INTEGER NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
published_at TEXT,
|
||||
confirmed_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
item_name TEXT,
|
||||
baseline_quantity INTEGER,
|
||||
expected_quantity INTEGER,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (player_id, package_version, action_index)
|
||||
);
|
||||
INSERT INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at
|
||||
) VALUES ('P1', 'v1', 10, 'Chani', 'granted', 'now', 'now');
|
||||
INSERT INTO welcome_grant_actions (
|
||||
player_id, package_version, action_index, action_type, status,
|
||||
created_at, updated_at, published_at
|
||||
) VALUES ('P1', 'v1', -1, 'welcome_message', 'published', 'now', 'now', 'now');
|
||||
-- Legacy pending grant + action: must be dropped by the migration.
|
||||
INSERT INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at
|
||||
) VALUES ('P2', 'v1', 20, 'Leto', 'pending', 'now', 'now');
|
||||
INSERT INTO welcome_grant_actions (
|
||||
player_id, package_version, action_index, action_type, status,
|
||||
created_at, updated_at
|
||||
) VALUES ('P2', 'v1', 0, 'grant_item', 'pending', 'now', 'now');
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let store = Store::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
primary_key_columns(conn, "welcome_grants")?,
|
||||
["player_id", "package_version", "account_id"]
|
||||
);
|
||||
assert_eq!(
|
||||
primary_key_columns(conn, "welcome_grant_actions")?,
|
||||
["player_id", "package_version", "account_id", "action_index"]
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let existing = store
|
||||
.ensure_welcome_action("P1", "v1", 10, -1, "welcome_message")
|
||||
.unwrap();
|
||||
assert_eq!(existing.status, WelcomeActionStatus::Published);
|
||||
|
||||
// The granted player survived; the legacy pending player and its
|
||||
// action were dropped (no FK orphan left behind).
|
||||
assert!(store.welcome_grant_exists("P1", "v1", 10).unwrap());
|
||||
assert!(!store.welcome_grant_exists("P2", "v1", 20).unwrap());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let orphans: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM welcome_grant_actions WHERE player_id = 'P2'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(orphans, 0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.ensure_welcome_grant("P1", "v1", 11, Some("Paul"), "Online")
|
||||
.unwrap();
|
||||
let fresh = store
|
||||
.ensure_welcome_action("P1", "v1", 11, -1, "welcome_message")
|
||||
.unwrap();
|
||||
assert_eq!(fresh.status, WelcomeActionStatus::Pending);
|
||||
}
|
||||
|
||||
pub(crate) fn tempdir() -> std::path::PathBuf {
|
||||
let mut d = std::env::temp_dir();
|
||||
d.push(format!("dune-sms-test-{}", uuid()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn uuid() -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Store;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingUpdateRecord {
|
||||
pub battlegroup: String,
|
||||
pub namespace: String,
|
||||
pub latest_steam_build: Option<String>,
|
||||
pub local_steam_build: Option<String>,
|
||||
pub live_version: Option<String>,
|
||||
pub downloaded_version: String,
|
||||
/// Unix epoch seconds.
|
||||
pub due_ts: i64,
|
||||
/// Unix epoch seconds.
|
||||
pub created_ts: i64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn load_pending_update(&self) -> Result<Option<PendingUpdateRecord>> {
|
||||
self.with_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT battlegroup, namespace, latest_steam_build, local_steam_build, \
|
||||
live_version, downloaded_version, due_ts, created_ts
|
||||
FROM pending_update WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PendingUpdateRecord {
|
||||
battlegroup: row.get(0)?,
|
||||
namespace: row.get(1)?,
|
||||
latest_steam_build: row.get(2)?,
|
||||
local_steam_build: row.get(3)?,
|
||||
live_version: row.get(4)?,
|
||||
downloaded_version: row.get(5)?,
|
||||
due_ts: row.get(6)?,
|
||||
created_ts: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn upsert_pending_update(&self, record: &PendingUpdateRecord) -> Result<()> {
|
||||
let created_ts = if record.created_ts == 0 {
|
||||
Utc::now().timestamp()
|
||||
} else {
|
||||
record.created_ts
|
||||
};
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO pending_update (id, battlegroup, namespace, latest_steam_build, \
|
||||
local_steam_build, live_version, downloaded_version, due_ts, created_ts)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
battlegroup = excluded.battlegroup,
|
||||
namespace = excluded.namespace,
|
||||
latest_steam_build = excluded.latest_steam_build,
|
||||
local_steam_build = excluded.local_steam_build,
|
||||
live_version = excluded.live_version,
|
||||
downloaded_version = excluded.downloaded_version,
|
||||
due_ts = excluded.due_ts",
|
||||
params![
|
||||
record.battlegroup,
|
||||
record.namespace,
|
||||
record.latest_steam_build,
|
||||
record.local_steam_build,
|
||||
record.live_version,
|
||||
record.downloaded_version,
|
||||
record.due_ts,
|
||||
created_ts,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_pending_update(&self) -> Result<()> {
|
||||
self.with_conn(|c| {
|
||||
c.execute("DELETE FROM pending_update WHERE id = 1", [])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn defer_pending_update(&self, delay_secs: i64) -> Result<()> {
|
||||
let due_ts = Utc::now().timestamp() + delay_secs.max(60);
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE pending_update SET due_ts = ?1 WHERE id = 1",
|
||||
params![due_ts],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tests::tempdir;
|
||||
|
||||
#[test]
|
||||
fn pending_update_roundtrip() {
|
||||
let s = Store::open(&tempdir().join("s.sqlite")).unwrap();
|
||||
assert!(s.load_pending_update().unwrap().is_none());
|
||||
let rec = PendingUpdateRecord {
|
||||
battlegroup: "alpha".into(),
|
||||
namespace: "funcom-seabass-alpha".into(),
|
||||
latest_steam_build: Some("12345".into()),
|
||||
local_steam_build: Some("12340".into()),
|
||||
live_version: Some("12340".into()),
|
||||
downloaded_version: "12345".into(),
|
||||
due_ts: 1_700_000_000,
|
||||
created_ts: 0,
|
||||
};
|
||||
s.upsert_pending_update(&rec).unwrap();
|
||||
let loaded = s.load_pending_update().unwrap().unwrap();
|
||||
assert_eq!(loaded.downloaded_version, "12345");
|
||||
assert!(loaded.created_ts > 0);
|
||||
s.clear_pending_update().unwrap();
|
||||
assert!(s.load_pending_update().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Store;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskRunStatus {
|
||||
Running,
|
||||
Success,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl TaskRunStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Success => "success",
|
||||
Self::Failed => "failed",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(value: &str) -> Self {
|
||||
match value {
|
||||
"running" => Self::Running,
|
||||
"success" => Self::Success,
|
||||
"failed" => Self::Failed,
|
||||
"skipped" => Self::Skipped,
|
||||
_ => Self::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskTrigger {
|
||||
Scheduled,
|
||||
Manual,
|
||||
Startup,
|
||||
}
|
||||
|
||||
impl TaskTrigger {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Scheduled => "scheduled",
|
||||
Self::Manual => "manual",
|
||||
Self::Startup => "startup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogLevel {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warn => "warn",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(value: &str) -> Self {
|
||||
match value {
|
||||
"warn" => Self::Warn,
|
||||
"error" => Self::Error,
|
||||
_ => Self::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TaskRun {
|
||||
pub id: i64,
|
||||
#[serde(rename = "taskId")]
|
||||
pub task_id: String,
|
||||
pub trigger: TaskTrigger,
|
||||
#[serde(rename = "dryRun")]
|
||||
pub dry_run: bool,
|
||||
pub status: TaskRunStatus,
|
||||
#[serde(rename = "startedAt")]
|
||||
pub started_at: String,
|
||||
#[serde(rename = "finishedAt")]
|
||||
pub finished_at: Option<String>,
|
||||
#[serde(rename = "durationMs")]
|
||||
pub duration_ms: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogEntry {
|
||||
pub id: i64,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
pub level: LogLevel,
|
||||
pub message: String,
|
||||
#[serde(rename = "taskId")]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(rename = "runId")]
|
||||
pub run_id: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct NewLogEntry<'a> {
|
||||
pub level: LogLevel,
|
||||
pub message: &'a str,
|
||||
pub task_id: Option<&'a str>,
|
||||
pub run_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn start_run(&self, task_id: &str, trigger: TaskTrigger, dry_run: bool) -> Result<i64> {
|
||||
let started_at = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO task_runs (task_id, trigger, dry_run, status, started_at)
|
||||
VALUES (?1, ?2, ?3, 'running', ?4)",
|
||||
params![task_id, trigger.as_str(), dry_run as i32, started_at],
|
||||
)?;
|
||||
Ok(c.last_insert_rowid())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_run(
|
||||
&self,
|
||||
run_id: i64,
|
||||
status: TaskRunStatus,
|
||||
error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let finished_at = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE task_runs
|
||||
SET status = ?1,
|
||||
finished_at = ?2,
|
||||
duration_ms = CAST((julianday(?2) - julianday(started_at)) * 86400000 AS INTEGER),
|
||||
error = ?3
|
||||
WHERE id = ?4",
|
||||
params![status.as_str(), finished_at, error, run_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_run(&self, run_id: i64) -> Result<()> {
|
||||
self.with_conn(|c| {
|
||||
c.execute("DELETE FROM log_entries WHERE run_id = ?1", params![run_id])?;
|
||||
c.execute("DELETE FROM task_runs WHERE id = ?1", params![run_id])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn log(&self, entry: &NewLogEntry<'_>) -> Result<()> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO log_entries (created_at, level, message, task_id, run_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
created_at,
|
||||
entry.level.as_str(),
|
||||
entry.message,
|
||||
entry.task_id,
|
||||
entry.run_id
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_runs(&self, limit: u32, task_id: Option<&str>) -> Result<Vec<TaskRun>> {
|
||||
let limit = limit.clamp(1, 500) as i64;
|
||||
self.with_conn(|c| match task_id {
|
||||
Some(tid) => {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id, task_id, trigger, dry_run, status, started_at, finished_at, \
|
||||
duration_ms, error
|
||||
FROM task_runs WHERE task_id = ?1
|
||||
ORDER BY started_at DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![tid, limit], map_run)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
None => {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id, task_id, trigger, dry_run, status, started_at, finished_at, \
|
||||
duration_ms, error
|
||||
FROM task_runs ORDER BY started_at DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], map_run)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_logs(&self, limit: u32, run_id: Option<i64>) -> Result<Vec<LogEntry>> {
|
||||
let limit = limit.clamp(1, 2000) as i64;
|
||||
self.with_conn(|c| match run_id {
|
||||
Some(rid) => {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id, created_at, level, message, task_id, run_id
|
||||
FROM log_entries WHERE run_id = ?1
|
||||
ORDER BY created_at ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![rid, limit], map_log)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
None => {
|
||||
// Take the N most recent rows but return them oldest-first so
|
||||
// the client can scroll down through chronological order.
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT id, created_at, level, message, task_id, run_id FROM (\
|
||||
SELECT * FROM log_entries ORDER BY created_at DESC LIMIT ?1\
|
||||
) sub ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], map_log)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_runs_by_status(&self) -> Result<(i64, i64, i64, i64)> {
|
||||
self.with_conn(|c| {
|
||||
let total: i64 = c.query_row("SELECT count(*) FROM task_runs", [], |r| r.get(0))?;
|
||||
let succeeded: i64 = c.query_row(
|
||||
"SELECT count(*) FROM task_runs WHERE status='success'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let failed: i64 = c.query_row(
|
||||
"SELECT count(*) FROM task_runs WHERE status='failed'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let running: i64 = c.query_row(
|
||||
"SELECT count(*) FROM task_runs WHERE status='running'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((total, succeeded, failed, running))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_run(&self, run_id: i64) -> Result<Option<TaskRun>> {
|
||||
self.with_conn(|c| {
|
||||
c.query_row(
|
||||
"SELECT id, task_id, trigger, dry_run, status, started_at, finished_at, \
|
||||
duration_ms, error
|
||||
FROM task_runs WHERE id = ?1",
|
||||
params![run_id],
|
||||
map_run,
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn map_run(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRun> {
|
||||
let trigger_raw: String = row.get(2)?;
|
||||
let status_raw: String = row.get(4)?;
|
||||
let dry_run_raw: i64 = row.get(3)?;
|
||||
Ok(TaskRun {
|
||||
id: row.get(0)?,
|
||||
task_id: row.get(1)?,
|
||||
trigger: match trigger_raw.as_str() {
|
||||
"scheduled" => TaskTrigger::Scheduled,
|
||||
"startup" => TaskTrigger::Startup,
|
||||
_ => TaskTrigger::Manual,
|
||||
},
|
||||
dry_run: dry_run_raw != 0,
|
||||
status: TaskRunStatus::from_str(&status_raw),
|
||||
started_at: row.get(5)?,
|
||||
finished_at: row.get(6)?,
|
||||
duration_ms: row.get(7)?,
|
||||
error: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_log(row: &rusqlite::Row<'_>) -> rusqlite::Result<LogEntry> {
|
||||
let level_raw: String = row.get(2)?;
|
||||
Ok(LogEntry {
|
||||
id: row.get(0)?,
|
||||
created_at: row.get(1)?,
|
||||
level: LogLevel::from_str(&level_raw),
|
||||
message: row.get(3)?,
|
||||
task_id: row.get(4)?,
|
||||
run_id: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tests::tempdir;
|
||||
use crate::store::Store;
|
||||
|
||||
fn open_store() -> Store {
|
||||
let dir = tempdir();
|
||||
Store::open(&dir.join("s.sqlite")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_lifecycle_and_logging_roundtrip() {
|
||||
let s = open_store();
|
||||
let id = s
|
||||
.start_run("backup", TaskTrigger::Scheduled, false)
|
||||
.unwrap();
|
||||
s.log(&NewLogEntry {
|
||||
level: LogLevel::Info,
|
||||
message: "starting",
|
||||
task_id: Some("backup"),
|
||||
run_id: Some(id),
|
||||
})
|
||||
.unwrap();
|
||||
s.finish_run(id, TaskRunStatus::Success, None).unwrap();
|
||||
|
||||
let runs = s.list_runs(10, None).unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].id, id);
|
||||
assert_eq!(runs[0].status, TaskRunStatus::Success);
|
||||
assert!(runs[0].duration_ms.is_some());
|
||||
|
||||
let logs = s.list_logs(10, Some(id)).unwrap();
|
||||
assert_eq!(logs.len(), 1);
|
||||
assert_eq!(logs[0].message, "starting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_run_cascades_logs() {
|
||||
let s = open_store();
|
||||
let id = s.start_run("backup", TaskTrigger::Manual, true).unwrap();
|
||||
s.log(&NewLogEntry {
|
||||
level: LogLevel::Info,
|
||||
message: "noop",
|
||||
task_id: Some("backup"),
|
||||
run_id: Some(id),
|
||||
})
|
||||
.unwrap();
|
||||
s.delete_run(id).unwrap();
|
||||
assert!(s.list_runs(10, None).unwrap().is_empty());
|
||||
assert!(s.list_logs(10, Some(id)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_track_status_transitions() {
|
||||
let s = open_store();
|
||||
let r1 = s.start_run("a", TaskTrigger::Scheduled, false).unwrap();
|
||||
let r2 = s.start_run("b", TaskTrigger::Scheduled, false).unwrap();
|
||||
s.finish_run(r1, TaskRunStatus::Success, None).unwrap();
|
||||
s.finish_run(r2, TaskRunStatus::Failed, Some("boom"))
|
||||
.unwrap();
|
||||
let (total, succ, fail, running) = s.count_runs_by_status().unwrap();
|
||||
assert_eq!((total, succ, fail, running), (2, 1, 1, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::Store;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WelcomeGrantStatus {
|
||||
Pending,
|
||||
Granted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl WelcomeGrantStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Granted => "granted",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(raw: &str) -> Self {
|
||||
match raw {
|
||||
"granted" => Self::Granted,
|
||||
"failed" => Self::Failed,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WelcomeGrantRecord {
|
||||
pub player_id: String,
|
||||
pub package_version: String,
|
||||
pub account_id: i64,
|
||||
pub character_name: Option<String>,
|
||||
pub status: WelcomeGrantStatus,
|
||||
pub detected_at: String,
|
||||
pub updated_at: String,
|
||||
pub granted_at: Option<String>,
|
||||
pub attempts: i64,
|
||||
pub last_online_status: Option<String>,
|
||||
pub first_online_at: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WelcomeActionStatus {
|
||||
Pending,
|
||||
Published,
|
||||
Confirmed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl WelcomeActionStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Published => "published",
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(raw: &str) -> Self {
|
||||
match raw {
|
||||
"published" => Self::Published,
|
||||
"confirmed" => Self::Confirmed,
|
||||
"failed" => Self::Failed,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WelcomeActionRecord {
|
||||
pub player_id: String,
|
||||
pub package_version: String,
|
||||
pub account_id: i64,
|
||||
pub action_index: i64,
|
||||
pub action_type: String,
|
||||
pub status: WelcomeActionStatus,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub published_at: Option<String>,
|
||||
pub confirmed_at: Option<String>,
|
||||
pub attempts: i64,
|
||||
pub item_name: Option<String>,
|
||||
pub baseline_quantity: Option<i64>,
|
||||
pub expected_quantity: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn welcome_grant_exists(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
) -> Result<bool> {
|
||||
self.with_conn(|c| {
|
||||
let exists = c
|
||||
.query_row(
|
||||
"SELECT 1 FROM welcome_grants
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND account_id = ?3
|
||||
LIMIT 1",
|
||||
params![player_id, package_version, account_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
Ok(exists)
|
||||
})
|
||||
}
|
||||
|
||||
/// Cheap sqlite check used by the welcome-message worker to skip accounts
|
||||
/// whose whisper is already confirmed before paying for a Postgres chat
|
||||
/// lookup on every scan.
|
||||
pub fn welcome_action_confirmed(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
) -> Result<bool> {
|
||||
self.with_conn(|c| {
|
||||
let confirmed = c
|
||||
.query_row(
|
||||
"SELECT 1 FROM welcome_grant_actions
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND account_id = ?3
|
||||
AND action_index = ?4 AND status = 'confirmed'
|
||||
LIMIT 1",
|
||||
params![player_id, package_version, account_id, action_index],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
Ok(confirmed)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_welcome_grant_granted(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
character_name: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_error
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, 'granted', ?5, ?5, ?5, 1, NULL)",
|
||||
params![player_id, package_version, account_id, character_name, now],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert_welcome_grant_failed(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
character_name: Option<&str>,
|
||||
error: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_error
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, 'failed', ?5, ?5, NULL, 1, ?6)",
|
||||
params![
|
||||
player_id,
|
||||
package_version,
|
||||
account_id,
|
||||
character_name,
|
||||
now,
|
||||
error
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_welcome_grant(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
character_name: Option<&str>,
|
||||
online_status: &str,
|
||||
) -> Result<WelcomeGrantRecord> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_online = online_status.eq_ignore_ascii_case("Online");
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO welcome_grants (
|
||||
player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, last_online_status, first_online_at
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?5, ?6, ?7)
|
||||
ON CONFLICT(player_id, package_version, account_id) DO UPDATE SET
|
||||
character_name = COALESCE(excluded.character_name, welcome_grants.character_name),
|
||||
last_online_status = excluded.last_online_status,
|
||||
first_online_at = CASE
|
||||
WHEN welcome_grants.status != 'pending' THEN welcome_grants.first_online_at
|
||||
WHEN excluded.first_online_at IS NULL THEN NULL
|
||||
ELSE COALESCE(welcome_grants.first_online_at, excluded.first_online_at)
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN welcome_grants.status = 'pending' THEN excluded.updated_at
|
||||
ELSE welcome_grants.updated_at
|
||||
END",
|
||||
params![
|
||||
player_id,
|
||||
package_version,
|
||||
account_id,
|
||||
character_name,
|
||||
now,
|
||||
online_status,
|
||||
if is_online { Some(now.as_str()) } else { None },
|
||||
],
|
||||
)?;
|
||||
select_welcome_grant(c, player_id, package_version, account_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Clears a `failed` welcome-grant ledger row so the next scan re-attempts
|
||||
/// it. Only `failed` rows are removed — `granted` rows are left in place so
|
||||
/// a retry can never duplicate a successful package. Returns the number of
|
||||
/// rows deleted (0 if the grant was not failed or does not exist).
|
||||
pub fn delete_welcome_grant(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
) -> Result<usize> {
|
||||
self.with_conn(|c| {
|
||||
let removed = c.execute(
|
||||
"DELETE FROM welcome_grants
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND account_id = ?3
|
||||
AND status = 'failed'",
|
||||
params![player_id, package_version, account_id],
|
||||
)?;
|
||||
Ok(removed)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_welcome_grants(&self, limit: u32) -> Result<Vec<WelcomeGrantRecord>> {
|
||||
let limit = limit.clamp(1, 500) as i64;
|
||||
self.with_conn(|c| {
|
||||
let mut stmt = c.prepare(
|
||||
"SELECT player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_online_status,
|
||||
first_online_at, last_error
|
||||
FROM welcome_grants
|
||||
WHERE package_version NOT LIKE '%:message'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], read_welcome_grant)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_welcome_action(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
action_type: &str,
|
||||
) -> Result<WelcomeActionRecord> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"INSERT INTO welcome_grant_actions (
|
||||
player_id, package_version, account_id, action_index, action_type, status,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?6)
|
||||
ON CONFLICT(player_id, package_version, account_id, action_index) DO UPDATE SET
|
||||
action_type = excluded.action_type",
|
||||
params![
|
||||
player_id,
|
||||
package_version,
|
||||
account_id,
|
||||
action_index,
|
||||
action_type,
|
||||
now
|
||||
],
|
||||
)?;
|
||||
select_welcome_action(c, player_id, package_version, account_id, action_index)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_welcome_action_published(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
item_name: Option<&str>,
|
||||
baseline_quantity: Option<i64>,
|
||||
expected_quantity: Option<i64>,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE welcome_grant_actions
|
||||
SET status = 'published',
|
||||
updated_at = ?4,
|
||||
published_at = ?4,
|
||||
attempts = attempts + 1,
|
||||
item_name = COALESCE(?5, item_name),
|
||||
baseline_quantity = COALESCE(?6, baseline_quantity),
|
||||
expected_quantity = COALESCE(?7, expected_quantity),
|
||||
last_error = NULL
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND action_index = ?3 AND account_id = ?8",
|
||||
params![
|
||||
player_id,
|
||||
package_version,
|
||||
action_index,
|
||||
now,
|
||||
item_name,
|
||||
baseline_quantity,
|
||||
expected_quantity,
|
||||
account_id
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_welcome_action_confirmed(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE welcome_grant_actions
|
||||
SET status = 'confirmed',
|
||||
updated_at = ?4,
|
||||
confirmed_at = ?4,
|
||||
last_error = NULL
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND action_index = ?3 AND account_id = ?5",
|
||||
params![player_id, package_version, action_index, now, account_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_welcome_action_failed(
|
||||
&self,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
error: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
self.with_conn(|c| {
|
||||
c.execute(
|
||||
"UPDATE welcome_grant_actions
|
||||
SET status = 'failed',
|
||||
updated_at = ?4,
|
||||
last_error = ?5
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND action_index = ?3 AND account_id = ?6",
|
||||
params![player_id, package_version, action_index, now, error, account_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn select_welcome_grant(
|
||||
c: &rusqlite::Connection,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
) -> rusqlite::Result<WelcomeGrantRecord> {
|
||||
c.query_row(
|
||||
"SELECT player_id, package_version, account_id, character_name, status,
|
||||
detected_at, updated_at, granted_at, attempts, last_online_status,
|
||||
first_online_at, last_error
|
||||
FROM welcome_grants
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND account_id = ?3",
|
||||
params![player_id, package_version, account_id],
|
||||
read_welcome_grant,
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(rusqlite::Error::QueryReturnedNoRows)
|
||||
}
|
||||
|
||||
fn read_welcome_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result<WelcomeGrantRecord> {
|
||||
let status: String = row.get(4)?;
|
||||
Ok(WelcomeGrantRecord {
|
||||
player_id: row.get(0)?,
|
||||
package_version: row.get(1)?,
|
||||
account_id: row.get(2)?,
|
||||
character_name: row.get(3)?,
|
||||
status: WelcomeGrantStatus::from_str(&status),
|
||||
detected_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
granted_at: row.get(7)?,
|
||||
attempts: row.get(8)?,
|
||||
last_online_status: row.get(9)?,
|
||||
first_online_at: row.get(10)?,
|
||||
last_error: row.get(11)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_welcome_action(
|
||||
c: &rusqlite::Connection,
|
||||
player_id: &str,
|
||||
package_version: &str,
|
||||
account_id: i64,
|
||||
action_index: i64,
|
||||
) -> rusqlite::Result<WelcomeActionRecord> {
|
||||
c.query_row(
|
||||
"SELECT player_id, package_version, account_id, action_index, action_type, status,
|
||||
created_at, updated_at, published_at, confirmed_at, attempts,
|
||||
item_name, baseline_quantity, expected_quantity, last_error
|
||||
FROM welcome_grant_actions
|
||||
WHERE player_id = ?1 AND package_version = ?2 AND account_id = ?3 AND action_index = ?4",
|
||||
params![player_id, package_version, account_id, action_index],
|
||||
read_welcome_action,
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(rusqlite::Error::QueryReturnedNoRows)
|
||||
}
|
||||
|
||||
fn read_welcome_action(row: &rusqlite::Row<'_>) -> rusqlite::Result<WelcomeActionRecord> {
|
||||
let status: String = row.get(5)?;
|
||||
Ok(WelcomeActionRecord {
|
||||
player_id: row.get(0)?,
|
||||
package_version: row.get(1)?,
|
||||
account_id: row.get(2)?,
|
||||
action_index: row.get(3)?,
|
||||
action_type: row.get(4)?,
|
||||
status: WelcomeActionStatus::from_str(&status),
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
published_at: row.get(8)?,
|
||||
confirmed_at: row.get(9)?,
|
||||
attempts: row.get(10)?,
|
||||
item_name: row.get(11)?,
|
||||
baseline_quantity: row.get(12)?,
|
||||
expected_quantity: row.get(13)?,
|
||||
last_error: row.get(14)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::tests::tempdir;
|
||||
|
||||
#[test]
|
||||
fn welcome_grant_lifecycle() {
|
||||
let s = Store::open(&tempdir().join("s.sqlite")).unwrap();
|
||||
assert!(!s.welcome_grant_exists("P1", "v1", 10).unwrap());
|
||||
|
||||
s.insert_welcome_grant_granted("P0", "v1", 9, Some("Duncan"))
|
||||
.unwrap();
|
||||
assert!(s.welcome_grant_exists("P0", "v1", 9).unwrap());
|
||||
|
||||
s.insert_welcome_grant_failed("P1", "v1", 10, Some("Chani"), "db timeout")
|
||||
.unwrap();
|
||||
assert!(s.welcome_grant_exists("P1", "v1", 10).unwrap());
|
||||
|
||||
let rows = s.list_welcome_grants(10).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
let p1 = rows.iter().find(|row| row.player_id == "P1").unwrap();
|
||||
assert_eq!(p1.status, WelcomeGrantStatus::Failed);
|
||||
assert_eq!(p1.last_error.as_deref(), Some("db timeout"));
|
||||
|
||||
// Retry clears only the failed row so the next scan re-attempts it.
|
||||
assert_eq!(s.delete_welcome_grant("P1", "v1", 10).unwrap(), 1);
|
||||
assert!(!s.welcome_grant_exists("P1", "v1", 10).unwrap());
|
||||
// A granted row is never removed by retry, so items cannot duplicate.
|
||||
assert_eq!(s.delete_welcome_grant("P0", "v1", 9).unwrap(), 0);
|
||||
assert!(s.welcome_grant_exists("P0", "v1", 9).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_action_lifecycle() {
|
||||
let s = Store::open(&tempdir().join("s.sqlite")).unwrap();
|
||||
s.ensure_welcome_grant("P1", "v1", 10, Some("Chani"), "Online")
|
||||
.unwrap();
|
||||
let rec = s
|
||||
.ensure_welcome_action("P1", "v1", 10, 0, "grant_item")
|
||||
.unwrap();
|
||||
assert_eq!(rec.status, WelcomeActionStatus::Pending);
|
||||
assert_eq!(rec.account_id, 10);
|
||||
|
||||
s.mark_welcome_action_published("P1", "v1", 10, 0, Some("Literjon"), Some(0), Some(1))
|
||||
.unwrap();
|
||||
let rec = s
|
||||
.ensure_welcome_action("P1", "v1", 10, 0, "grant_item")
|
||||
.unwrap();
|
||||
assert_eq!(rec.status, WelcomeActionStatus::Published);
|
||||
assert_eq!(rec.item_name.as_deref(), Some("Literjon"));
|
||||
assert_eq!(rec.expected_quantity, Some(1));
|
||||
|
||||
s.mark_welcome_action_confirmed("P1", "v1", 10, 0).unwrap();
|
||||
let rec = s
|
||||
.ensure_welcome_action("P1", "v1", 10, 0, "grant_item")
|
||||
.unwrap();
|
||||
assert_eq!(rec.status, WelcomeActionStatus::Confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_actions_are_account_scoped() {
|
||||
let s = Store::open(&tempdir().join("s.sqlite")).unwrap();
|
||||
s.ensure_welcome_grant("P1", "v1", 10, Some("Chani"), "Online")
|
||||
.unwrap();
|
||||
s.ensure_welcome_grant("P1", "v1", 11, Some("Paul"), "Online")
|
||||
.unwrap();
|
||||
s.mark_welcome_action_published("P1", "v1", 10, 0, Some("Literjon"), Some(0), Some(1))
|
||||
.unwrap();
|
||||
let rec = s
|
||||
.ensure_welcome_action("P1", "v1", 11, 0, "grant_item")
|
||||
.unwrap();
|
||||
assert_eq!(rec.status, WelcomeActionStatus::Pending);
|
||||
assert_eq!(rec.account_id, 11);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
const SERVICE_NAME: &str = "dune-server-service.service";
|
||||
const OVERRIDE_PATH: &str =
|
||||
"/etc/systemd/system/dune-server-service.service.d/zz-dune-steamcmd-compat.conf";
|
||||
|
||||
pub fn steamcmd_relocation_blocked(text: &str) -> bool {
|
||||
text.contains("steamclient.so")
|
||||
&& text.contains("cannot make segment writable for relocation")
|
||||
&& text.contains("Permission denied")
|
||||
}
|
||||
|
||||
pub fn repair_on_startup_if_needed() -> Result<bool> {
|
||||
repair_if_needed(false, "startup")
|
||||
}
|
||||
|
||||
pub fn repair_after_steamcmd_relocation_failure() -> Result<bool> {
|
||||
repair_if_needed(true, "steamcmd-relocation-failure")
|
||||
}
|
||||
|
||||
fn repair_if_needed(force: bool, reason: &str) -> Result<bool> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let script = format!(
|
||||
r#"set -eu
|
||||
if ! command -v systemctl >/dev/null 2>&1 || [ ! -d /run/systemd/system ]; then
|
||||
echo "not-systemd"
|
||||
exit 0
|
||||
fi
|
||||
state="$(systemctl show {service} -p MemoryDenyWriteExecute --value 2>/dev/null || true)"
|
||||
if [ "{force}" != "1" ]; then
|
||||
case "$state" in
|
||||
yes|true|1) ;;
|
||||
*) echo "compatible:$state"; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
sudo install -d -m 0755 /etc/systemd/system/{service}.d
|
||||
printf '%s\n' '[Service]' 'NoNewPrivileges=false' 'MemoryDenyWriteExecute=false' \
|
||||
| sudo install -m 0644 -o root -g root /dev/stdin {override_path}
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl reset-failed {service} >/dev/null 2>&1 || true
|
||||
sudo systemctl --no-block restart {service}
|
||||
echo "repaired:$state"
|
||||
"#,
|
||||
service = SERVICE_NAME,
|
||||
force = if force { "1" } else { "0" },
|
||||
override_path = OVERRIDE_PATH,
|
||||
);
|
||||
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.with_context(|| format!("checking systemd steamcmd compatibility for {reason}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"failed to repair systemd steamcmd compatibility for {reason}: stdout={} stderr={}",
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
let repaired = stdout.lines().any(|line| line.starts_with("repaired:"));
|
||||
if repaired {
|
||||
tracing::warn!(
|
||||
reason,
|
||||
override_path = OVERRIDE_PATH,
|
||||
"installed systemd steamcmd compatibility override and requested service restart"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
reason,
|
||||
detail = stdout.trim(),
|
||||
"systemd steamcmd compatibility check passed"
|
||||
);
|
||||
}
|
||||
Ok(repaired)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::kubectl::battlegroup as bg;
|
||||
use crate::kubectl::run_process;
|
||||
use crate::scheduler::{Schedule, Task, TaskCtx, TaskOutcome};
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
/// Replaces `scripts/cron-battlegroup-backup`. Runs the vendor backup helper,
|
||||
/// emits a per-run log line referencing the dump path, and lets the operator
|
||||
/// handle stale dump cleanup out-of-band (we do not invoke `sudo find -delete`
|
||||
/// from the daemon — too easy to widen the blast radius).
|
||||
pub struct BackupTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for BackupTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"backup"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
// Two gates: the `backup_enabled` master switch and a parsed cron.
|
||||
// Vendor backups block server I/O for the whole dump, so a cron must be
|
||||
// set explicitly (see seb851's report of in-play perf hits with the old
|
||||
// 2h default). The switch lets an operator pause the cadence without
|
||||
// discarding their cron. Either gate off -> Disabled (manual still runs).
|
||||
match (self.env.backup_enabled, self.env.backup_cron.as_ref()) {
|
||||
(true, Some(schedule)) => Schedule::Cron(Box::new(schedule.clone())),
|
||||
_ => Schedule::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
let cluster = ctx.env.cluster.get().await?;
|
||||
let bg_name = bg::bg_name(&ctx.env.kubectl, &cluster.namespace).await?;
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let backup_name = format!("{}-{}.backup", bg_name, stamp);
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info(&format!(
|
||||
"[dry-run] would invoke battlegroup backup name={backup_name}"
|
||||
))?;
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
ctx.log_info(&format!(
|
||||
"starting backup bg={bg_name} ns={} name={backup_name}",
|
||||
cluster.namespace
|
||||
))?;
|
||||
run_backup_and_verify(ctx, &bg_name, &backup_name).await?;
|
||||
ctx.log_info(&format!(
|
||||
"backup complete path=/funcom/artifacts/database-dumps/{bg_name}/{backup_name}"
|
||||
))?;
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_backup_and_verify(ctx: &TaskCtx, bg_name: &str, backup_name: &str) -> Result<()> {
|
||||
ctx.env.bg_cli.backup(backup_name).await?;
|
||||
|
||||
let backup_path = format!("/funcom/artifacts/database-dumps/{bg_name}/{backup_name}");
|
||||
let stat = run_process("sudo", &["-n", "stat", "-c", "%s", &backup_path], None, 30)
|
||||
.await
|
||||
.with_context(|| format!("checking backup output {backup_path}"))?;
|
||||
stat.require_ok(&format!("stat backup {backup_path}"))?;
|
||||
|
||||
let size = stat.stdout.trim().parse::<u64>().unwrap_or(0);
|
||||
if size == 0 {
|
||||
return Err(anyhow!("backup output is empty: {backup_path}"));
|
||||
}
|
||||
ctx.log_info(&format!("backup verified path={backup_path} bytes={size}"))?;
|
||||
|
||||
let spec_path = format!("{backup_path}.yaml");
|
||||
let spec = run_process("sudo", &["-n", "test", "-f", &spec_path], None, 30)
|
||||
.await
|
||||
.with_context(|| format!("checking backup companion spec {spec_path}"))?;
|
||||
if spec.ok() {
|
||||
ctx.log_info(&format!("backup companion spec present path={spec_path}"))?;
|
||||
} else {
|
||||
ctx.log_warn(&format!("backup companion spec missing path={spec_path}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use crate::admin::MqPublisher;
|
||||
use crate::kubectl::{BattlegroupCli, ClusterCache, KubectlClient, SteamCmd};
|
||||
use crate::postgres::PgClient;
|
||||
|
||||
pub mod backup;
|
||||
pub mod restart;
|
||||
pub mod restart_notice;
|
||||
pub mod update_apply;
|
||||
pub mod update_check;
|
||||
pub mod welcome_package;
|
||||
|
||||
/// Heavy-weight resources shared by all scheduled tasks. Constructed once in
|
||||
/// `main.rs` from [`crate::config::ServiceConfig`] and dropped into the
|
||||
/// scheduler so each `Task::run` call can borrow what it needs.
|
||||
pub struct TaskEnv {
|
||||
pub kubectl: KubectlClient,
|
||||
pub cluster: ClusterCache,
|
||||
pub bg_cli: BattlegroupCli,
|
||||
pub steamcmd: SteamCmd,
|
||||
pub mq: Arc<MqPublisher>,
|
||||
pub pg: Arc<PgClient>,
|
||||
pub bin_dir: PathBuf,
|
||||
pub download_path: PathBuf,
|
||||
/// Master switch for the daily restart and its pre-restart warning
|
||||
/// broadcast. Defaults to true; existing installs (no stored row) keep the
|
||||
/// prior always-on behavior.
|
||||
pub restart_enabled: bool,
|
||||
/// Master switch for the automatic update check + apply loop. Defaults to
|
||||
/// true. Manual triggers still work when disabled.
|
||||
pub update_enabled: bool,
|
||||
/// Master switch for scheduled backups. Defaults to true, but backups also
|
||||
/// require `backup_cron` to be set — this flag lets an operator pause the
|
||||
/// cadence without discarding their cron expression.
|
||||
pub backup_enabled: bool,
|
||||
/// Lead time before a downloaded update is applied (default 1800s = 30 min).
|
||||
pub update_lead_secs: i64,
|
||||
/// Restart-notice + restart wall-clock target (default 05:00).
|
||||
pub restart_hour: u32,
|
||||
pub restart_minute: u32,
|
||||
/// Restart broadcast frequency / declared shutdown duration.
|
||||
pub restart_warning_frequency_secs: u64,
|
||||
pub restart_warning_duration_secs: u64,
|
||||
pub restart_tz: Tz,
|
||||
/// Operator-supplied cron expression that drives automatic battlegroup
|
||||
/// backups, evaluated in `restart_tz`. `None` disables the scheduled
|
||||
/// backup loop; manual triggers via `/api/tasks/trigger` still run.
|
||||
/// Defaults to None — operators opt in to a cadence that suits their
|
||||
/// player traffic, since vendor backups stall server I/O.
|
||||
pub backup_cron: Option<cron::Schedule>,
|
||||
/// User-typed form of the cron expression, kept verbatim so the UI
|
||||
/// echoes exactly what the operator entered.
|
||||
pub backup_cron_raw: Option<String>,
|
||||
/// Enables the opt-in new-player welcome-package worker.
|
||||
pub welcome_package_enabled: bool,
|
||||
/// Enables the welcome whisper worker independently from item/package
|
||||
/// grants.
|
||||
pub welcome_message_enabled: bool,
|
||||
/// Operator-controlled package version. Changing it grants the package
|
||||
/// again because the package ledger key is
|
||||
/// `(player_id, package_version, account_id)`.
|
||||
pub welcome_package_version: String,
|
||||
/// JSON config for welcome-package actions. Kept as parsed data in the env
|
||||
/// so scheduled fires don't re-parse sqlite state.
|
||||
pub welcome_package_actions: Vec<welcome_package::WelcomePackageAction>,
|
||||
/// Verbatim JSON string for UI echo/restart-required checks.
|
||||
pub welcome_package_actions_json: String,
|
||||
/// Player lookup used as the visible sender for welcome whispers. Empty
|
||||
/// falls back to the recipient for self-sourced whispers.
|
||||
pub welcome_whisper_source_player: String,
|
||||
/// Welcome whisper text used by the automated action and manual send.
|
||||
pub welcome_message: String,
|
||||
}
|
||||
|
||||
/// All task implementations registered for the scheduler.
|
||||
pub fn build_all(env: Arc<TaskEnv>) -> Vec<Arc<dyn crate::scheduler::Task>> {
|
||||
vec![
|
||||
Arc::new(backup::BackupTask::new(env.clone())) as Arc<dyn crate::scheduler::Task>,
|
||||
Arc::new(update_check::UpdateCheckTask::new(env.clone())),
|
||||
Arc::new(update_apply::UpdateApplyTask::new(env.clone())),
|
||||
Arc::new(restart_notice::RestartNoticeTask::new(env.clone())),
|
||||
Arc::new(restart::RestartTask::new(env.clone())),
|
||||
Arc::new(welcome_package::WelcomePackageTask::new(env.clone())),
|
||||
Arc::new(welcome_package::WelcomeMessageTask::new(env)),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::kubectl::battlegroup as bg;
|
||||
use crate::kubectl::battlegroup_cli;
|
||||
use crate::scheduler::{Schedule, Task, TaskCtx, TaskOutcome};
|
||||
use crate::store::TaskTrigger;
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
/// Replaces `scripts/daily-battlegroup-restart`. Delegates the restart itself
|
||||
/// to the vendor `battlegroup restart` helper, then waits for full readiness.
|
||||
/// Schedule fires at the configured wall-clock hour:minute in the IANA timezone
|
||||
/// supplied by `TaskEnv` (default 05:00 Europe/Amsterdam).
|
||||
pub struct RestartTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl RestartTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for RestartTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"restart"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
if self.env.restart_enabled {
|
||||
Schedule::daily(self.env.restart_hour, self.env.restart_minute)
|
||||
} else {
|
||||
Schedule::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
let cluster = ctx.env.cluster.get().await?;
|
||||
let bg_name = bg::bg_name(&ctx.env.kubectl, &cluster.namespace).await?;
|
||||
if ctx.trigger == TaskTrigger::Scheduled {
|
||||
let stop_value = bg::bg_field(
|
||||
&ctx.env.kubectl,
|
||||
&cluster.namespace,
|
||||
&bg_name,
|
||||
"{.spec.stop}",
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if stop_value == "true" {
|
||||
ctx.log_info(&format!(
|
||||
"battlegroup bg={bg_name} is stopped; skipping scheduled restart"
|
||||
))?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log_info(&format!(
|
||||
"restarting battlegroup bg={bg_name} ns={}",
|
||||
cluster.namespace
|
||||
))?;
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info("[dry-run] would invoke battlegroup restart")?;
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
ctx.env.bg_cli.restart().await?;
|
||||
let summary = battlegroup_cli::wait_until_running(
|
||||
&ctx.env.kubectl,
|
||||
&cluster.namespace,
|
||||
&bg_name,
|
||||
Duration::from_secs(1200),
|
||||
)
|
||||
.await?;
|
||||
ctx.log_info(&format!(
|
||||
"battlegroup restart complete phase={} serverGroupPhase={} ready={}/{}",
|
||||
summary.phase, summary.server_group_phase, summary.ready, summary.size
|
||||
))?;
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::admin::ShutdownType;
|
||||
use crate::scheduler::{schedule::Schedule, timezone, Task, TaskCtx, TaskOutcome};
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
/// Replaces `scripts/daily-battlegroup-restart-notice`. Scheduled daily at the
|
||||
/// configured wall-clock hour:minute. Computes the target timestamp for the
|
||||
/// actual restart and publishes a single ServerShutdown broadcast — the server
|
||||
/// uses the frequency/duration fields to render its own repeating countdown.
|
||||
///
|
||||
/// When triggered manually via `POST /api/runs/trigger` with an `options` body
|
||||
/// of shape `{ leadSecs, frequencySecs, durationSecs, title, body }`, the
|
||||
/// target timestamp is computed as `now + leadSecs` and the operator-supplied
|
||||
/// frequency / duration override the scheduler-wide defaults from
|
||||
/// `TaskEnv::restart_warning_*`. If both `title` and `body` are present, an
|
||||
/// additional Generic broadcast carrying them is fired before the
|
||||
/// ServerShutdown notice so the in-game UI shows the operator's message.
|
||||
pub struct RestartNoticeTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl RestartNoticeTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for RestartNoticeTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"restart-notice"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
// Tied to the daily restart switch: if auto-restart is off, the
|
||||
// scheduled countdown would warn about a restart that never fires.
|
||||
// Manual triggers still work regardless.
|
||||
if !self.env.restart_enabled {
|
||||
return Schedule::Disabled;
|
||||
}
|
||||
// Fire `restart_warning_duration_secs` before the actual restart
|
||||
// moment, so the operator-configured lead time is honored.
|
||||
let total_minutes = self.env.restart_hour * 60 + self.env.restart_minute;
|
||||
let lead_minutes = self.env.restart_warning_duration_secs.div_ceil(60) as u32;
|
||||
let pre = (total_minutes + 24 * 60 - (lead_minutes % (24 * 60))) % (24 * 60);
|
||||
Schedule::daily(pre / 60, pre % 60)
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
let opts = ctx.options.as_ref().and_then(|v| v.as_object());
|
||||
|
||||
let lead_secs = opts
|
||||
.and_then(|o| o.get("leadSecs"))
|
||||
.and_then(|v| v.as_i64());
|
||||
let frequency_override = opts
|
||||
.and_then(|o| o.get("frequencySecs"))
|
||||
.and_then(|v| v.as_u64());
|
||||
let duration_override = opts
|
||||
.and_then(|o| o.get("durationSecs"))
|
||||
.and_then(|v| v.as_u64());
|
||||
let title = opts
|
||||
.and_then(|o| o.get("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
let body = opts
|
||||
.and_then(|o| o.get("body"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if matches!(lead_secs, Some(secs) if secs < 0) {
|
||||
return Err(anyhow!("leadSecs must be >= 0"));
|
||||
}
|
||||
if matches!(frequency_override, Some(0)) {
|
||||
return Err(anyhow!("frequencySecs must be greater than 0"));
|
||||
}
|
||||
if matches!(duration_override, Some(0)) {
|
||||
return Err(anyhow!("durationSecs must be greater than 0"));
|
||||
}
|
||||
|
||||
let target_utc = match lead_secs {
|
||||
Some(secs) => Utc::now() + chrono::Duration::seconds(secs),
|
||||
None => timezone::next_daily_at(
|
||||
ctx.env.restart_tz,
|
||||
ctx.env.restart_hour,
|
||||
ctx.env.restart_minute,
|
||||
Utc::now(),
|
||||
),
|
||||
};
|
||||
let target_ts = target_utc.timestamp();
|
||||
let frequency = frequency_override.unwrap_or(ctx.env.restart_warning_frequency_secs);
|
||||
let duration = duration_override.unwrap_or(ctx.env.restart_warning_duration_secs);
|
||||
|
||||
ctx.log_info(&format!(
|
||||
"scheduling restart warning target_ts={target_ts} frequency={frequency}s duration={duration}s tz={} (source={})",
|
||||
ctx.env.restart_tz.name(),
|
||||
if lead_secs.is_some() { "manual" } else { "scheduled" },
|
||||
))?;
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info("[dry-run] would publish ServerShutdown broadcast")?;
|
||||
if title.is_some() && body.is_some() {
|
||||
ctx.log_info(
|
||||
"[dry-run] would also publish Generic broadcast with custom title/body",
|
||||
)?;
|
||||
}
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
if let (Some(t), Some(b)) = (title, body) {
|
||||
// Operator opted in to a custom in-game banner; fire it for the same
|
||||
// wall-clock duration as the countdown so it stays visible.
|
||||
let banner = ctx.env.mq.publish_service_broadcast(t, b, duration).await?;
|
||||
ctx.log_info(&format!(
|
||||
"custom broadcast ok={} output={}",
|
||||
banner.ok,
|
||||
banner.output.trim()
|
||||
))?;
|
||||
}
|
||||
|
||||
// The shared service-broadcast base payload accepts BroadcastDuration
|
||||
// for the on-screen pulse length. Match the manual Admin form's
|
||||
// default so scheduled + manual paths produce the same shape.
|
||||
let broadcast_duration = 30u64;
|
||||
let result = ctx
|
||||
.env
|
||||
.mq
|
||||
.publish_server_shutdown(
|
||||
ShutdownType::Restart,
|
||||
target_ts,
|
||||
frequency,
|
||||
duration,
|
||||
broadcast_duration,
|
||||
)
|
||||
.await?;
|
||||
ctx.log_info(&format!(
|
||||
"publish ok={} output={}",
|
||||
result.ok,
|
||||
result.output.trim()
|
||||
))?;
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::kubectl::{battlegroup as bg, steam};
|
||||
use crate::scheduler::{Schedule, Task, TaskCtx, TaskOutcome};
|
||||
use crate::store::PendingUpdateRecord;
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
const UPDATE_RETRY_DELAY_SECS: i64 = 15 * 60;
|
||||
|
||||
/// Replaces `scripts/apply-pending-battlegroup-update`. Every minute, checks
|
||||
/// whether the `pending_update` row's `due_ts` has arrived; when it has,
|
||||
/// broadcasts, runs a pre-update backup, then delegates the full
|
||||
/// check/download/apply/restart flow to the vendor `battlegroup update`.
|
||||
pub struct UpdateApplyTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl UpdateApplyTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for UpdateApplyTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"update-apply"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
if self.env.update_enabled {
|
||||
Schedule::interval_secs(60)
|
||||
} else {
|
||||
Schedule::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
let Some(pending) = ctx.store.load_pending_update()? else {
|
||||
return Ok(TaskOutcome::Noop);
|
||||
};
|
||||
if Utc::now().timestamp() < pending.due_ts {
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
|
||||
let result = apply_due_update(ctx, pending).await;
|
||||
if let Err(err) = &result {
|
||||
if !ctx.dry_run {
|
||||
let _ = ctx.store.defer_pending_update(UPDATE_RETRY_DELAY_SECS);
|
||||
let _ = ctx.log_warn(&format!(
|
||||
"deferred pending update retry by {} seconds after failure: {err:#}",
|
||||
UPDATE_RETRY_DELAY_SECS
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_due_update(ctx: &TaskCtx, pending: PendingUpdateRecord) -> Result<TaskOutcome> {
|
||||
ctx.log_info(&format!(
|
||||
"applying pending update bg={} latest_steam_build={} live={}",
|
||||
pending.battlegroup,
|
||||
pending.downloaded_version,
|
||||
pending.live_version.as_deref().unwrap_or("unknown")
|
||||
))?;
|
||||
|
||||
let bg_doc = bg::bg_json(&ctx.env.kubectl, &pending.namespace, &pending.battlegroup).await?;
|
||||
let current_live_version = steam::extract_live_version(&bg_doc);
|
||||
let downloaded_version = ctx.env.steamcmd.downloaded_version().await?;
|
||||
let local_build = ctx.env.steamcmd.local_build().await?;
|
||||
if update_already_applied(
|
||||
local_build.as_deref(),
|
||||
pending.latest_steam_build.as_deref(),
|
||||
current_live_version.as_deref(),
|
||||
downloaded_version.as_deref(),
|
||||
) {
|
||||
let applied = downloaded_version.as_deref().unwrap_or("unknown");
|
||||
ctx.log_info(&format!(
|
||||
"pending update already applied to live BattleGroup version {applied}; clearing pending update"
|
||||
))?;
|
||||
if !ctx.dry_run {
|
||||
ctx.store.clear_pending_update()?;
|
||||
}
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info("[dry-run] would run pre-update backup and vendor battlegroup update")?;
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
if let Err(err) = ctx
|
||||
.env
|
||||
.mq
|
||||
.publish_service_broadcast(
|
||||
"Server update",
|
||||
"Server update is starting now. The server will restart.",
|
||||
60,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ctx.log_warn(&format!("pre-update broadcast failed: {err:#}"))?;
|
||||
}
|
||||
|
||||
ctx.log_info("taking pre-update database backup")?;
|
||||
backup_one(ctx, &pending.battlegroup).await?;
|
||||
|
||||
ctx.log_info("running vendor battlegroup update")?;
|
||||
ctx.env.bg_cli.update().await?;
|
||||
ctx.log_info("vendor battlegroup update completed")?;
|
||||
ctx.store.clear_pending_update()?;
|
||||
|
||||
if let Err(err) = ctx
|
||||
.env
|
||||
.mq
|
||||
.publish_service_broadcast(
|
||||
"Server update",
|
||||
"Server update is complete and the server is back online.",
|
||||
60,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ctx.log_warn(&format!("post-update broadcast failed: {err:#}"))?;
|
||||
}
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
|
||||
async fn backup_one(ctx: &TaskCtx, bg_name: &str) -> Result<()> {
|
||||
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let backup_name = format!("{}-pre-update-{}.backup", bg_name, stamp);
|
||||
crate::tasks::backup::run_backup_and_verify(ctx, bg_name, &backup_name).await
|
||||
}
|
||||
|
||||
/// Decide whether a pending update is already satisfied and can be cleared
|
||||
/// without running the vendor update.
|
||||
///
|
||||
/// Both conditions are required:
|
||||
/// 1. the on-disk Steam download has advanced to the latest build
|
||||
/// (`local_build == latest_steam_build`), and
|
||||
/// 2. the live BattleGroup is already running that downloaded version
|
||||
/// (`live_version == downloaded_version`).
|
||||
///
|
||||
/// Condition 1 is the one that was missing and caused auto-updates to silently
|
||||
/// no-op: `downloaded_version` is read from `images/battlegroup/version.txt`,
|
||||
/// which is only rewritten by the vendor `battlegroup update` download step that
|
||||
/// runs *after* this check. Before that step runs, `version.txt` still holds the
|
||||
/// old live version, so `live == downloaded` is trivially true on every fresh
|
||||
/// pending update. Without first confirming the download actually advanced to
|
||||
/// the latest build, the guard would clear the pending row and return early,
|
||||
/// never downloading or restarting. `update-check` would then re-create the
|
||||
/// pending row on its next pass and the cycle would repeat indefinitely.
|
||||
fn update_already_applied(
|
||||
local_build: Option<&str>,
|
||||
latest_steam_build: Option<&str>,
|
||||
live_version: Option<&str>,
|
||||
downloaded_version: Option<&str>,
|
||||
) -> bool {
|
||||
let download_is_latest = matches!(
|
||||
(local_build, latest_steam_build),
|
||||
(Some(local), Some(latest)) if local == latest
|
||||
);
|
||||
if !download_is_latest {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
(live_version, downloaded_version),
|
||||
(Some(live), Some(downloaded)) if live == downloaded
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::update_already_applied;
|
||||
|
||||
// Regression for the silent auto-update no-op: a fresh pending update has the
|
||||
// on-disk download still behind latest, while live == downloaded (both the old
|
||||
// version). The guard must NOT treat this as applied, so the vendor update runs.
|
||||
#[test]
|
||||
fn not_applied_when_download_still_behind_latest() {
|
||||
assert!(!update_already_applied(
|
||||
Some("23510000"), // local build still old
|
||||
Some("23528481"), // latest steam build
|
||||
Some("1973075-0-shipping"), // live == downloaded because version.txt
|
||||
Some("1973075-0-shipping"), // hasn't been refreshed by the download step yet
|
||||
));
|
||||
}
|
||||
|
||||
// After the download advanced and the BattleGroup restarted onto it (e.g. a
|
||||
// manual update), the pending update is genuinely applied and can be cleared.
|
||||
#[test]
|
||||
fn applied_when_download_latest_and_live_matches() {
|
||||
assert!(update_already_applied(
|
||||
Some("23528481"),
|
||||
Some("23528481"),
|
||||
Some("1979201-0-shipping"),
|
||||
Some("1979201-0-shipping"),
|
||||
));
|
||||
}
|
||||
|
||||
// Download advanced to latest but the BattleGroup is still on the old image
|
||||
// (download done, restart pending): not yet applied, let the vendor flow run.
|
||||
#[test]
|
||||
fn not_applied_when_downloaded_but_not_yet_live() {
|
||||
assert!(!update_already_applied(
|
||||
Some("23528481"),
|
||||
Some("23528481"),
|
||||
Some("1973075-0-shipping"),
|
||||
Some("1979201-0-shipping"),
|
||||
));
|
||||
}
|
||||
|
||||
// Missing build/version data is never treated as applied.
|
||||
#[test]
|
||||
fn not_applied_when_data_missing() {
|
||||
assert!(!update_already_applied(None, Some("23528481"), Some("x"), Some("x")));
|
||||
assert!(!update_already_applied(
|
||||
Some("23528481"),
|
||||
None,
|
||||
Some("x"),
|
||||
Some("x")
|
||||
));
|
||||
assert!(!update_already_applied(
|
||||
Some("23528481"),
|
||||
Some("23528481"),
|
||||
None,
|
||||
Some("1979201-0-shipping")
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::kubectl::battlegroup as bg;
|
||||
use crate::kubectl::steam;
|
||||
use crate::scheduler::{Schedule, Task, TaskCtx, TaskOutcome};
|
||||
use crate::store::PendingUpdateRecord;
|
||||
use crate::tasks::TaskEnv;
|
||||
|
||||
/// Replaces `scripts/cron-battlegroup-update-check`. Polls Steam for the
|
||||
/// public-branch buildid, compares it to the locally downloaded build, and on
|
||||
/// a real delta writes a `pending_update` row. `UpdateApplyTask` later invokes
|
||||
/// the vendor `battlegroup update` flow, which owns download/apply/restart.
|
||||
pub struct UpdateCheckTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl UpdateCheckTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for UpdateCheckTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"update-check"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
if self.env.update_enabled {
|
||||
Schedule::interval_secs(15 * 60)
|
||||
} else {
|
||||
Schedule::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
// If a pending update already exists, nothing to do.
|
||||
if ctx.store.load_pending_update()?.is_some() {
|
||||
ctx.log_info("pending update already scheduled; skipping check")?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
|
||||
let cluster = ctx.env.cluster.get().await?;
|
||||
let bg_name = bg::bg_name(&ctx.env.kubectl, &cluster.namespace).await?;
|
||||
let bg_doc = bg::bg_json(&ctx.env.kubectl, &cluster.namespace, &bg_name).await?;
|
||||
let live_version = steam::extract_live_version(&bg_doc);
|
||||
|
||||
let latest = ctx.env.steamcmd.latest_public_build().await?;
|
||||
let local = ctx.env.steamcmd.local_build().await?;
|
||||
|
||||
ctx.log_info(&format!(
|
||||
"update check latest_build={} local_build={} live_version={}",
|
||||
latest.buildid,
|
||||
local.as_deref().unwrap_or("unknown"),
|
||||
live_version.as_deref().unwrap_or("unknown"),
|
||||
))?;
|
||||
|
||||
if let Some(local_build) = local.as_deref() {
|
||||
if local_build == latest.buildid {
|
||||
ctx.log_info("no Steam update available")?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info("[dry-run] would schedule vendor battlegroup update")?;
|
||||
return Ok(TaskOutcome::Done);
|
||||
}
|
||||
|
||||
let lead_secs = ctx.env.update_lead_secs.max(0);
|
||||
let due_ts = Utc::now().timestamp() + lead_secs;
|
||||
ctx.store.upsert_pending_update(&PendingUpdateRecord {
|
||||
battlegroup: bg_name.clone(),
|
||||
namespace: cluster.namespace.clone(),
|
||||
latest_steam_build: Some(latest.buildid.clone()),
|
||||
local_steam_build: local,
|
||||
live_version,
|
||||
downloaded_version: latest.buildid.clone(),
|
||||
due_ts,
|
||||
created_ts: 0,
|
||||
})?;
|
||||
ctx.log_info(&format!(
|
||||
"scheduled update bg={bg_name} latest_steam_build={} due_ts={due_ts}",
|
||||
latest.buildid
|
||||
))?;
|
||||
|
||||
if let Err(err) = ctx
|
||||
.env
|
||||
.mq
|
||||
.publish_service_broadcast(
|
||||
"Server update",
|
||||
&format!(
|
||||
"A server update is ready and will be applied in {}. The server will restart.",
|
||||
human_duration(lead_secs)
|
||||
),
|
||||
60,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ctx.log_warn(&format!("warning broadcast failed: {err:#}"))?;
|
||||
}
|
||||
Ok(TaskOutcome::Done)
|
||||
}
|
||||
}
|
||||
|
||||
fn human_duration(seconds: i64) -> String {
|
||||
if seconds == 0 {
|
||||
return "less than a minute".to_string();
|
||||
}
|
||||
if seconds % 3600 == 0 {
|
||||
let hours = seconds / 3600;
|
||||
return format!("{hours} {}", if hours == 1 { "hour" } else { "hours" });
|
||||
}
|
||||
if seconds % 60 == 0 {
|
||||
let minutes = seconds / 60;
|
||||
return format!(
|
||||
"{minutes} {}",
|
||||
if minutes == 1 { "minute" } else { "minutes" }
|
||||
);
|
||||
}
|
||||
format!("{seconds} seconds")
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::scheduler::{Schedule, Task, TaskCtx, TaskOutcome};
|
||||
use crate::store::WelcomeActionStatus;
|
||||
use crate::tasks::TaskEnv;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
const WELCOME_PACKAGE_SCAN_INTERVAL_SECS: u64 = 2;
|
||||
const WELCOME_MESSAGE_SCAN_INTERVAL_SECS: u64 = 60;
|
||||
const WELCOME_MESSAGE_ACTION_INDEX: i64 = -1;
|
||||
const WELCOME_MESSAGE_VERSION_SUFFIX: &str = ":message";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WelcomePackageItem {
|
||||
pub item_name: String,
|
||||
#[serde(default = "default_quantity")]
|
||||
pub quantity: i64,
|
||||
#[serde(default = "default_durability")]
|
||||
pub durability: f64,
|
||||
}
|
||||
|
||||
impl WelcomePackageItem {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.item_name.trim().is_empty() {
|
||||
return Err(anyhow!("welcome package itemName must not be empty"));
|
||||
}
|
||||
if self.quantity <= 0 {
|
||||
return Err(anyhow!(
|
||||
"welcome package quantity for {} must be greater than 0",
|
||||
self.item_name
|
||||
));
|
||||
}
|
||||
if !self.durability.is_finite() || self.durability <= 0.0 {
|
||||
return Err(anyhow!(
|
||||
"welcome package durability for {} must be greater than 0",
|
||||
self.item_name
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_quantity() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_durability() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
pub fn parse_welcome_items(raw: &str) -> Result<Vec<WelcomePackageItem>> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let items: Vec<WelcomePackageItem> = serde_json::from_str(trimmed)
|
||||
.map_err(|err| anyhow!("invalid welcome package JSON: {err}"))?;
|
||||
for item in &items {
|
||||
item.validate()?;
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WelcomePackageAction {
|
||||
GrantItem {
|
||||
#[serde(rename = "itemName")]
|
||||
item_name: String,
|
||||
#[serde(default = "default_quantity")]
|
||||
quantity: i64,
|
||||
#[serde(default = "default_durability")]
|
||||
durability: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl WelcomePackageAction {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::GrantItem {
|
||||
item_name,
|
||||
quantity,
|
||||
durability,
|
||||
} => WelcomePackageItem {
|
||||
item_name: item_name.clone(),
|
||||
quantity: *quantity,
|
||||
durability: *durability,
|
||||
}
|
||||
.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_welcome_actions(raw: &str) -> Result<Vec<WelcomePackageAction>> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(trimmed)
|
||||
.map_err(|err| anyhow!("invalid welcome package JSON: {err}"))?;
|
||||
let Some(items) = value.as_array() else {
|
||||
return Err(anyhow!("welcome package JSON must be an array"));
|
||||
};
|
||||
let actions: Vec<WelcomePackageAction> = if items.iter().any(|item| item.get("type").is_some())
|
||||
{
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if item.get("type").and_then(serde_json::Value::as_str) != Some("grantItem") {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_value::<WelcomePackageAction>(item.clone()).ok()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
serde_json::from_value::<Vec<WelcomePackageItem>>(value)
|
||||
.map_err(|err| anyhow!("invalid welcome package item JSON: {err}"))?
|
||||
.into_iter()
|
||||
.map(|item| WelcomePackageAction::GrantItem {
|
||||
item_name: item.item_name,
|
||||
quantity: item.quantity,
|
||||
durability: item.durability,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for action in &actions {
|
||||
action.validate()?;
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
pub struct WelcomePackageTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl WelcomePackageTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for WelcomePackageTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"welcome-package"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
if self.env.welcome_package_enabled {
|
||||
Schedule::interval_secs(WELCOME_PACKAGE_SCAN_INTERVAL_SECS)
|
||||
} else {
|
||||
Schedule::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
if !ctx.env.welcome_package_enabled {
|
||||
ctx.log_info("welcome package disabled")?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
if ctx.env.welcome_package_actions.is_empty() {
|
||||
ctx.log_warn("welcome package enabled but action list is empty")?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
|
||||
let cluster = ctx.env.cluster.get().await?;
|
||||
let accounts =
|
||||
crate::postgres::list_welcome_accounts(&ctx.env.pg, &cluster.namespace).await?;
|
||||
|
||||
// Only keep a run row when this scan actually did something (granted a
|
||||
// package, recorded a failure, or, for a manual dry-run, previewed an
|
||||
// account). The 2s scan is otherwise a no-op for already-handled or
|
||||
// still-loading accounts and would flood the recent-runs history.
|
||||
let mut acted = false;
|
||||
|
||||
for account in accounts {
|
||||
if account.fls_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info(&format!(
|
||||
"[dry-run] would inspect welcome package version={} player={} account_id={} items={}",
|
||||
ctx.env.welcome_package_version,
|
||||
account.fls_id,
|
||||
account.account_id,
|
||||
ctx.env.welcome_package_actions.len()
|
||||
))?;
|
||||
acted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cheap sqlite gate: a granted OR failed ledger row means we are
|
||||
// done with this account. Failed rows are cleared by the operator
|
||||
// via the "retry" action, which deletes the row so it re-attempts.
|
||||
if ctx.store.welcome_grant_exists(
|
||||
&account.fls_id,
|
||||
&ctx.env.welcome_package_version,
|
||||
account.account_id,
|
||||
)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
match process_item_package(ctx, &cluster.namespace, &account).await {
|
||||
Ok(Some(character_name)) => {
|
||||
ctx.store.insert_welcome_grant_granted(
|
||||
&account.fls_id,
|
||||
&ctx.env.welcome_package_version,
|
||||
account.account_id,
|
||||
Some(character_name.as_str()),
|
||||
)?;
|
||||
acted = true;
|
||||
}
|
||||
// Backpack inventory row not present yet — leave no ledger row
|
||||
// so a later scan retries once the character finishes loading.
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
let scrubbed = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
ctx.store.insert_welcome_grant_failed(
|
||||
&account.fls_id,
|
||||
&ctx.env.welcome_package_version,
|
||||
account.account_id,
|
||||
None,
|
||||
&scrubbed,
|
||||
)?;
|
||||
ctx.log_warn(&format!(
|
||||
"welcome package failed player={} account_id={} version={} error={}",
|
||||
account.fls_id, account.account_id, ctx.env.welcome_package_version, scrubbed
|
||||
))?;
|
||||
acted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if acted {
|
||||
TaskOutcome::Done
|
||||
} else {
|
||||
TaskOutcome::Noop
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the welcome whisper on its own slower cadence. Split out from the
|
||||
/// package worker so the 2s item-grant scan does not hit Postgres for a chat
|
||||
/// lookup on every account every tick.
|
||||
pub struct WelcomeMessageTask {
|
||||
env: Arc<TaskEnv>,
|
||||
}
|
||||
|
||||
impl WelcomeMessageTask {
|
||||
pub fn new(env: Arc<TaskEnv>) -> Self {
|
||||
Self { env }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Task for WelcomeMessageTask {
|
||||
fn id(&self) -> &'static str {
|
||||
"welcome-message"
|
||||
}
|
||||
|
||||
fn schedule(&self) -> Schedule {
|
||||
if self.env.welcome_message_enabled {
|
||||
Schedule::interval_secs(WELCOME_MESSAGE_SCAN_INTERVAL_SECS)
|
||||
} else {
|
||||
Schedule::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &TaskCtx) -> Result<TaskOutcome> {
|
||||
if !ctx.env.welcome_message_enabled {
|
||||
ctx.log_info("welcome message disabled")?;
|
||||
return Ok(TaskOutcome::Noop);
|
||||
}
|
||||
|
||||
let cluster = ctx.env.cluster.get().await?;
|
||||
let accounts =
|
||||
crate::postgres::list_welcome_accounts(&ctx.env.pg, &cluster.namespace).await?;
|
||||
let message_version = format!(
|
||||
"{}{}",
|
||||
ctx.env.welcome_package_version, WELCOME_MESSAGE_VERSION_SUFFIX
|
||||
);
|
||||
|
||||
// Only keep a run row when this scan actually whispered someone,
|
||||
// recorded a failure, or previewed during a manual dry-run. The 60s
|
||||
// scan is otherwise a no-op for already-greeted or absent players and
|
||||
// would flood the recent-runs history.
|
||||
let mut acted = false;
|
||||
|
||||
for account in accounts {
|
||||
if account.fls_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ctx.dry_run {
|
||||
ctx.log_info(&format!(
|
||||
"[dry-run] would send welcome whisper player={} account_id={}",
|
||||
account.fls_id, account.account_id
|
||||
))?;
|
||||
acted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cheap sqlite gate before any Postgres chat lookup: skip accounts
|
||||
// whose whisper is already confirmed.
|
||||
if ctx.store.welcome_action_confirmed(
|
||||
&account.fls_id,
|
||||
&message_version,
|
||||
account.account_id,
|
||||
WELCOME_MESSAGE_ACTION_INDEX,
|
||||
)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
match process_account_welcome_message(ctx, &cluster.namespace, &account).await {
|
||||
// Whisper published/confirmed this scan.
|
||||
Ok(true) => acted = true,
|
||||
// Recipient not resolvable yet or already confirmed — no action.
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
let scrubbed = crate::logger::redact(&format!("{err:#}")).into_owned();
|
||||
ctx.log_warn(&format!(
|
||||
"welcome message failed player={} account_id={} error={}",
|
||||
account.fls_id, account.account_id, scrubbed
|
||||
))?;
|
||||
acted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if acted {
|
||||
TaskOutcome::Done
|
||||
} else {
|
||||
TaskOutcome::Noop
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_item_package(
|
||||
ctx: &TaskCtx,
|
||||
namespace: &str,
|
||||
account: &crate::postgres::WelcomeAccount,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(backpack) =
|
||||
crate::postgres::resolve_account_backpack(&ctx.env.pg, namespace, account.account_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let items = ctx
|
||||
.env
|
||||
.welcome_package_actions
|
||||
.iter()
|
||||
.map(|action| match action {
|
||||
WelcomePackageAction::GrantItem {
|
||||
item_name,
|
||||
quantity,
|
||||
durability,
|
||||
} => Ok(crate::postgres::BackpackGrantItem {
|
||||
template_id: item_name.clone(),
|
||||
quantity: *quantity,
|
||||
stats_json: welcome_item_stats_json(item_name, *durability)?,
|
||||
}),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
let ids = crate::postgres::insert_items_to_backpack(
|
||||
&ctx.env.pg,
|
||||
namespace,
|
||||
backpack.inventory_id,
|
||||
&items,
|
||||
)
|
||||
.await?;
|
||||
let _ = ctx.store.record_admin_command(
|
||||
"WelcomePackage.DbAddItemsToBackpack",
|
||||
&json!({
|
||||
"playerId": account.fls_id,
|
||||
"accountId": account.account_id,
|
||||
"inventoryId": backpack.inventory_id,
|
||||
"items": ctx.env.welcome_package_actions,
|
||||
"itemIds": ids,
|
||||
}),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
ctx.log_info(&format!(
|
||||
"welcome package db-confirmed player={} account_id={} inventory_id={} version={} items={} item_ids={:?}",
|
||||
account.fls_id,
|
||||
account.account_id,
|
||||
backpack.inventory_id,
|
||||
ctx.env.welcome_package_version,
|
||||
items.len(),
|
||||
ids
|
||||
))?;
|
||||
Ok(Some(backpack.character_name.unwrap_or_default()))
|
||||
}
|
||||
|
||||
async fn process_account_welcome_message(
|
||||
ctx: &TaskCtx,
|
||||
namespace: &str,
|
||||
account: &crate::postgres::WelcomeAccount,
|
||||
) -> Result<bool> {
|
||||
let message_version = format!(
|
||||
"{}{}",
|
||||
ctx.env.welcome_package_version, WELCOME_MESSAGE_VERSION_SUFFIX
|
||||
);
|
||||
let recipient = match crate::postgres::resolve_chat_player(
|
||||
&ctx.env.pg,
|
||||
namespace,
|
||||
&account.fls_id,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(recipient) => recipient,
|
||||
None => return Ok(false),
|
||||
};
|
||||
ctx.store.ensure_welcome_grant(
|
||||
&account.fls_id,
|
||||
&message_version,
|
||||
account.account_id,
|
||||
Some(&recipient.character_name),
|
||||
"",
|
||||
)?;
|
||||
let record = ctx.store.ensure_welcome_action(
|
||||
&account.fls_id,
|
||||
&message_version,
|
||||
account.account_id,
|
||||
WELCOME_MESSAGE_ACTION_INDEX,
|
||||
"welcome_message",
|
||||
)?;
|
||||
if record.status == WelcomeActionStatus::Confirmed {
|
||||
return Ok(false);
|
||||
}
|
||||
process_send_welcome_message(
|
||||
ctx,
|
||||
namespace,
|
||||
&recipient.fls_id,
|
||||
&recipient.funcom_id,
|
||||
account.account_id,
|
||||
&recipient.character_name,
|
||||
&message_version,
|
||||
WELCOME_MESSAGE_ACTION_INDEX,
|
||||
&record,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_send_welcome_message(
|
||||
ctx: &TaskCtx,
|
||||
namespace: &str,
|
||||
recipient_fls_id: &str,
|
||||
recipient_funcom_id: &str,
|
||||
account_id: i64,
|
||||
recipient_name: &str,
|
||||
package_version: &str,
|
||||
index: i64,
|
||||
record: &crate::store::WelcomeActionRecord,
|
||||
) -> Result<bool> {
|
||||
if record.published_at.is_some() {
|
||||
ctx.store.mark_welcome_action_confirmed(
|
||||
recipient_fls_id,
|
||||
package_version,
|
||||
account_id,
|
||||
index,
|
||||
)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let source_lookup = ctx.env.welcome_whisper_source_player.trim();
|
||||
let message = ctx.env.welcome_message.trim();
|
||||
if message.is_empty() {
|
||||
return Err(anyhow!("welcome message must not be empty"));
|
||||
}
|
||||
if recipient_funcom_id.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"recipient player {} does not have a Funcom chat id",
|
||||
recipient_fls_id
|
||||
));
|
||||
}
|
||||
let source_lookup = if source_lookup.is_empty() {
|
||||
recipient_fls_id
|
||||
} else {
|
||||
source_lookup
|
||||
};
|
||||
|
||||
let source = crate::postgres::resolve_chat_player(&ctx.env.pg, namespace, source_lookup)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("welcome whisper source player not found: {source_lookup}"))?;
|
||||
let result = publish_welcome_whisper(
|
||||
&ctx.env.mq,
|
||||
&source,
|
||||
recipient_fls_id,
|
||||
recipient_funcom_id,
|
||||
recipient_name,
|
||||
message,
|
||||
"welcome-whisper",
|
||||
)
|
||||
.await?;
|
||||
let _ = ctx.store.record_admin_command(
|
||||
"WelcomePackage.SendWelcomeWhisper",
|
||||
&json!({
|
||||
"sourcePlayerId": source.fls_id,
|
||||
"recipientPlayerId": recipient_fls_id,
|
||||
"recipientFuncomId": recipient_funcom_id,
|
||||
"message": message,
|
||||
}),
|
||||
result.ok,
|
||||
None,
|
||||
);
|
||||
ctx.store.mark_welcome_action_published(
|
||||
recipient_fls_id,
|
||||
package_version,
|
||||
account_id,
|
||||
index,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
ctx.store.mark_welcome_action_confirmed(
|
||||
recipient_fls_id,
|
||||
package_version,
|
||||
account_id,
|
||||
index,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn send_welcome_whisper_now(
|
||||
env: &TaskEnv,
|
||||
namespace: &str,
|
||||
source_lookup: &str,
|
||||
recipient_lookup: &str,
|
||||
message: &str,
|
||||
) -> Result<crate::admin::PublishResult> {
|
||||
let recipient = crate::postgres::resolve_chat_player(&env.pg, namespace, recipient_lookup)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("recipient player not found: {recipient_lookup}"))?;
|
||||
let source_lookup = if source_lookup.trim().is_empty() {
|
||||
recipient.fls_id.as_str()
|
||||
} else {
|
||||
source_lookup.trim()
|
||||
};
|
||||
let source = crate::postgres::resolve_chat_player(&env.pg, namespace, source_lookup)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("welcome whisper source player not found: {source_lookup}"))?;
|
||||
publish_welcome_whisper(
|
||||
&env.mq,
|
||||
&source,
|
||||
&recipient.fls_id,
|
||||
&recipient.funcom_id,
|
||||
&recipient.character_name,
|
||||
message.trim(),
|
||||
"welcome-whisper",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish_welcome_whisper(
|
||||
mq: &crate::admin::MqPublisher,
|
||||
source: &crate::postgres::ChatPlayer,
|
||||
recipient_fls_id: &str,
|
||||
recipient_funcom_id: &str,
|
||||
recipient_name: &str,
|
||||
message: &str,
|
||||
label: &str,
|
||||
) -> Result<crate::admin::PublishResult> {
|
||||
if message.trim().is_empty() {
|
||||
return Err(anyhow!("welcome message must not be empty"));
|
||||
}
|
||||
if source.fls_id.trim().is_empty() || source.funcom_id.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"welcome whisper source player has incomplete chat identity"
|
||||
));
|
||||
}
|
||||
if recipient_funcom_id.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"recipient player {} does not have a Funcom chat id",
|
||||
recipient_fls_id
|
||||
));
|
||||
}
|
||||
|
||||
let body = build_whisper_body(
|
||||
&source.funcom_id,
|
||||
recipient_funcom_id,
|
||||
recipient_name,
|
||||
message,
|
||||
)?;
|
||||
let result = mq
|
||||
.publish_whisper(recipient_funcom_id, &source.fls_id, &body, label)
|
||||
.await?;
|
||||
if !result.ok {
|
||||
return Err(anyhow!(
|
||||
"MQ publish did not report ok for welcome whisper: {}",
|
||||
result.output.trim()
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn build_whisper_body(
|
||||
sender_funcom_id: &str,
|
||||
recipient_funcom_id: &str,
|
||||
recipient_name: &str,
|
||||
message: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let message = normalize_chat_message(message);
|
||||
let chat = json!({
|
||||
"m_Id": message_guid(sender_funcom_id, recipient_funcom_id),
|
||||
"m_ChannelType": "ETextChatChannelType::Whispers",
|
||||
"m_SubChannelId": recipient_funcom_id,
|
||||
"m_bUseSpoofedUserName": false,
|
||||
"m_SpoofedUserNameFrom": {"m_Id": "", "m_DisplayName": ""},
|
||||
"m_FuncomIdFrom": sender_funcom_id,
|
||||
"m_UserNameTo": recipient_name,
|
||||
"m_Message": {
|
||||
"m_UnlocalizedMessage": message,
|
||||
"m_LocalizedMessage": {"m_TableId": "", "m_Key": "", "m_FormatArgs": []}
|
||||
},
|
||||
"m_TimeStamp": chrono::Utc::now().to_rfc3339(),
|
||||
"m_OriginLocation": {"X": 0.0, "Y": 0.0, "Z": 0.0},
|
||||
"m_HasSeenMessage": false
|
||||
});
|
||||
Ok(json!({
|
||||
"Content": serde_json::to_string(&chat)?,
|
||||
"Type": "ECourierMessageType::TextChat"
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_chat_message(message: &str) -> String {
|
||||
message
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn welcome_item_stats_json(item_name: &str, durability: f64) -> Result<String> {
|
||||
let durability_stats = if durability >= 1.0 {
|
||||
// Full durability: write no durability fields. The game treats an absent
|
||||
// entry as undamaged; an explicit DecayedMaxDurability must not be set.
|
||||
json!({})
|
||||
} else {
|
||||
json!({"CurrentDurability": durability})
|
||||
};
|
||||
let mut stats = serde_json::Map::from_iter([(
|
||||
"FItemStackAndDurabilityStats".to_string(),
|
||||
json!([[], durability_stats]),
|
||||
)]);
|
||||
if let Some(max_amount) = fillable_water_container_max_amount(item_name) {
|
||||
stats.insert(
|
||||
"FFillableItemStats".to_string(),
|
||||
json!([[], {
|
||||
"CurrentAmount": max_amount as f64,
|
||||
"MaxAmount": max_amount,
|
||||
"FillableType": "Water",
|
||||
"FillableTypeRestriction": "Water",
|
||||
"bIsContainer": true,
|
||||
}]),
|
||||
);
|
||||
} else {
|
||||
// Non-fillable items (weapons/equipment) carry an empty customization block.
|
||||
stats.insert("FCustomizationStats".to_string(), json!([[], {}]));
|
||||
}
|
||||
Ok(serde_json::to_string(&serde_json::Value::Object(stats))?)
|
||||
}
|
||||
|
||||
fn fillable_water_container_max_amount(item_name: &str) -> Option<i64> {
|
||||
match item_name.to_ascii_lowercase().as_str() {
|
||||
// Dune/Systems/Items/DT_ItemTableFillables container rows.
|
||||
"dewpack" => Some(250),
|
||||
"literjon" => Some(1000),
|
||||
"highcapacityliterjon" => Some(1500),
|
||||
"literjon_03" => Some(1100),
|
||||
"literjon_04" => Some(1200),
|
||||
"literjon_05" => Some(1300),
|
||||
"literjon_06" => Some(1400),
|
||||
"literjon_07" => Some(1500),
|
||||
"literjon_08" => Some(1600),
|
||||
"literjon_09" => Some(1700),
|
||||
"decajon" => Some(10_000),
|
||||
"literjon_t6" => Some(20_000),
|
||||
"highcapacityliterjon_02" => Some(1750),
|
||||
"highcapacityliterjon_03" => Some(2000),
|
||||
"highcapacityliterjon_04" => Some(2250),
|
||||
"highcapacityliterjon_05" => Some(2500),
|
||||
"highcapacityliterjon_06" => Some(3000),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn message_guid(sender_funcom_id: &str, recipient_funcom_id: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let nanos = chrono::Utc::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis() * 1_000_000);
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
sender_funcom_id.hash(&mut hasher);
|
||||
recipient_funcom_id.hash(&mut hasher);
|
||||
nanos.hash(&mut hasher);
|
||||
let a = nanos as u128;
|
||||
let b = hasher.finish() as u128;
|
||||
let raw = (a << 64) ^ b;
|
||||
format!(
|
||||
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
||||
(raw >> 96) as u32,
|
||||
(raw >> 80) as u16,
|
||||
(raw >> 64) as u16,
|
||||
(raw >> 48) as u16,
|
||||
raw & 0xffff_ffff_ffff
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_welcome_items_accepts_defaults() {
|
||||
let items = parse_welcome_items(r#"[{"itemName":"PlantFiber"}]"#).unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].item_name, "PlantFiber");
|
||||
assert_eq!(items[0].quantity, 1);
|
||||
assert_eq!(items[0].durability, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_welcome_actions_accepts_item_rows() {
|
||||
let actions =
|
||||
parse_welcome_actions(r#"[{"type":"grantItem","itemName":"Literjon"}]"#).unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(matches!(actions[0], WelcomePackageAction::GrantItem { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_welcome_actions_drops_legacy_non_item_actions() {
|
||||
let actions = parse_welcome_actions(
|
||||
r#"[{"type":"refillWater"},{"type":"sendWelcomeMessage"},{"type":"grantItem","itemName":"PlantFiber"}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert!(matches!(actions[0], WelcomePackageAction::GrantItem { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whisper_body_uses_localizable_message_shape() {
|
||||
let body = build_whisper_body("sender-chat", "recipient-chat", "Ada", "Welcome").unwrap();
|
||||
assert_eq!(body["Type"], "ECourierMessageType::TextChat");
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(body["Content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(content["m_ChannelType"], "ETextChatChannelType::Whispers");
|
||||
assert_eq!(content["m_SubChannelId"], "recipient-chat");
|
||||
assert_eq!(content["m_Message"]["m_UnlocalizedMessage"], "Welcome");
|
||||
assert!(content["m_Message"]["m_LocalizedMessage"]["m_FormatArgs"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whisper_body_flattens_multiline_text() {
|
||||
let body = build_whisper_body("sender-chat", "recipient-chat", "Ada", "Hello\r\n\nArrakis")
|
||||
.unwrap();
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(body["Content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
content["m_Message"]["m_UnlocalizedMessage"],
|
||||
"Hello Arrakis"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_welcome_actions_keeps_old_item_json_compatible() {
|
||||
let actions = parse_welcome_actions(r#"[{"itemName":"PlantFiber","quantity":2}]"#).unwrap();
|
||||
assert_eq!(
|
||||
actions[0],
|
||||
WelcomePackageAction::GrantItem {
|
||||
item_name: "PlantFiber".into(),
|
||||
quantity: 2,
|
||||
durability: 1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_welcome_items_rejects_bad_quantity() {
|
||||
let err = parse_welcome_items(r#"[{"itemName":"PlantFiber","quantity":0}]"#).unwrap_err();
|
||||
assert!(err.to_string().contains("quantity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literjon_stats_are_inserted_full() {
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&welcome_item_stats_json("Literjon", 1.0).unwrap()).unwrap();
|
||||
assert_eq!(stats["FFillableItemStats"][1]["CurrentAmount"], 1000.0);
|
||||
assert_eq!(stats["FFillableItemStats"][1]["MaxAmount"], 1000);
|
||||
assert_eq!(stats["FFillableItemStats"][1]["FillableType"], "Water");
|
||||
assert_eq!(
|
||||
stats["FFillableItemStats"][1]["FillableTypeRestriction"],
|
||||
"Water"
|
||||
);
|
||||
assert_eq!(stats["FFillableItemStats"][1]["bIsContainer"], true);
|
||||
// Fillable containers get no customization block.
|
||||
assert!(stats.get("FCustomizationStats").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_durability_weapon_omits_durability_fields() {
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&welcome_item_stats_json("JabalSwordUnique", 1.0).unwrap())
|
||||
.unwrap();
|
||||
// Durability entry exists but carries no fields at full durability.
|
||||
let durability = &stats["FItemStackAndDurabilityStats"][1];
|
||||
assert!(durability.is_object());
|
||||
assert!(durability.get("DecayedMaxDurability").is_none());
|
||||
assert!(durability.get("CurrentDurability").is_none());
|
||||
// A weapon is not a fillable container.
|
||||
assert!(stats.get("FFillableItemStats").is_none());
|
||||
// Non-fillable items carry an empty customization block.
|
||||
assert!(stats["FCustomizationStats"][0].as_array().unwrap().is_empty());
|
||||
assert!(stats["FCustomizationStats"][1]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_durability_weapon_sets_current_durability() {
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&welcome_item_stats_json("JabalSwordUnique", 0.5).unwrap())
|
||||
.unwrap();
|
||||
let durability = &stats["FItemStackAndDurabilityStats"][1];
|
||||
assert_eq!(durability["CurrentDurability"], 0.5);
|
||||
assert!(durability.get("DecayedMaxDurability").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decajon_stats_are_inserted_full() {
|
||||
let stats: serde_json::Value =
|
||||
serde_json::from_str(&welcome_item_stats_json("Decajon", 1.0).unwrap()).unwrap();
|
||||
assert_eq!(stats["FFillableItemStats"][1]["CurrentAmount"], 10000.0);
|
||||
assert_eq!(stats["FFillableItemStats"][1]["MaxAmount"], 10000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=Dune server management service (Rust)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/dune-server-service/dune-server-service
|
||||
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/dune/.local/bin"
|
||||
Environment="DUNE_DASHBOARD_PORT=29187"
|
||||
Environment="DUNE_SERVICE_HOME=/home/dune"
|
||||
EnvironmentFile=-/etc/dune-server-service.env
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
User=dune
|
||||
Group=dune
|
||||
|
||||
ReadWritePaths=/home/dune/.dune /home/dune/.local /home/dune/.steam /home/dune/Steam /tmp -/funcom/artifacts/database-dumps
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
NoNewPrivileges=false
|
||||
MemoryDenyWriteExecute=false
|
||||
# steamcmd's bundled 32-bit steamclient.so requires writable text relocations
|
||||
# (dlmopen -> mprotect PROT_WRITE). Keep MemoryDenyWriteExecute explicitly
|
||||
# disabled so scheduled update checks can run under systemd.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user