diff --git a/.github/workflows/_bot-deploy.yml b/.github/workflows/_bot-deploy.yml
index d766fd5e..6d86b51d 100644
--- a/.github/workflows/_bot-deploy.yml
+++ b/.github/workflows/_bot-deploy.yml
@@ -6,14 +6,20 @@ on:
test-command:
required: true
type: string
+ has-database:
+ type: boolean
+ default: true
+ description: "False skips every Flyway/migration and DB-connectivity step (e.g. Fizruk, which has no database)"
migrations-path:
- required: true
+ required: false
type: string
- description: "Path to migrations dir relative to repo root"
+ default: ""
+ description: "Path to migrations dir relative to repo root (required when has-database is true)"
db-name:
- required: true
+ required: false
type: string
- description: "Database name for Flyway migrations (e.g. vahter_db)"
+ default: ""
+ description: "Database name for Flyway migrations, e.g. vahter_db (required when has-database is true)"
docker-image:
required: true
type: string
@@ -45,11 +51,12 @@ on:
single-occupancy; sharing it across bots flaps the WG endpoint
for concurrent deploys (issue #368).
DB_PROD_HOST:
- required: true
+ required: false
+ description: "Required when has-database is true — workflow_call can't make secrets conditional, so the migration steps are the only consumers"
DB_PROD_USERNAME:
- required: true
+ required: false
DB_PROD_PASSWORD:
- required: true
+ required: false
ARGOCD_AUTH_TOKEN:
required: true
AZURE_OPENAI_API_KEY:
@@ -182,9 +189,10 @@ jobs:
WIREGUARD_CONFIG: ${{ secrets.WIREGUARD_CONFIG }}
SPLIT_TUNNEL: "false"
- # Unconditional: no-op if the last run's migrate succeeded (checksum-safe),
- # required to clear a failed CIC migration's success=false history row.
+ # No-op if the last migrate succeeded; clears a failed CIC migration's
+ # success=false row. Skipped entirely when has-database is false.
- name: Repair production DB migration history
+ if: inputs.has-database
run: >-
docker run --rm
--network host
@@ -197,6 +205,7 @@ jobs:
repair -schemas="${{ env.SCHEMAS }}"
- name: Apply migrations to production DB
+ if: inputs.has-database
run: >-
docker run --rm
--network host
@@ -213,6 +222,7 @@ jobs:
run: sudo wg-quick down wg0 || true
- name: Upload Flyway report
+ if: inputs.has-database
uses: actions/upload-artifact@v4
with:
name: Database Report
diff --git a/.github/workflows/fizruk-build.yml b/.github/workflows/fizruk-build.yml
new file mode 100644
index 00000000..6df94c8b
--- /dev/null
+++ b/.github/workflows/fizruk-build.yml
@@ -0,0 +1,14 @@
+name: "Fizruk: Build"
+
+on:
+ pull_request:
+ branches: [ "main" ]
+ workflow_dispatch:
+
+jobs:
+ build:
+ uses: ./.github/workflows/_bot-build.yml
+ with:
+ test-command: |
+ dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed"
+ bot-paths-regex: '^(src/(BotInfra|Fizruk)/|src/Dockerfile\.bot$|tests/Fizruk\.Tests/|Directory\.Build\.props$|Directory\.Packages\.props$|global\.json$)'
diff --git a/.github/workflows/fizruk-deploy.yml b/.github/workflows/fizruk-deploy.yml
new file mode 100644
index 00000000..290e05b5
--- /dev/null
+++ b/.github/workflows/fizruk-deploy.yml
@@ -0,0 +1,48 @@
+name: "Fizruk: Deploy"
+
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - 'src/BotInfra/**'
+ - '!src/BotInfra/**/*.md'
+ - 'src/Fizruk/**'
+ - '!src/Fizruk/**/*.md'
+ - 'src/Dockerfile.bot'
+ - 'tests/Fizruk.Tests/**'
+ - 'Directory.Build.props'
+ - 'Directory.Packages.props'
+ - 'global.json'
+ workflow_dispatch:
+
+permissions:
+ # contents: write (not read) because this workflow calls _bot-deploy.yml,
+ # which calls _sre-agent.yml on failure — see coupon-deploy.yml for why.
+ contents: write
+ packages: write
+ issues: write
+ pull-requests: write
+ id-token: write
+ actions: read
+
+jobs:
+ deploy:
+ uses: ./.github/workflows/_bot-deploy.yml
+ with:
+ has-database: false
+ test-command: |
+ dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx"
+ docker-image: ghcr.io/szer/fizruk
+ bot-project: Fizruk
+ argocd-app-name: fizruk
+ container-name: fizruk
+ # Off until the ArgoCD app exists in my-infra and has had a chance to settle —
+ # see other bots' alita-deploy.yml for the same new-bot convention.
+ sre-enabled: false
+ secrets:
+ # Dedicated single-occupancy peer (Szer/my-infra#111), not the shared
+ # WIREGUARD_CONFIG secret — must exist in my-infra before verify-deploy can pass.
+ WIREGUARD_CONFIG: ${{ secrets.WIREGUARD_CONFIG_FIZRUK }}
+ ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }}
+ AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
+ AZURE_OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_BASE_URL }}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index eaf32d79..5905c11f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,6 +19,9 @@
+
+
+
diff --git a/README.md b/README.md
index c7fc1428..3266ada0 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ Monorepo for F# Telegram bots deployed to Kubernetes via ArgoCD.
|-----|-------------|------|
| [VahterBanBot](src/VahterBanBot/) | Spam moderation bot for Telegram chats | [README](src/VahterBanBot/README.md) |
| [CouponHubBot](src/CouponHubBot/) | Coupon management bot for a private community | [README](src/CouponHubBot/README.md) |
+| [Fizruk](src/Fizruk/) | Starts/stops on-demand game server Deployments from Telegram | see below |
## Tech Stack
@@ -23,6 +24,7 @@ src/
BotInfra/ — shared bot infrastructure
VahterBanBot/ — VahterBanBot application
CouponHubBot/ — CouponHubBot application
+ Fizruk/ — Fizruk application
vahter-bot/ — Helm chart + DB migrations
coupon-hub-bot/ — Helm chart + DB migrations
Dockerfile.bot — shared Dockerfile
@@ -31,6 +33,7 @@ tests/
VahterBanBot.Tests/ — integration tests
CouponHubBot.Tests/ — integration tests
CouponHubBot.Ocr.Tests/ — OCR unit tests
+ Fizruk.Tests/ — hermetic unit tests (no DB, no containers)
FakeTgApi/ — fake Telegram API
FakeAzureOcrApi/ — fake Azure OCR + OpenAI API
scripts/
@@ -49,6 +52,39 @@ Each bot has thin caller workflows (`vahter-build.yml`, `coupon-build.yml`, etc.
VahterBanBot source is also synced to the [fsharplang-ru/vahter-bot](https://github.com/fsharplang-ru/vahter-bot) mirror via automated PRs.
+## Fizruk
+
+Fizruk (Russian for a PE teacher — the one who blows the whistle to start and stop
+the game) is a Telegram bot that scales an on-demand game server's Kubernetes
+Deployment up on `/start`, down on `/stop`, and back down automatically once nobody's
+playing. Unlike the other bots it has no database — all state lives in the cluster
+and in a mounted config file.
+
+- **Commands**: `/start [game]`, `/stop [game]`, `/status [game]` (optional
+ `@botname` suffix, case-insensitive). If a chat controls exactly one game the name
+ can be omitted; a chat controlling several games must name one for `/start`/`/stop`,
+ while a nameless `/status` reports on all of them.
+- **Config** (`FIZRUK_CONFIG_PATH`, a mounted ConfigMap): a `games` map (per game:
+ `displayName`, `namespace`, `deployment`, `podSelector`, `container`,
+ `nodeLabelSelector`, `address`, an optional `players` probe — `rcon` or `raknet` —,
+ `activityRegex`, `idleGraceMinutes`, `idleWindowMinutes`, `startTimeoutMinutes`) and
+ a `chats` map from Telegram chat id to the list of games that chat may control.
+- **Idle shutdown**: every 10 minutes, a game running past its `idleGraceMinutes`
+ with nobody online (player probe) and no matching activity in its recent pod logs
+ is scaled back to 0, with every controlling chat notified.
+- **Env vars**: `FIZRUK_CONFIG_PATH`, `BOT_TELEGRAM_TOKEN`, `BOT_AUTH_TOKEN`,
+ `BOT_USERNAME` (optional, for the `@botname` suffix), `TELEGRAM_API_URL`
+ (optional test override), `BOT_WEBHOOK_URL` (optional; when set, Fizruk
+ self-registers its webhook at startup via `BotInfra.WebhookRegistration` —
+ see below), and each RCON-probed game's `passwordEnv` variable (e.g.
+ `FACTORIO_RCON_PASSWORD`).
+- **Webhook self-registration**: opt-in, via `BotInfra.WebhookRegistration`
+ (any bot can adopt it). Unset/empty `BOT_WEBHOOK_URL` is a no-op. When set,
+ Fizruk calls Telegram's `setWebhook` at startup (fire-and-forget, retried a
+ few times with backoff, never crashes the pod) with that URL,
+ `secret_token=BOT_AUTH_TOKEN`, and `allowed_updates=["message"]`.
+- **GHCR image**: `ghcr.io/szer/fizruk`.
+
## License
MIT. See [LICENSE](LICENSE).
diff --git a/bots.slnx b/bots.slnx
index 6397936c..2af3c0cf 100644
--- a/bots.slnx
+++ b/bots.slnx
@@ -3,6 +3,7 @@
+
@@ -14,6 +15,7 @@
+
diff --git a/src/BotInfra/BotInfra.fsproj b/src/BotInfra/BotInfra.fsproj
index e3d4a0d1..68b03ec5 100644
--- a/src/BotInfra/BotInfra.fsproj
+++ b/src/BotInfra/BotInfra.fsproj
@@ -23,6 +23,7 @@
+
diff --git a/src/BotInfra/WebhookRegistration.fs b/src/BotInfra/WebhookRegistration.fs
new file mode 100644
index 00000000..5b8862f8
--- /dev/null
+++ b/src/BotInfra/WebhookRegistration.fs
@@ -0,0 +1,83 @@
+namespace BotInfra
+
+open System
+open System.Threading.Tasks
+open Microsoft.Extensions.Logging
+
+/// Opt-in Telegram webhook self-registration — a bot calls `registerAsync`,
+/// typically fire-and-forget from a startup hosted service (see Fizruk).
+module WebhookRegistration =
+
+ let private allowedUpdates = [| "message" |]
+
+ /// Recurses (not nested in another task CE — required for a statically
+ /// compilable state machine) until setWebhook succeeds or attempts run out.
+ let rec private attemptSetWebhook
+ (tg: ITelegramApi)
+ req
+ (webhookUrl: string)
+ (maxAttempts: int)
+ (retryDelay: TimeSpan)
+ (logger: ILogger)
+ (n: int)
+ : Task =
+ task {
+ try
+ match! tg.Call req with
+ | Ok _ -> logger.LogInformation("Webhook registered: {Url}", webhookUrl)
+ | Error e ->
+ logger.LogWarning(
+ "setWebhook failed (attempt {Attempt}/{Max}): {Code} {Description}",
+ n, maxAttempts, e.ErrorCode, e.Description)
+ if n < maxAttempts then
+ do! Task.Delay retryDelay
+ do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger (n + 1)
+ else
+ logger.LogError("setWebhook giving up after {Max} attempts", maxAttempts)
+ with ex ->
+ logger.LogWarning(ex, "setWebhook threw (attempt {Attempt}/{Max})", n, maxAttempts)
+ if n < maxAttempts then
+ do! Task.Delay retryDelay
+ do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger (n + 1)
+ else
+ logger.LogError(ex, "setWebhook giving up after {Max} attempts", maxAttempts)
+ }
+ :> Task
+
+ /// getWebhookInfo can't reveal the secret token, so setWebhook always runs
+ /// (idempotent) when `webhookUrl` is set; getWebhookInfo only logs the before-state.
+ let registerAsync
+ (tg: ITelegramApi)
+ (webhookUrl: string)
+ (secretToken: string)
+ (maxAttempts: int)
+ (retryDelay: TimeSpan)
+ (logger: ILogger)
+ : Task =
+ task {
+ if String.IsNullOrWhiteSpace webhookUrl then
+ logger.LogInformation "webhook self-registration disabled"
+ else
+ try
+ match! tg.Call(Funogram.Telegram.Req.GetWebhookInfo.Make()) with
+ | Ok info ->
+ logger.LogInformation(
+ "Webhook before self-registration: url={Url} pendingUpdateCount={Pending}",
+ info.Url, info.PendingUpdateCount)
+ | Error e ->
+ logger.LogWarning(
+ "getWebhookInfo failed (logging before-state only): {Code} {Description}",
+ e.ErrorCode, e.Description)
+ with ex ->
+ logger.LogWarning(ex, "getWebhookInfo threw (logging before-state only)")
+
+ let req =
+ Funogram.Telegram.Req.SetWebhook.Make(
+ webhookUrl,
+ allowedUpdates = allowedUpdates,
+ dropPendingUpdates = false,
+ secretToken = secretToken)
+
+ do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger 1
+ }
+ :> Task
diff --git a/src/Fizruk/BotFrontend.fs b/src/Fizruk/BotFrontend.fs
new file mode 100644
index 00000000..e7d3ec2d
--- /dev/null
+++ b/src/Fizruk/BotFrontend.fs
@@ -0,0 +1,57 @@
+namespace Fizruk
+
+open System
+open System.Threading.Tasks
+open Funogram.Telegram.Types
+open Microsoft.Extensions.Logging
+open BotInfra
+
+/// Thin Telegram frontend: parse -> resolve -> call GameCore -> reply. Game
+/// semantics live in GameCore/Commands so a second frontend can reuse them.
+type FizrukBotService(config: FizrukConfig, core: GameCore, tg: ITelegramApi, botUsername: string option, logger: ILogger) =
+
+ let sendText (chatId: int64) (text: string) =
+ tg.CallIgnore(Funogram.Telegram.Req.SendMessage.Make(chatId, text))
+
+ let allowedGamesText (allowed: string list) = "This chat controls: " + String.Join(", ", allowed)
+
+ member _.Handle(chatId: int64, action: BotAction, gameArg: string option) : Task =
+ task {
+ Metrics.commandTotal.Add(
+ 1L,
+ Collections.Generic.KeyValuePair("action", box (string action)),
+ Collections.Generic.KeyValuePair("game", box (gameArg |> Option.defaultValue "")))
+
+ match Commands.resolveGame config.Chats action chatId gameArg with
+ | Commands.Resolution.Unauthorized -> ()
+ | Commands.Resolution.Unknown allowed -> do! sendText chatId (allowedGamesText allowed)
+ | Commands.Resolution.Ambiguous allowed -> do! sendText chatId ("Which game? " + allowedGamesText allowed)
+ | Commands.Resolution.AllGames allowed ->
+ let! texts = allowed |> List.map core.Status |> Task.WhenAll
+ do! sendText chatId (String.Join("\n\n", texts))
+ | Commands.Resolution.Resolved gameId ->
+ let! reply =
+ match action with
+ | BotAction.Start -> core.Start gameId
+ | BotAction.Stop -> core.Stop gameId
+ | BotAction.Status -> core.Status gameId
+ do! sendText chatId reply
+ }
+
+ /// Only messages from a chat id present in `chats` are handled; everything else
+ /// (unknown chats, non-commands, malformed updates) is ignored silently.
+ member this.OnUpdate(update: Update) : Task =
+ task {
+ match update.Message |> Option.bind (fun m -> m.Text |> Option.map (fun t -> m, t)) with
+ | None -> ()
+ | Some(msg, text) ->
+ let chatId = msg.Chat.Id
+ if config.Chats.ContainsKey chatId then
+ match Commands.tryParse botUsername text with
+ | None -> ()
+ | Some cmd ->
+ try
+ do! this.Handle(chatId, cmd.Action, cmd.GameArg)
+ with ex ->
+ logger.LogError(ex, "Fizruk: error handling command in chat {ChatId}", chatId)
+ }
diff --git a/src/Fizruk/Commands.fs b/src/Fizruk/Commands.fs
new file mode 100644
index 00000000..37fdc9d4
--- /dev/null
+++ b/src/Fizruk/Commands.fs
@@ -0,0 +1,85 @@
+namespace Fizruk
+
+open System
+
+[]
+type BotAction =
+ | Start
+ | Stop
+ | Status
+
+type ParsedCommand =
+ { Action: BotAction
+ GameArg: string option }
+
+/// Pure Telegram-command parsing and game-ACL resolution — no Funogram, no Task,
+/// so both are directly unit-testable and reusable by any future frontend.
+module Commands =
+
+ /// Strips a `@botname` suffix from a command word, case-insensitively. None
+ /// means "addressed to a different bot"; a word with no `@` passes through as-is.
+ let private stripBotSuffix (botUsername: string option) (word: string) : string option =
+ match word.IndexOf '@' with
+ | -1 -> Some word
+ | i ->
+ let cmd = word.Substring(0, i)
+ let addressedTo = word.Substring(i + 1)
+ match botUsername with
+ | Some u when String.Equals(addressedTo, u, StringComparison.OrdinalIgnoreCase) -> Some cmd
+ | _ -> None
+
+ /// Parses raw message text into a command + optional game-name argument. None
+ /// for non-commands, unrecognized commands, or a different bot's `@botname`.
+ let tryParse (botUsername: string option) (text: string) : ParsedCommand option =
+ if String.IsNullOrWhiteSpace text || text.[0] <> '/' then
+ None
+ else
+ let parts = text.Split([| ' '; '\t'; '\n' |], StringSplitOptions.RemoveEmptyEntries)
+ if parts.Length = 0 then
+ None
+ else
+ match stripBotSuffix botUsername parts.[0] with
+ | None -> None
+ | Some cmdWord ->
+ let action =
+ match cmdWord.ToLowerInvariant() with
+ | "/start" -> Some BotAction.Start
+ | "/stop" -> Some BotAction.Stop
+ | "/status" -> Some BotAction.Status
+ | _ -> None
+ action
+ |> Option.map (fun a ->
+ { Action = a
+ GameArg = if parts.Length > 1 then Some parts.[1] else None })
+
+ []
+ type Resolution =
+ /// Chat id isn't in the config's `chats` map at all.
+ | Unauthorized
+ /// An explicit game name that isn't in this chat's allowed list.
+ | Unknown of allowed: string list
+ /// No name given, this chat controls more than one game, and the action needs one.
+ | Ambiguous of allowed: string list
+ /// No name given for /status with more than one allowed game: report all of them.
+ | AllGames of allowed: string list
+ | Resolved of gameId: string
+
+ /// Resolves a command's target game against the chat's ACL. The single-game-chat
+ /// shortcut (no name needed) applies to every action, including /status.
+ let resolveGame (chats: Map) (action: BotAction) (chatId: int64) (gameArg: string option) : Resolution =
+ match chats.TryFind chatId with
+ | None -> Resolution.Unauthorized
+ | Some allowed ->
+ match gameArg with
+ | Some name ->
+ // Case-insensitive match; the reply uses the configured spelling, not the user's.
+ match allowed |> List.tryFind (fun g -> String.Equals(g, name, StringComparison.OrdinalIgnoreCase)) with
+ | Some configuredName -> Resolution.Resolved configuredName
+ | None -> Resolution.Unknown allowed
+ | None ->
+ match allowed with
+ | [ only ] -> Resolution.Resolved only
+ | _ ->
+ match action with
+ | BotAction.Status -> Resolution.AllGames allowed
+ | BotAction.Start | BotAction.Stop -> Resolution.Ambiguous allowed
diff --git a/src/Fizruk/Config.fs b/src/Fizruk/Config.fs
new file mode 100644
index 00000000..7d5c9ab0
--- /dev/null
+++ b/src/Fizruk/Config.fs
@@ -0,0 +1,142 @@
+namespace Fizruk
+
+open System
+open System.Text.Json
+open System.Text.RegularExpressions
+
+/// How to reach a game server's player list. `None` means no live probe is configured.
+[]
+type ProbeType =
+ | None
+ | Rcon
+ | RakNet
+
+type PlayersConfig =
+ { Type: ProbeType
+ Host: string
+ Port: int
+ /// Env var holding the RCON password. Set only when Type = Rcon.
+ PasswordEnv: string option }
+
+type GameConfig =
+ { Id: string
+ DisplayName: string
+ Namespace: string
+ Deployment: string
+ PodSelector: string
+ Container: string
+ NodeLabelSelector: string
+ Address: string
+ Players: PlayersConfig
+ ActivityRegex: Regex
+ IdleGraceMinutes: int
+ IdleWindowMinutes: int
+ StartTimeoutMinutes: int }
+
+type FizrukConfig =
+ { Games: Map
+ Chats: Map }
+
+/// Raised for any config file/schema problem. Program.fs lets this crash startup.
+exception ConfigError of string
+
+module Config =
+
+ let noProbe =
+ { Type = ProbeType.None; Host = ""; Port = 0; PasswordEnv = None }
+
+ let private prop (el: JsonElement) (name: string) : JsonElement option =
+ match el.TryGetProperty name with
+ | true, v -> Some v
+ | false, _ -> None
+
+ let private requireProp (el: JsonElement) (ctx: string) (name: string) : JsonElement =
+ match prop el name with
+ | Some v -> v
+ | None -> raise (ConfigError $"{ctx}: missing required field '{name}'")
+
+ let private requireString (el: JsonElement) (ctx: string) (name: string) : string =
+ (requireProp el ctx name).GetString()
+
+ let private requireInt (el: JsonElement) (ctx: string) (name: string) : int =
+ (requireProp el ctx name).GetInt32()
+
+ let private parsePlayers (ctx: string) (playersEl: JsonElement option) : PlayersConfig =
+ match playersEl with
+ | None -> noProbe
+ | Some el ->
+ match (requireString el ctx "type").ToLowerInvariant() with
+ | "none" -> noProbe
+ | "rcon" ->
+ let passwordEnv =
+ match prop el "passwordEnv" with
+ | Some v -> v.GetString()
+ | None -> raise (ConfigError $"{ctx}: players.passwordEnv is required when type=rcon")
+ { Type = ProbeType.Rcon
+ Host = requireString el ctx "host"
+ Port = requireInt el ctx "port"
+ PasswordEnv = Some passwordEnv }
+ | "raknet" ->
+ { Type = ProbeType.RakNet
+ Host = requireString el ctx "host"
+ Port = requireInt el ctx "port"
+ PasswordEnv = None }
+ | other -> raise (ConfigError $"{ctx}: unknown players.type '{other}'")
+
+ let private parseGame (id: string) (el: JsonElement) : GameConfig =
+ let ctx = $"games.{id}"
+ let regexStr = requireString el ctx "activityRegex"
+ let regex =
+ try Regex(regexStr, RegexOptions.Compiled)
+ with :? ArgumentException as ex ->
+ raise (ConfigError $"{ctx}: invalid activityRegex '{regexStr}': {ex.Message}")
+ { Id = id
+ DisplayName = requireString el ctx "displayName"
+ Namespace = requireString el ctx "namespace"
+ Deployment = requireString el ctx "deployment"
+ PodSelector = requireString el ctx "podSelector"
+ Container = requireString el ctx "container"
+ NodeLabelSelector = requireString el ctx "nodeLabelSelector"
+ Address = requireString el ctx "address"
+ Players = parsePlayers ctx (prop el "players")
+ ActivityRegex = regex
+ IdleGraceMinutes = requireInt el ctx "idleGraceMinutes"
+ IdleWindowMinutes = requireInt el ctx "idleWindowMinutes"
+ StartTimeoutMinutes = requireInt el ctx "startTimeoutMinutes" }
+
+ let private parseChats (games: Map) (el: JsonElement) : Map =
+ el.EnumerateObject()
+ |> Seq.map (fun p ->
+ let chatId =
+ match Int64.TryParse p.Name with
+ | true, v -> v
+ | false, _ -> raise (ConfigError $"chats: invalid chat id '{p.Name}'")
+ let gameIds =
+ p.Value.EnumerateArray() |> Seq.map (fun g -> g.GetString()) |> List.ofSeq
+ for gid in gameIds do
+ if not (games.ContainsKey gid) then
+ raise (ConfigError $"chats.{p.Name}: references unknown game '{gid}'")
+ chatId, gameIds)
+ |> Map.ofSeq
+
+ /// Parses and validates the full Fizruk config document. Raises ConfigError on any
+ /// schema violation, unknown game reference, or invalid regex.
+ let parse (json: string) : FizrukConfig =
+ try
+ use doc = JsonDocument.Parse json
+ let root = doc.RootElement
+ let games =
+ (requireProp root "$" "games").EnumerateObject()
+ |> Seq.map (fun p -> p.Name, parseGame p.Name p.Value)
+ |> Map.ofSeq
+ let chats = parseChats games (requireProp root "$" "chats")
+ { Games = games; Chats = chats }
+ with
+ | ConfigError _ as ex -> raise ex
+ | ex -> raise (ConfigError $"failed to parse config: {ex.Message}")
+
+ let loadFromFile (path: string) : FizrukConfig =
+ let json =
+ try IO.File.ReadAllText path
+ with ex -> raise (ConfigError $"failed to read config file '{path}': {ex.Message}")
+ parse json
diff --git a/src/Fizruk/Core.fs b/src/Fizruk/Core.fs
new file mode 100644
index 00000000..65c78589
--- /dev/null
+++ b/src/Fizruk/Core.fs
@@ -0,0 +1,276 @@
+namespace Fizruk
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+open System.Threading.Tasks
+open Microsoft.Extensions.Logging
+open BotInfra
+
+/// Renders GameCore's user-facing text. Kept separate from the k8s/network calls
+/// above so the wording can be read and tested at a glance.
+module private StatusText =
+
+ let formatNodeLine (node: NodeInfo option) (now: DateTimeOffset) =
+ match node with
+ | None -> "Node: unknown."
+ | Some n ->
+ let readyText = if n.Ready then "Ready" else "NotReady"
+ let ageText =
+ match n.CreationTimestamp with
+ | Some ts -> $"{int (now - ts).TotalMinutes}m"
+ | None -> "unknown"
+ $"Node: {n.Name} ({readyText}, age {ageText})"
+
+ let formatPlayersLine (players: PlayersProbeResult) =
+ match players with
+ | PlayersProbeResult.Ok(PlayersInfo.Names []) -> "Players: nobody online."
+ | PlayersProbeResult.Ok(PlayersInfo.Names names) -> $"""Players: {String.Join(", ", names)}."""
+ | PlayersProbeResult.Ok(PlayersInfo.Count 0) -> "Players: nobody online."
+ | PlayersProbeResult.Ok(PlayersInfo.Count n) -> $"Players: {n} online."
+ | PlayersProbeResult.Error msg -> $"Players: unknown ({msg})."
+ | PlayersProbeResult.NotConfigured -> "Players: unknown."
+
+ let formatStatus (game: GameConfig) (desired: int) (pod: PodInfo option) (node: NodeInfo option) (players: PlayersProbeResult) (now: DateTimeOffset) : string =
+ let prefix = game.DisplayName
+ if desired = 0 then
+ $"{prefix}: stopped."
+ else
+ match pod with
+ | None -> $"{prefix}: starting (pod pending)."
+ | Some p when not p.Ready -> $"{prefix}: starting (pod {p.Phase})."
+ | Some _ ->
+ [ $"{prefix}: running."
+ formatNodeLine node now
+ formatPlayersLine players
+ $"Address: {game.Address}" ]
+ |> String.concat "\n"
+
+ /// "who was online at shutdown" summary for the /stop reply.
+ let formatPlayersAtShutdown (players: PlayersProbeResult) : string =
+ match players with
+ | PlayersProbeResult.Ok(PlayersInfo.Names []) -> "nobody"
+ | PlayersProbeResult.Ok(PlayersInfo.Names names) -> String.Join(", ", names)
+ | PlayersProbeResult.Ok(PlayersInfo.Count 0) -> "nobody"
+ | PlayersProbeResult.Ok(PlayersInfo.Count n) -> string n
+ | PlayersProbeResult.Error _
+ | PlayersProbeResult.NotConfigured -> "unknown"
+
+ let formatUpFor (ageMinutes: int option) : string =
+ match ageMinutes with
+ | Some m -> $"{m / 60}h {m % 60}m"
+ | None -> "unknown"
+
+/// One game's runtime behaviour: state reads, start/stop, the start watcher, and the
+/// idle check, serialised per game so a double /start or /stop is a safe no-op.
+type GameCore(config: FizrukConfig, k8s: IK8sGateway, notifier: INotifier, time: TimeProvider, pollInterval: TimeSpan, logger: ILogger) =
+
+ let locks = ConcurrentDictionary()
+ let watching = ConcurrentDictionary()
+
+ let lockFor gameId = locks.GetOrAdd(gameId, fun _ -> new SemaphoreSlim(1, 1))
+
+ let gameConfig (gameId: string) = config.Games.[gameId]
+
+ let chatsFor (gameId: string) =
+ config.Chats
+ |> Map.toList
+ |> List.filter (fun (_, games) -> List.contains gameId games)
+ |> List.map fst
+
+ let notify (gameId: string) (text: string) =
+ task {
+ match chatsFor gameId with
+ | [] -> ()
+ | chats -> do! notifier.NotifyChats(chats, text)
+ }
+
+ let podAgeMinutes (pod: PodInfo) =
+ pod.StartTime |> Option.map (fun st -> int (time.GetUtcNow() - st).TotalMinutes)
+
+ member _.GetState(gameId: string) : Task =
+ task {
+ let game = gameConfig gameId
+ let! desired = k8s.GetDesiredReplicas(game.Namespace, game.Deployment)
+ let! pods = k8s.ListPods(game.Namespace, game.PodSelector)
+ let pod = pods |> List.filter (fun p -> not p.Terminating) |> List.tryHead
+ let! node =
+ match pod |> Option.bind (fun p -> p.NodeName) with
+ | None -> Task.FromResult None
+ | Some nodeName ->
+ task {
+ let! nodes = k8s.ListNodes game.NodeLabelSelector
+ return nodes |> List.tryFind (fun n -> n.Name = nodeName)
+ }
+ // Player probes only run against a Ready pod — otherwise there's nothing
+ // listening on the game port yet, and NotConfigured doubles as "not probed".
+ let! players =
+ match pod with
+ | Some p when p.Ready ->
+ task {
+ try return! PlayerProbe.probe game
+ with ex -> return PlayersProbeResult.Error ex.Message
+ }
+ | _ -> Task.FromResult PlayersProbeResult.NotConfigured
+ return desired, pod, node, players
+ }
+
+ member this.Status(gameId: string) : Task =
+ task {
+ let game = gameConfig gameId
+ let! desired, pod, node, players = this.GetState gameId
+ return StatusText.formatStatus game desired pod node players (time.GetUtcNow())
+ }
+
+ member this.Start(gameId: string) : Task =
+ task {
+ let sem = lockFor gameId
+ do! sem.WaitAsync()
+ try
+ let game = gameConfig gameId
+ let! desired = k8s.GetDesiredReplicas(game.Namespace, game.Deployment)
+ if desired > 0 then
+ let! pods = k8s.ListPods(game.Namespace, game.PodSelector)
+ let ready = pods |> List.exists (fun p -> not p.Terminating && p.Ready)
+ if ready then
+ return "Already running."
+ else
+ // Already scaled up from an earlier /start (or a pod restart
+ // wiped the in-memory watcher) — make sure one is still running.
+ this.EnsureWatcher gameId
+ return "Already starting."
+ else
+ do! k8s.ScaleDeployment(game.Namespace, game.Deployment, 1)
+ this.EnsureWatcher gameId
+ return $"Starting {game.DisplayName}, the node takes a few minutes. I'll post here once it's ready."
+ finally
+ %sem.Release()
+ }
+
+ member _.Stop(gameId: string) : Task =
+ task {
+ let sem = lockFor gameId
+ do! sem.WaitAsync()
+ try
+ let game = gameConfig gameId
+ let! desired = k8s.GetDesiredReplicas(game.Namespace, game.Deployment)
+ if desired = 0 then
+ return "Already stopped."
+ else
+ let! players =
+ task {
+ try return! PlayerProbe.probe game
+ with ex -> return PlayersProbeResult.Error ex.Message
+ }
+ do! k8s.ScaleDeployment(game.Namespace, game.Deployment, 0)
+ return $"Stopping {game.DisplayName}. Players online at shutdown: {StatusText.formatPlayersAtShutdown players}."
+ finally
+ %sem.Release()
+ }
+
+ /// Starts the (single, deduplicated) start-timeout watcher for a game — a no-op
+ /// if one is already running.
+ member private this.EnsureWatcher(gameId: string) : unit =
+ if watching.TryAdd(gameId, true) then
+ fireAndForget logger "fizruk.watch_start" (fun () ->
+ task {
+ try do! this.RunWatcher gameId
+ finally watching.TryRemove(gameId) |> ignore
+ } :> Task)
+
+ /// Re-arms a start watcher for every game already scaled up but not yet Ready —
+ /// call once at startup so a mid-start pod restart still gets its notification.
+ member this.ArmPendingWatchers() : Task =
+ task {
+ for gameId in config.Games.Keys do
+ let! desired, pod, _node, _players = this.GetState gameId
+ let podReady = pod |> Option.map (fun p -> p.Ready) |> Option.defaultValue false
+ if desired > 0 && not podReady then
+ this.EnsureWatcher gameId
+ }
+
+ member private this.RunWatcher(gameId: string) : Task =
+ task {
+ let game = gameConfig gameId
+ let totalSeconds = game.StartTimeoutMinutes * 60
+ let intervalSeconds = max 1 (int pollInterval.TotalSeconds)
+ let maxIterations = (totalSeconds + intervalSeconds - 1) / intervalSeconds
+ let mutable finished = false
+ let mutable i = 0
+ while not finished && i < maxIterations do
+ do! Task.Delay pollInterval
+ i <- i + 1
+ // Replicas-dropped is checked before pod-ready: a manual /stop during
+ // startup must win over a pod that happens to turn Ready moments later.
+ let! desired, pod, _node, _players = this.GetState gameId
+ if desired = 0 then
+ do! notify gameId $"Start cancelled, {game.DisplayName} stopped"
+ finished <- true
+ else
+ match pod with
+ | Some p when p.Ready ->
+ do! notify gameId $"{game.DisplayName} ready at {game.Address}"
+ finished <- true
+ | _ -> ()
+ if not finished then
+ do! notify gameId $"{game.DisplayName} did not become ready within {game.StartTimeoutMinutes} minutes, check /status."
+ } :> Task
+
+ /// One idle-check pass for a single game: pure IdleDecision.decide fed with a
+ /// fresh player probe and a window of recent activity-log lines.
+ member _.CheckIdle(gameId: string) : Task =
+ task {
+ let sem = lockFor gameId
+ do! sem.WaitAsync()
+ try
+ let game = gameConfig gameId
+ let! desired = k8s.GetDesiredReplicas(game.Namespace, game.Deployment)
+ let! pods = k8s.ListPods(game.Namespace, game.PodSelector)
+ let pod = pods |> List.filter (fun p -> not p.Terminating) |> List.tryHead
+ let podReady = pod |> Option.map (fun p -> p.Ready) |> Option.defaultValue false
+ let ageMinutes = pod |> Option.bind podAgeMinutes
+
+ let! playersProbe =
+ if podReady then
+ task {
+ try return! PlayerProbe.probe game
+ with ex -> return PlayersProbeResult.Error ex.Message
+ }
+ else
+ Task.FromResult PlayersProbeResult.NotConfigured
+ let playersCount =
+ match playersProbe with
+ | PlayersProbeResult.Ok info -> IdleDecision.PlayersCount.Ok(PlayersInfo.count info)
+ | PlayersProbeResult.Error e -> IdleDecision.PlayersCount.Error e
+ | PlayersProbeResult.NotConfigured -> IdleDecision.PlayersCount.NotConfigured
+
+ let! activity =
+ match pod with
+ | Some p when podReady ->
+ task {
+ try
+ let! log = k8s.GetPodLog(game.Namespace, p.Name, game.Container, game.IdleWindowMinutes * 60)
+ let events =
+ log.Split '\n' |> Array.filter game.ActivityRegex.IsMatch |> List.ofArray
+ return IdleDecision.Activity.Ok events
+ with ex -> return IdleDecision.Activity.Error ex.Message
+ }
+ | _ -> Task.FromResult(IdleDecision.Activity.Ok [])
+
+ match IdleDecision.decide desired podReady ageMinutes game.IdleGraceMinutes playersCount activity with
+ | IdleDecision.Keep reason ->
+ logger.LogDebug("Fizruk idle-check: keeping {Game} ({Reason})", gameId, reason)
+ | IdleDecision.Stop ->
+ do! k8s.ScaleDeployment(game.Namespace, game.Deployment, 0)
+ Metrics.idleStopTotal.Add(1L, Collections.Generic.KeyValuePair("game", box gameId))
+ do! notify gameId $"Stopped {game.DisplayName}: nobody online for the last {game.IdleWindowMinutes} min (was up {StatusText.formatUpFor ageMinutes})."
+ finally
+ %sem.Release()
+ }
+
+ member this.CheckAllIdle() : Task =
+ task {
+ for gameId in config.Games.Keys do
+ try do! this.CheckIdle gameId
+ with ex -> logger.LogError(ex, "Fizruk idle-check failed for {Game}", gameId)
+ }
diff --git a/src/Fizruk/Domain.fs b/src/Fizruk/Domain.fs
new file mode 100644
index 00000000..a695876a
--- /dev/null
+++ b/src/Fizruk/Domain.fs
@@ -0,0 +1,37 @@
+namespace Fizruk
+
+open System
+
+/// The first non-terminating pod matching a game's podSelector.
+type PodInfo =
+ { Name: string
+ Phase: string
+ Ready: bool
+ Terminating: bool
+ StartTime: DateTimeOffset option
+ NodeName: string option }
+
+type NodeInfo =
+ { Name: string
+ Ready: bool
+ CreationTimestamp: DateTimeOffset option }
+
+/// What a player probe found: named players (RCON) or just a headcount (RakNet).
+[]
+type PlayersInfo =
+ | Names of string list
+ | Count of int
+
+module PlayersInfo =
+ let count (info: PlayersInfo) : int =
+ match info with
+ | PlayersInfo.Names names -> List.length names
+ | PlayersInfo.Count n -> n
+
+/// Outcome of probing a game's player list. NotConfigured covers both "no players
+/// block in config" and "not probed because the pod isn't Ready yet".
+[]
+type PlayersProbeResult =
+ | Ok of PlayersInfo
+ | Error of string
+ | NotConfigured
diff --git a/src/Fizruk/Fizruk.fsproj b/src/Fizruk/Fizruk.fsproj
new file mode 100644
index 00000000..504090c0
--- /dev/null
+++ b/src/Fizruk/Fizruk.fsproj
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Fizruk/IdleCheckHostedService.fs b/src/Fizruk/IdleCheckHostedService.fs
new file mode 100644
index 00000000..1c559396
--- /dev/null
+++ b/src/Fizruk/IdleCheckHostedService.fs
@@ -0,0 +1,27 @@
+namespace Fizruk
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open Microsoft.Extensions.Hosting
+open Microsoft.Extensions.Logging
+
+/// Ticks GameCore.CheckAllIdle every 10 minutes. Fizruk has no database, so this is
+/// a plain hosted service rather than BotInfra.ScheduledJobs (which needs one).
+type IdleCheckHostedService(core: GameCore, time: TimeProvider, logger: ILogger) =
+ inherit BackgroundService()
+
+ override _.ExecuteAsync(ct: CancellationToken) =
+ task {
+ // A pod restart mid-start must still produce the "ready"/timeout
+ // notification, so re-arm watchers for every game left starting.
+ try do! core.ArmPendingWatchers()
+ with ex -> logger.LogError(ex, "Fizruk: failed to arm pending start watchers at startup")
+
+ use timer = new PeriodicTimer(TimeSpan.FromMinutes 10.0, time)
+ while! timer.WaitForNextTickAsync ct do
+ if not ct.IsCancellationRequested then
+ try do! core.CheckAllIdle()
+ with ex -> logger.LogError(ex, "Fizruk idle-check tick failed")
+ }
+ :> Task
diff --git a/src/Fizruk/IdleDecision.fs b/src/Fizruk/IdleDecision.fs
new file mode 100644
index 00000000..a4f162da
--- /dev/null
+++ b/src/Fizruk/IdleDecision.fs
@@ -0,0 +1,49 @@
+namespace Fizruk
+
+/// Pure idle-shutdown policy, deliberately independent of Domain/Config so it's
+/// trivial to table-test.
+module IdleDecision =
+
+ []
+ type PlayersCount =
+ | Ok of int
+ | Error of string
+ | NotConfigured
+
+ []
+ type Activity =
+ | Ok of string list
+ | Error of string
+
+ type Decision =
+ | Keep of reason: string
+ | Stop
+
+ /// Decides whether a running game should be scaled to 0. Rules are checked in
+ /// order and the first match wins — see each branch's comment for why it's Keep.
+ let decide
+ (desiredReplicas: int)
+ (podReady: bool)
+ (podAgeMinutes: int option)
+ (idleGraceMinutes: int)
+ (players: PlayersCount)
+ (activity: Activity)
+ : Decision =
+ if desiredReplicas = 0 then
+ Keep "already stopped"
+ elif not podReady then
+ Keep "pod not ready"
+ else
+ match podAgeMinutes with
+ | None -> Keep "pod age unknown"
+ | Some age when age < idleGraceMinutes -> Keep "within start grace period"
+ | Some _ ->
+ match players with
+ | PlayersCount.Error msg -> Keep $"player probe error: {msg}"
+ | PlayersCount.Ok n when n > 0 -> Keep $"{n} player(s) online"
+ | PlayersCount.Ok _
+ | PlayersCount.NotConfigured ->
+ match activity with
+ | Activity.Error msg -> Keep $"activity log error: {msg}"
+ | Activity.Ok events when not (List.isEmpty events) -> Keep "recent activity"
+ | Activity.Ok _ -> Stop
diff --git a/src/Fizruk/InMemoryK8sGateway.fs b/src/Fizruk/InMemoryK8sGateway.fs
new file mode 100644
index 00000000..4e2cafd1
--- /dev/null
+++ b/src/Fizruk/InMemoryK8sGateway.fs
@@ -0,0 +1,13 @@
+namespace Fizruk
+
+open System.Threading.Tasks
+
+/// Fake IK8sGateway with no cluster dependency: every deployment reads as
+/// desired=0/no pods/no nodes. Selected via FIZRUK_FAKE_K8S=true for container tests.
+type InMemoryK8sGateway() =
+ interface IK8sGateway with
+ member _.GetDesiredReplicas(_ns, _deployment) = Task.FromResult 0
+ member _.ScaleDeployment(_ns, _deployment, _replicas) = Task.FromResult()
+ member _.ListPods(_ns, _labelSelector) = Task.FromResult []
+ member _.GetPodLog(_ns, _podName, _container, _sinceSeconds) = Task.FromResult ""
+ member _.ListNodes(_labelSelector) = Task.FromResult []
diff --git a/src/Fizruk/K8sGateway.fs b/src/Fizruk/K8sGateway.fs
new file mode 100644
index 00000000..92c185d4
--- /dev/null
+++ b/src/Fizruk/K8sGateway.fs
@@ -0,0 +1,83 @@
+namespace Fizruk
+
+open System
+open System.Collections.Generic
+open System.IO
+open System.Threading.Tasks
+open k8s
+open k8s.Models
+
+/// Seam over the Kubernetes API so GameCore is testable with a fake. Every member is
+/// the minimal read/patch Fizruk needs — no watches, no generic dynamic client.
+type IK8sGateway =
+ abstract GetDesiredReplicas: ns: string * deployment: string -> Task
+ abstract ScaleDeployment: ns: string * deployment: string * replicas: int -> Task
+ abstract ListPods: ns: string * labelSelector: string -> Task
+ abstract GetPodLog: ns: string * podName: string * container: string * sinceSeconds: int -> Task
+ abstract ListNodes: labelSelector: string -> Task
+
+/// K8sGateway-internal helpers, exposed for unit testing without a cluster.
+module K8sGateway =
+
+ /// DateTimeOffset from a k8s-deserialized DateTime, treating Unspecified Kind
+ /// as UTC (as k8s API timestamps always are) instead of the local offset.
+ let asUtcOffset (dt: DateTime) : DateTimeOffset =
+ let utc = if dt.Kind = DateTimeKind.Unspecified then DateTime.SpecifyKind(dt, DateTimeKind.Utc) else dt
+ DateTimeOffset utc
+
+/// Real implementation backed by the official KubernetesClient, using in-cluster config.
+type KubernetesGateway(client: Kubernetes) =
+
+ let isReady (conditions: IList) =
+ not (isNull conditions) && conditions |> Seq.exists (fun c -> c.Type = "Ready" && c.Status = "True")
+
+ let isNodeReady (conditions: IList) =
+ not (isNull conditions) && conditions |> Seq.exists (fun c -> c.Type = "Ready" && c.Status = "True")
+
+ interface IK8sGateway with
+ member _.GetDesiredReplicas(ns, deployment) =
+ task {
+ let! d = client.ReadNamespacedDeploymentAsync(deployment, ns)
+ return d.Spec.Replicas |> Option.ofNullable |> Option.defaultValue 0
+ }
+
+ member _.ScaleDeployment(ns, deployment, replicas) =
+ task {
+ let patch = V1Patch({| spec = {| replicas = replicas |} |}, V1Patch.PatchType.MergePatch)
+ let! _ = client.PatchNamespacedDeploymentScaleAsync(patch, deployment, ns)
+ return ()
+ }
+
+ member _.ListPods(ns, labelSelector) =
+ task {
+ let! list = client.ListNamespacedPodAsync(ns, labelSelector = labelSelector)
+ return
+ list.Items
+ |> Seq.map (fun p ->
+ { Name = p.Metadata.Name
+ Phase = p.Status.Phase
+ Ready = isReady p.Status.Conditions
+ Terminating = p.Metadata.DeletionTimestamp.HasValue
+ StartTime = p.Status.StartTime |> Option.ofNullable |> Option.map K8sGateway.asUtcOffset
+ NodeName = p.Spec.NodeName |> Option.ofObj })
+ |> List.ofSeq
+ }
+
+ member _.GetPodLog(ns, podName, container, sinceSeconds) =
+ task {
+ use! stream = client.ReadNamespacedPodLogAsync(podName, ns, container = container, sinceSeconds = sinceSeconds)
+ use reader = new StreamReader(stream)
+ return! reader.ReadToEndAsync()
+ }
+
+ member _.ListNodes(labelSelector) =
+ task {
+ let! list = client.ListNodeAsync(labelSelector = labelSelector)
+ return
+ list.Items
+ |> Seq.map (fun n ->
+ { Name = n.Metadata.Name
+ Ready = isNodeReady n.Status.Conditions
+ CreationTimestamp = n.Metadata.CreationTimestamp |> Option.ofNullable |> Option.map K8sGateway.asUtcOffset })
+ |> List.ofSeq
+ }
diff --git a/src/Fizruk/Notifier.fs b/src/Fizruk/Notifier.fs
new file mode 100644
index 00000000..97915202
--- /dev/null
+++ b/src/Fizruk/Notifier.fs
@@ -0,0 +1,21 @@
+namespace Fizruk
+
+open System.Threading.Tasks
+open Microsoft.Extensions.Logging
+open BotInfra
+
+/// Delivers a game-status notification to every chat that controls that game.
+/// Kept separate from ITelegramApi so a future frontend can plug in its own.
+type INotifier =
+ abstract NotifyChats: chatIds: int64 list * text: string -> Task
+
+type TelegramNotifier(tg: ITelegramApi, logger: ILogger) =
+ interface INotifier with
+ member _.NotifyChats(chatIds, text) =
+ task {
+ for chatId in chatIds do
+ try
+ do! tg.CallIgnore(Funogram.Telegram.Req.SendMessage.Make(chatId, text))
+ with ex ->
+ logger.LogWarning(ex, "Fizruk: failed to notify chat {ChatId}", chatId)
+ }
diff --git a/src/Fizruk/PlayerProbe.fs b/src/Fizruk/PlayerProbe.fs
new file mode 100644
index 00000000..22193030
--- /dev/null
+++ b/src/Fizruk/PlayerProbe.fs
@@ -0,0 +1,31 @@
+namespace Fizruk
+
+open System
+open System.Threading.Tasks
+
+/// Dispatches a game's configured player probe (RCON, RakNet, or none).
+module PlayerProbe =
+
+ let private timeout = TimeSpan.FromSeconds 5.0
+
+ let probe (game: GameConfig) : Task =
+ task {
+ match game.Players.Type with
+ | ProbeType.None -> return PlayersProbeResult.NotConfigured
+ | ProbeType.Rcon ->
+ let envName = game.Players.PasswordEnv |> Option.defaultValue ""
+ match Environment.GetEnvironmentVariable envName with
+ | null -> return PlayersProbeResult.Error $"env var {envName} not set"
+ | password ->
+ let! result = Rcon.probeAsync game.Players.Host game.Players.Port password timeout
+ return
+ match result with
+ | Ok names -> PlayersProbeResult.Ok(PlayersInfo.Names names)
+ | Error e -> PlayersProbeResult.Error e
+ | ProbeType.RakNet ->
+ let! result = RakNet.probeAsync game.Players.Host game.Players.Port timeout
+ return
+ match result with
+ | Ok(online, _maxPlayers) -> PlayersProbeResult.Ok(PlayersInfo.Count online)
+ | Error e -> PlayersProbeResult.Error e
+ }
diff --git a/src/Fizruk/Program.fs b/src/Fizruk/Program.fs
new file mode 100644
index 00000000..fca6f7cb
--- /dev/null
+++ b/src/Fizruk/Program.fs
@@ -0,0 +1,101 @@
+// Fizruk — starts and stops on-demand game server Deployments from Telegram,
+// and shuts them down when nobody's playing.
+open System
+open Microsoft.AspNetCore.Builder
+open Microsoft.Extensions.DependencyInjection
+open Microsoft.Extensions.Hosting
+open Microsoft.Extensions.Logging
+open Fizruk
+open BotInfra
+
+type Root = class end
+
+let config =
+ try Config.loadFromFile(getEnv "FIZRUK_CONFIG_PATH")
+ with ConfigError msg ->
+ eprintfn "[FATAL] Fizruk config error: %s" msg
+ reraise ()
+
+let botUsername =
+ match getEnvOr "BOT_USERNAME" "" with
+ | "" -> None
+ | u -> Some u
+
+let webhookUrl = getEnvOr "BOT_WEBHOOK_URL" ""
+
+let webhookCfg: WebhookConfig =
+ { BotToken = getEnv "BOT_TELEGRAM_TOKEN"
+ SecretToken = getEnv "BOT_AUTH_TOKEN"
+ TelegramApiBaseUrl =
+ match getEnvOr "TELEGRAM_API_URL" "" with
+ | "" -> null
+ | v -> v
+ OtelServiceName = "fizruk"
+ ActivitySourceName = Telemetry.botActivity.Name
+ MeterName = Metrics.meter.Name
+ WebhookRoute = "/bot" }
+
+let builder = WebApplication.CreateBuilder()
+
+WebhookHost.configureSharedServices webhookCfg builder
+
+%builder.Services.AddSingleton(config)
+
+// FIZRUK_FAKE_K8S selects an in-memory gateway with no cluster dependency —
+// used by the container smoke tests, never set in production.
+%builder.Services.AddSingleton(fun _ ->
+ if getEnvOrBool "FIZRUK_FAKE_K8S" false then
+ InMemoryK8sGateway() :> IK8sGateway
+ else
+ let k8sConfig = k8s.KubernetesClientConfiguration.InClusterConfig()
+ KubernetesGateway(new k8s.Kubernetes(k8sConfig)) :> IK8sGateway)
+
+%builder.Services.AddSingleton(fun sp ->
+ TelegramNotifier(sp.GetRequiredService(), sp.GetRequiredService>())
+ :> INotifier)
+
+%builder.Services.AddSingleton(fun sp ->
+ GameCore(
+ config,
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ TimeSpan.FromSeconds 20.0,
+ sp.GetRequiredService>()))
+
+%builder.Services.AddSingleton(fun sp ->
+ FizrukBotService(
+ config,
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ botUsername,
+ sp.GetRequiredService>()))
+
+%builder.Services.AddHostedService()
+
+%builder.Services.AddSingleton(fun sp ->
+ WebhookRegistrationHostedService(
+ sp.GetRequiredService(),
+ webhookUrl,
+ webhookCfg.SecretToken,
+ sp.GetRequiredService>())
+ :> IHostedService)
+
+let app = builder.Build()
+
+// No database, so readiness has nothing to check beyond the process being up.
+Readiness.mapReadyEndpoint [] app
+
+WebhookHost.mapWebhookEndpoints webhookCfg FunogramJson.parseUpdate
+ (fun ctx _rawBody update ->
+ task {
+ let logger = ctx.RequestServices.GetRequiredService>()
+ try
+ let bot = ctx.RequestServices.GetRequiredService()
+ do! bot.OnUpdate update
+ with ex ->
+ logger.LogError(ex, "Unhandled error in update handler for {UpdateId}", update.UpdateId)
+ })
+ app
+
+app.Run()
diff --git a/src/Fizruk/RakNet.fs b/src/Fizruk/RakNet.fs
new file mode 100644
index 00000000..42bc4b12
--- /dev/null
+++ b/src/Fizruk/RakNet.fs
@@ -0,0 +1,76 @@
+namespace Fizruk
+
+open System
+open System.Buffers.Binary
+open System.Net.Sockets
+open System.Text
+open System.Threading
+open System.Threading.Tasks
+
+/// RakNet unconnected-ping/pong for Minecraft Bedrock's UDP status query. Packet
+/// framing and response parsing are pure so they're unit-testable without a socket.
+module RakNet =
+
+ []
+ let UnconnectedPingId = 0x01uy
+
+ []
+ let UnconnectedPongId = 0x1Cuy
+
+ /// The fixed RakNet offline-message magic sequence.
+ let magic: byte[] =
+ [| 0x00uy; 0xffuy; 0xffuy; 0x00uy; 0xfeuy; 0xfeuy; 0xfeuy; 0xfeuy
+ 0xfduy; 0xfduy; 0xfduy; 0xfduy; 0x12uy; 0x34uy; 0x56uy; 0x78uy |]
+
+ /// Builds an unconnected ping: id, timestamp, magic, client GUID.
+ let encodePing (timestamp: int64) (clientGuid: int64) : byte[] =
+ let buf = Array.zeroCreate (1 + 8 + 16 + 8)
+ buf.[0] <- UnconnectedPingId
+ BinaryPrimitives.WriteInt64BigEndian(Span(buf, 1, 8), timestamp)
+ Array.blit magic 0 buf 9 16
+ BinaryPrimitives.WriteInt64BigEndian(Span(buf, 25, 8), clientGuid)
+ buf
+
+ /// Parses an unconnected pong into (serverTime, serverGuid, motd). None if the
+ /// packet is too short, has the wrong id, or the magic doesn't match.
+ let decodePong (data: byte[]) : (int64 * int64 * string) option =
+ if data.Length < 1 + 8 + 8 + 16 + 2 then None
+ elif data.[0] <> UnconnectedPongId then None
+ else
+ let magicOk = ReadOnlySpan(data, 17, 16).SequenceEqual(ReadOnlySpan magic)
+ if not magicOk then None
+ else
+ let time = BinaryPrimitives.ReadInt64BigEndian(ReadOnlySpan(data, 1, 8))
+ let serverGuid = BinaryPrimitives.ReadInt64BigEndian(ReadOnlySpan(data, 9, 8))
+ let strLen = int (BinaryPrimitives.ReadUInt16BigEndian(ReadOnlySpan(data, 33, 2)))
+ if data.Length < 35 + strLen then None
+ else Some(time, serverGuid, Encoding.UTF8.GetString(data, 35, strLen))
+
+ /// Splits "MCPE;motd;protocol;version;online;max;..." into (online, max).
+ let parseOnlineMax (motd: string) : (int * int) option =
+ let parts = motd.Split ';'
+ if parts.Length < 6 then None
+ else
+ match Int32.TryParse parts.[4], Int32.TryParse parts.[5] with
+ | (true, online), (true, maxPlayers) -> Some(online, maxPlayers)
+ | _ -> None
+
+ /// Sends one unconnected ping and parses the pong's online/max player counts.
+ let probeAsync (host: string) (port: int) (timeout: TimeSpan) : Task> =
+ task {
+ use cts = new CancellationTokenSource(timeout)
+ try
+ use udp = new UdpClient()
+ let ping = encodePing (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) 0L
+ let! _ = udp.SendAsync(ReadOnlyMemory ping, host, port, cts.Token)
+ let! result = udp.ReceiveAsync cts.Token
+ match decodePong result.Buffer with
+ | None -> return Error "invalid RakNet pong"
+ | Some(_, _, motd) ->
+ match parseOnlineMax motd with
+ | Some(online, maxPlayers) -> return Ok(online, maxPlayers)
+ | None -> return Error $"unparsable MCPE motd: {motd}"
+ with
+ | :? OperationCanceledException -> return Error "RakNet probe timed out"
+ | ex -> return Error ex.Message
+ }
diff --git a/src/Fizruk/Rcon.fs b/src/Fizruk/Rcon.fs
new file mode 100644
index 00000000..27fd1cac
--- /dev/null
+++ b/src/Fizruk/Rcon.fs
@@ -0,0 +1,122 @@
+namespace Fizruk
+
+open System
+open System.IO
+open System.Net.Sockets
+open System.Text
+open System.Threading
+open System.Threading.Tasks
+
+/// Source RCON client for Factorio's "/players online" console command. Packet
+/// framing and response parsing are pure so they're unit-testable without a socket.
+module Rcon =
+
+ []
+ let ServerdataAuth = 3
+
+ []
+ let ServerdataExecCommand = 2
+
+ []
+ let ServerdataAuthResponse = 2
+
+ []
+ let ServerdataResponseValue = 0
+
+ /// Packets read hunting for SERVERDATA_AUTH_RESPONSE — Factorio echoes an
+ /// empty SERVERDATA_RESPONSE_VALUE (type 0) first, so the real one is 2nd.
+ []
+ let MaxAuthPackets = 4
+
+ /// Encodes one RCON packet: int32 size, int32 id, int32 type, body, two NUL bytes.
+ let encodePacket (id: int32) (packetType: int32) (body: string) : byte[] =
+ let bodyBytes = Encoding.UTF8.GetBytes body
+ let payload = Array.zeroCreate (4 + 4 + bodyBytes.Length + 2)
+ Buffer.BlockCopy(BitConverter.GetBytes id, 0, payload, 0, 4)
+ Buffer.BlockCopy(BitConverter.GetBytes packetType, 0, payload, 4, 4)
+ Buffer.BlockCopy(bodyBytes, 0, payload, 8, bodyBytes.Length)
+ Array.append (BitConverter.GetBytes payload.Length) payload
+
+ /// Decodes one RCON packet payload (everything after the leading size field)
+ /// into (id, type, body).
+ let decodePacket (payload: byte[]) : int32 * int32 * string =
+ let id = BitConverter.ToInt32(payload, 0)
+ let packetType = BitConverter.ToInt32(payload, 4)
+ let bodyLen = max 0 (payload.Length - 4 - 4 - 2)
+ let body = Encoding.UTF8.GetString(payload, 8, bodyLen)
+ id, packetType, body
+
+ /// Parses "Online players (N):\nname1\nname2" into just the player names,
+ /// dropping the header line. Empty (header-only) response yields [].
+ let parsePlayersResponse (body: string) : string list =
+ let lines =
+ body.Replace("\r\n", "\n").Split('\n')
+ |> Array.map (fun l -> l.Trim())
+ |> Array.filter (fun l -> l <> "")
+ match List.ofArray lines with
+ | _header :: names -> names
+ | [] -> []
+
+ /// Reads packets until SERVERDATA_AUTH_RESPONSE (type 2) or MaxAuthPackets is
+ /// exhausted, returning that packet's id (the auth verdict, -1 on failure).
+ let private readAuthResponseId (readPacket: unit -> Task) : Task =
+ task {
+ let mutable i = 0
+ let mutable found: int32 option = None
+ while found.IsNone && i < MaxAuthPackets do
+ let! id, packetType, _ = readPacket ()
+ if packetType = ServerdataAuthResponse then found <- Some id
+ i <- i + 1
+ match found with
+ | Some id -> return id
+ | None -> return failwith "RCON auth response not received within packet budget"
+ }
+
+ /// Runs the auth+exec protocol over any Stream — a real NetworkStream in
+ /// production, a fake in-memory one in tests — so this needs no socket to test.
+ let runProtocol (stream: Stream) (password: string) (ct: CancellationToken) : Task> =
+ task {
+ let readExact (buf: byte[]) =
+ task {
+ let mutable offset = 0
+ while offset < buf.Length do
+ let! n = stream.ReadAsync(buf.AsMemory(offset, buf.Length - offset), ct)
+ if n = 0 then failwith "RCON connection closed by server"
+ offset <- offset + n
+ }
+
+ let readPacket () =
+ task {
+ let sizeBuf = Array.zeroCreate 4
+ do! readExact sizeBuf
+ let size = BitConverter.ToInt32(sizeBuf, 0)
+ let payload = Array.zeroCreate size
+ do! readExact payload
+ return decodePacket payload
+ }
+
+ let authPacket = encodePacket 1 ServerdataAuth password
+ do! stream.WriteAsync(authPacket.AsMemory(), ct)
+ let! authId = readAuthResponseId readPacket
+ if authId = -1 then
+ return Error "RCON authentication failed"
+ else
+ let execPacket = encodePacket 2 ServerdataExecCommand "/players online"
+ do! stream.WriteAsync(execPacket.AsMemory(), ct)
+ let! _, _, body = readPacket ()
+ return Ok(parsePlayersResponse body)
+ }
+
+ /// Connects, then delegates to runProtocol for the auth+exec exchange.
+ let probeAsync (host: string) (port: int) (password: string) (timeout: TimeSpan) : Task> =
+ task {
+ use cts = new CancellationTokenSource(timeout)
+ try
+ use client = new TcpClient()
+ do! client.ConnectAsync(host, port, cts.Token)
+ use stream = client.GetStream()
+ return! runProtocol stream password cts.Token
+ with
+ | :? OperationCanceledException -> return Error "RCON probe timed out"
+ | ex -> return Error ex.Message
+ }
diff --git a/src/Fizruk/Telemetry.fs b/src/Fizruk/Telemetry.fs
new file mode 100644
index 00000000..8018713b
--- /dev/null
+++ b/src/Fizruk/Telemetry.fs
@@ -0,0 +1,17 @@
+namespace Fizruk
+
+open System.Diagnostics
+open System.Diagnostics.Metrics
+
+/// ActivitySource for custom spans in traces (OTEL). Used by AddOpenTelemetry in Program.
+module Telemetry =
+ let botActivity = new ActivitySource("Fizruk")
+
+module Metrics =
+ let meter = new Meter("Fizruk.Metrics")
+
+ /// Count of /start, /stop, /status invocations, tagged by `action` and `game`.
+ let commandTotal = meter.CreateCounter("fizruk_command_total")
+
+ /// Count of automatic idle shutdowns, tagged by `game`.
+ let idleStopTotal = meter.CreateCounter("fizruk_idle_stop_total")
diff --git a/src/Fizruk/WebhookRegistrationHostedService.fs b/src/Fizruk/WebhookRegistrationHostedService.fs
new file mode 100644
index 00000000..ee282e65
--- /dev/null
+++ b/src/Fizruk/WebhookRegistrationHostedService.fs
@@ -0,0 +1,20 @@
+namespace Fizruk
+
+open System
+open System.Threading
+open System.Threading.Tasks
+open Microsoft.Extensions.Hosting
+open Microsoft.Extensions.Logging
+open BotInfra
+
+/// Fires BotInfra.WebhookRegistration.registerAsync at startup without blocking
+/// the host from starting to serve — see that module for the opt-in behaviour.
+type WebhookRegistrationHostedService
+ (tg: ITelegramApi, webhookUrl: string, secretToken: string, logger: ILogger) =
+ interface IHostedService with
+ member _.StartAsync(_ct: CancellationToken) =
+ fireAndForget logger "fizruk.webhook_registration" (fun () ->
+ WebhookRegistration.registerAsync tg webhookUrl secretToken 5 (TimeSpan.FromSeconds 5.0) logger)
+ Task.CompletedTask
+
+ member _.StopAsync(_ct: CancellationToken) = Task.CompletedTask
diff --git a/tests/Fizruk.Tests/CommandParsingTests.fs b/tests/Fizruk.Tests/CommandParsingTests.fs
new file mode 100644
index 00000000..4ed6773e
--- /dev/null
+++ b/tests/Fizruk.Tests/CommandParsingTests.fs
@@ -0,0 +1,88 @@
+module Fizruk.Tests.CommandParsingTests
+
+open Fizruk
+open Fizruk.Tests.Fakes
+open Xunit
+
+[]
+let ``plain text is not a command`` () =
+ Assert.Equal(None, Commands.tryParse None "hello there")
+
+[]
+let ``bare start command with no game arg`` () =
+ let parsed = Commands.tryParse None "/start"
+ Assert.Equal(Some { Action = BotAction.Start; GameArg = None }, parsed)
+
+[]
+let ``start command with a game arg`` () =
+ let parsed = Commands.tryParse None "/start factorio"
+ Assert.Equal(Some { Action = BotAction.Start; GameArg = Some "factorio" }, parsed)
+
+[]
+let ``command matching is case-insensitive`` () =
+ let parsed = Commands.tryParse None "/STOP"
+ Assert.Equal(Some { Action = BotAction.Stop; GameArg = None }, parsed)
+
+[]
+let ``suffix addressed to this bot is stripped, case-insensitively`` () =
+ let parsed = Commands.tryParse (Some "fizruk_bot") "/Status@Fizruk_Bot factorio"
+ Assert.Equal(Some { Action = BotAction.Status; GameArg = Some "factorio" }, parsed)
+
+[]
+let ``suffix addressed to a different bot is ignored`` () =
+ Assert.Equal(None, Commands.tryParse (Some "fizruk_bot") "/start@some_other_bot")
+
+[]
+let ``unrecognized command word is ignored`` () =
+ Assert.Equal(None, Commands.tryParse None "/help")
+
+[]
+let ``unauthorized chat yields Unauthorized`` () =
+ let config = sampleConfig ()
+ Assert.Equal(Commands.Resolution.Unauthorized, Commands.resolveGame config.Chats BotAction.Status -999L None)
+
+[]
+let ``single-game chat resolves without naming the game`` () =
+ let config = sampleConfig ()
+ Assert.Equal(Commands.Resolution.Resolved "factorio", Commands.resolveGame config.Chats BotAction.Start -100L None)
+
+[]
+let ``single-game chat shortcut also applies to status`` () =
+ let config = sampleConfig ()
+ Assert.Equal(Commands.Resolution.Resolved "factorio", Commands.resolveGame config.Chats BotAction.Status -100L None)
+
+[]
+let ``explicit game name outside the chat's ACL is Unknown`` () =
+ let config = sampleConfig ()
+ Assert.Equal(Commands.Resolution.Unknown [ "factorio" ], Commands.resolveGame config.Chats BotAction.Start -100L (Some "minecraft-creative"))
+
+[]
+let ``explicit game name inside the chat's ACL resolves`` () =
+ let config = sampleConfig ()
+ Assert.Equal(
+ Commands.Resolution.Resolved "minecraft-creative",
+ Commands.resolveGame config.Chats BotAction.Start -200L (Some "minecraft-creative"))
+
+[]
+let ``multi-game chat with no name is ambiguous for start and stop`` () =
+ let config = sampleConfig ()
+ let expected = Commands.Resolution.Ambiguous [ "factorio"; "minecraft-creative" ]
+ Assert.Equal(expected, Commands.resolveGame config.Chats BotAction.Start -200L None)
+ Assert.Equal(expected, Commands.resolveGame config.Chats BotAction.Stop -200L None)
+
+[]
+let ``multi-game chat with no name reports every game for status`` () =
+ let config = sampleConfig ()
+ Assert.Equal(
+ Commands.Resolution.AllGames [ "factorio"; "minecraft-creative" ],
+ Commands.resolveGame config.Chats BotAction.Status -200L None)
+
+[]
+let ``explicit game name resolves case-insensitively to the configured spelling`` () =
+ let config = sampleConfig ()
+ Assert.Equal(
+ Commands.Resolution.Resolved "minecraft-creative",
+ Commands.resolveGame config.Chats BotAction.Start -200L (Some "Minecraft-Creative"))
+ Assert.Equal(
+ Commands.Resolution.Resolved "minecraft-creative",
+ Commands.resolveGame config.Chats BotAction.Start -200L (Some "MINECRAFT-CREATIVE"))
diff --git a/tests/Fizruk.Tests/ConfigTests.fs b/tests/Fizruk.Tests/ConfigTests.fs
new file mode 100644
index 00000000..8c9ebb87
--- /dev/null
+++ b/tests/Fizruk.Tests/ConfigTests.fs
@@ -0,0 +1,76 @@
+module Fizruk.Tests.ConfigTests
+
+open Fizruk
+open Fizruk.Tests.Fakes
+open Xunit
+
+let private minimalGame (activityRegex: string) (playersJson: string) =
+ $$"""
+ {
+ "games": {
+ "g": {
+ "displayName": "G",
+ "namespace": "ns",
+ "deployment": "g",
+ "podSelector": "app=g",
+ "container": "g",
+ "nodeLabelSelector": "workload=game",
+ "address": "g.example:1234",
+ {{playersJson}}
+ "activityRegex": "{{activityRegex}}",
+ "idleGraceMinutes": 5,
+ "idleWindowMinutes": 10,
+ "startTimeoutMinutes": 1
+ }
+ },
+ "chats": { "-1": ["g"] }
+ }
+ """
+
+[]
+let ``valid config parses every game and chat`` () =
+ let config = sampleConfig ()
+ Assert.Equal(3, config.Games.Count)
+ Assert.Equal([ "factorio" ], config.Chats.[-100L])
+ Assert.Equal([ "factorio"; "minecraft-creative" ], config.Chats.[-200L])
+
+[]
+let ``rcon game requires host, port, and passwordEnv`` () =
+ let config = sampleConfig ()
+ let game = config.Games.["factorio"]
+ Assert.Equal(ProbeType.Rcon, game.Players.Type)
+ Assert.Equal(Some "FACTORIO_RCON_PASSWORD", game.Players.PasswordEnv)
+
+[]
+let ``game without a players block has no probe configured`` () =
+ let config = sampleConfig ()
+ Assert.Equal(ProbeType.None, config.Games.["no-probe-game"].Players.Type)
+
+[]
+let ``chat referencing an unknown game raises ConfigError`` () =
+ let json = minimalGame "JOIN" ""
+ let json = json.Replace("\"chats\": { \"-1\": [\"g\"] }", "\"chats\": { \"-1\": [\"does-not-exist\"] }")
+ Assert.Throws(fun () -> Config.parse json |> ignore) |> ignore
+
+[]
+let ``missing required field raises ConfigError`` () =
+ let json = """{ "games": { "g": { "namespace": "ns" } }, "chats": {} }"""
+ Assert.Throws(fun () -> Config.parse json |> ignore) |> ignore
+
+[]
+let ``invalid activityRegex raises ConfigError`` () =
+ let json = minimalGame "[unterminated" ""
+ Assert.Throws(fun () -> Config.parse json |> ignore) |> ignore
+
+[]
+let ``rcon players block without passwordEnv raises ConfigError`` () =
+ let players = """"players": { "type": "rcon", "host": "h", "port": 1 },"""
+ let json = minimalGame "JOIN" players
+ Assert.Throws(fun () -> Config.parse json |> ignore) |> ignore
+
+[]
+let ``rcon players block with passwordEnv parses`` () =
+ let players = """"players": { "type": "rcon", "host": "h", "port": 1, "passwordEnv": "PW" },"""
+ let json = minimalGame "JOIN" players
+ let config = Config.parse json
+ Assert.Equal(Some "PW", config.Games.["g"].Players.PasswordEnv)
diff --git a/tests/Fizruk.Tests/ContainerTests.fs b/tests/Fizruk.Tests/ContainerTests.fs
new file mode 100644
index 00000000..68538b71
--- /dev/null
+++ b/tests/Fizruk.Tests/ContainerTests.fs
@@ -0,0 +1,98 @@
+module Fizruk.Tests.ContainerTests
+
+open System.Net
+open System.Net.Http
+open System.Text.Json
+open BotTestInfra
+open Fizruk.Tests.ContainerFixture
+open Fizruk.Tests.FakeCallHelpers
+open Xunit
+
+/// Pulls url/secret_token/allowed_updates out of a recorded setWebhook call body.
+let private parseSetWebhookBody (body: string) =
+ use doc = JsonDocument.Parse body
+ let root = doc.RootElement
+ let url = root.GetProperty("url").GetString()
+ let secretToken =
+ match root.TryGetProperty "secret_token" with
+ | true, v -> Some(v.GetString())
+ | false, _ -> None
+ let allowedUpdates =
+ match root.TryGetProperty "allowed_updates" with
+ | true, v -> v.EnumerateArray() |> Seq.map (fun e -> e.GetString()) |> Array.ofSeq
+ | false, _ -> [||]
+ url, secretToken, allowedUpdates
+
+/// Polls fakeTg's recorded calls for up to `timeoutMs` real milliseconds — the
+/// webhook response returns before the bot's outbound sendMessage necessarily lands.
+let rec private waitForCall
+ (fixture: FizrukContainerFixture)
+ (timeoutMs: int)
+ (predicate: FakeCall array -> bool)
+ : System.Threading.Tasks.Task =
+ task {
+ let sw = System.Diagnostics.Stopwatch.StartNew()
+ let mutable calls = [||]
+ let mutable ok = false
+ while not ok && sw.ElapsedMilliseconds < int64 timeoutMs do
+ let! got = fixture.GetFakeCalls "sendMessage"
+ calls <- got
+ ok <- predicate calls
+ if not ok then do! System.Threading.Tasks.Task.Delay 100
+ return calls
+ }
+
+type ContainerTests(fixture: FizrukContainerFixture) =
+
+ []
+ let ``container starts healthy and the health endpoint answers`` () =
+ task {
+ let! resp = fixture.BotHttp.GetAsync "/health"
+ Assert.Equal(HttpStatusCode.OK, resp.StatusCode)
+ let! body = resp.Content.ReadAsStringAsync()
+ Assert.Equal("OK", body)
+ }
+
+ []
+ let ``status from an allowed chat produces a sendMessage with the expected text`` () =
+ task {
+ do! fixture.ClearFakeCalls()
+ let user = Tg.user(id = 1L, username = "player_one", firstName = "Player")
+ let! resp = fixture.SendUpdate(Tg.groupMessage("/status", user, AllowedChatId))
+ Assert.Equal(HttpStatusCode.OK, resp.StatusCode)
+
+ let! calls = waitForCall fixture 5000 (fun calls -> anyCallHasText calls AllowedChatId "Demo Game")
+ Assert.True(
+ anyCallHasText calls AllowedChatId "Demo Game: stopped.",
+ $"expected a 'Demo Game: stopped.' reply to chat {AllowedChatId}, got: %A{calls}")
+ }
+
+ []
+ let ``a message from an unknown chat produces no sendMessage call`` () =
+ task {
+ do! fixture.ClearFakeCalls()
+ let user = Tg.user(id = 2L, username = "stranger", firstName = "Stranger")
+ let! resp = fixture.SendUpdate(Tg.groupMessage("/status", user, UnknownChatId))
+ Assert.Equal(HttpStatusCode.OK, resp.StatusCode)
+
+ // Negative assertion: give the (nonexistent) reply a real chance to land
+ // before concluding it never will.
+ do! System.Threading.Tasks.Task.Delay 1000
+ let! calls = fixture.GetFakeCalls "sendMessage"
+ Assert.Empty(calls |> Array.filter (fun c -> (parseCallBody c.Body |> Option.bind (fun p -> p.ChatId)) = Some UnknownChatId))
+ }
+
+ []
+ let ``webhook self-registration calls setWebhook with the expected url, secret token, and allowed_updates`` () =
+ let calls = fixture.WebhookRegistrationCalls
+ Assert.Single(calls) |> ignore
+ let url, secretToken, allowedUpdates = parseSetWebhookBody calls.[0].Body
+ Assert.Equal(WebhookUrl, url)
+ Assert.Equal(Some SecretToken, secretToken)
+ Assert.Equal([| "message" |], allowedUpdates)
+
+ []
+ let ``no setWebhook call is made by the container with BOT_WEBHOOK_URL unset`` () =
+ // The one captured call (asserted above) belongs to webhookBotContainer —
+ // botContainer never sets BOT_WEBHOOK_URL, so it contributes none.
+ Assert.Single(fixture.WebhookRegistrationCalls) |> ignore
diff --git a/tests/Fizruk.Tests/CoreFlowTests.fs b/tests/Fizruk.Tests/CoreFlowTests.fs
new file mode 100644
index 00000000..999def3c
--- /dev/null
+++ b/tests/Fizruk.Tests/CoreFlowTests.fs
@@ -0,0 +1,206 @@
+module Fizruk.Tests.CoreFlowTests
+
+open System
+open System.Threading.Tasks
+open Fizruk
+open Fizruk.Tests.Fakes
+open Microsoft.Extensions.Logging.Abstractions
+open Xunit
+
+let private ns = "games"
+let private deployment = "no-probe-game"
+let private podSelector = "app=no-probe-game"
+let private nodeLabelSelector = "workload=game"
+let private gameId = "no-probe-game"
+
+let private readyPod (name: string) (startedMinutesAgo: int) =
+ { Name = name
+ Phase = "Running"
+ Ready = true
+ Terminating = false
+ StartTime = Some(DateTimeOffset.UtcNow.AddMinutes(float -startedMinutesAgo))
+ NodeName = Some "node-1" }
+
+let private pendingPod =
+ { Name = "no-probe-game-x"
+ Phase = "Pending"
+ Ready = false
+ Terminating = false
+ StartTime = None
+ NodeName = None }
+
+/// GameCore doesn't expose a way to await its fire-and-forget watcher, so tests that
+/// depend on it poll the fake notifier for up to `timeoutMs` real milliseconds.
+let rec private waitUntil (timeoutMs: int) (check: unit -> bool) : Task =
+ task {
+ let sw = Diagnostics.Stopwatch.StartNew()
+ let mutable ok = check ()
+ while not ok && sw.ElapsedMilliseconds < int64 timeoutMs do
+ do! Task.Delay 10
+ ok <- check ()
+ return ok
+ }
+
+let private newCore (k8sGateway: FakeK8sGateway) (notifier: FakeNotifier) (pollInterval: TimeSpan) =
+ GameCore(
+ sampleConfig (),
+ k8sGateway,
+ notifier,
+ TimeProvider.System,
+ pollInterval,
+ NullLogger.Instance)
+
+[]
+let ``start when stopped scales up and replies Starting`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let core = newCore k8s (FakeNotifier()) (TimeSpan.FromSeconds 30.0)
+ let! reply = core.Start gameId
+ Assert.StartsWith("Starting No Probe Game", reply)
+ Assert.Equal(1, k8s.CurrentReplicas(ns, deployment))
+ }
+
+[]
+let ``start when already starting (pod not ready yet) reports Already starting`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ pendingPod ])
+ let core = newCore k8s (FakeNotifier()) (TimeSpan.FromSeconds 30.0)
+ let! reply = core.Start gameId
+ Assert.Equal("Already starting.", reply)
+ }
+
+[]
+let ``start when already running reports Already running`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 5 ])
+ let core = newCore k8s (FakeNotifier()) (TimeSpan.FromSeconds 30.0)
+ let! reply = core.Start gameId
+ Assert.Equal("Already running.", reply)
+ }
+
+[]
+let ``stop when running scales down and reports shutdown`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 5 ])
+ let core = newCore k8s (FakeNotifier()) (TimeSpan.FromSeconds 30.0)
+ let! reply = core.Stop gameId
+ Assert.Equal("Stopping No Probe Game. Players online at shutdown: unknown.", reply)
+ Assert.Equal(0, k8s.CurrentReplicas(ns, deployment))
+ }
+
+[]
+let ``stop when already stopped is a no-op`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let core = newCore k8s (FakeNotifier()) (TimeSpan.FromSeconds 30.0)
+ let! reply = core.Stop gameId
+ Assert.Equal("Already stopped.", reply)
+ }
+
+[]
+let ``watcher posts ready once the pod becomes Ready`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ let core = newCore k8s notifier (TimeSpan.FromMilliseconds 20.0)
+ let! _ = core.Start gameId
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 0 ])
+ let! found = waitUntil 5000 (fun () -> notifier.Sent |> List.exists (fun (_, text) -> text.Contains "ready at"))
+ Assert.True(found, "expected a ready notification")
+ let _, text = notifier.Sent |> List.find (fun (_, text) -> text.Contains "ready at")
+ Assert.Equal("No Probe Game ready at noprobe.szer.dev:1234", text)
+ }
+
+[]
+let ``start when already starting re-arms a watcher that still posts ready`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ pendingPod ])
+ let core = newCore k8s notifier (TimeSpan.FromMilliseconds 20.0)
+ let! reply = core.Start gameId
+ Assert.Equal("Already starting.", reply)
+
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 0 ])
+ let! found = waitUntil 5000 (fun () -> notifier.Sent |> List.exists (fun (_, text) -> text.Contains "ready at"))
+ Assert.True(found, "expected a ready notification from the re-armed watcher")
+ }
+
+[]
+let ``ArmPendingWatchers re-arms a watcher for a game left starting after a restart`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ pendingPod ])
+ let core = newCore k8s notifier (TimeSpan.FromMilliseconds 20.0)
+
+ do! core.ArmPendingWatchers()
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 0 ])
+ let! found = waitUntil 5000 (fun () -> notifier.Sent |> List.exists (fun (_, text) -> text.Contains "ready at"))
+ Assert.True(found, "expected ArmPendingWatchers to have armed a watcher")
+ }
+
+[]
+let ``watcher posts cancelled when replicas drop to zero`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ let core = newCore k8s notifier (TimeSpan.FromMilliseconds 20.0)
+ let! _ = core.Start gameId
+ let! _ = core.Stop gameId
+ let! found = waitUntil 5000 (fun () -> notifier.Sent |> List.exists (fun (_, text) -> text.Contains "Start cancelled"))
+ Assert.True(found, "expected a start-cancelled notification")
+ }
+
+[]
+let ``idle check stops an idle game and notifies every controlling chat`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 999 ])
+ k8s.SetLog(ns, "no-probe-game-1", "server started\nnothing interesting here\n")
+ let core = newCore k8s notifier (TimeSpan.FromSeconds 30.0)
+ do! core.CheckIdle gameId
+ Assert.Equal(0, k8s.CurrentReplicas(ns, deployment))
+ Assert.Single(notifier.Sent) |> ignore
+ let chatIds, text = notifier.Sent.Head
+ Assert.Equal([ -300L ], chatIds)
+ Assert.Contains("Stopped No Probe Game", text)
+ }
+
+[]
+let ``idle check keeps a game within its grace period`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 1 ])
+ k8s.SetLog(ns, "no-probe-game-1", "")
+ let core = newCore k8s notifier (TimeSpan.FromSeconds 30.0)
+ do! core.CheckIdle gameId
+ Assert.Equal(1, k8s.CurrentReplicas(ns, deployment))
+ Assert.Empty(notifier.Sent)
+ }
+
+[]
+let ``idle check keeps a game with recent matching activity`` () =
+ task {
+ let k8s = FakeK8sGateway()
+ let notifier = FakeNotifier()
+ k8s.SetReplicas(ns, deployment, 1)
+ k8s.SetPods(ns, podSelector, [ readyPod "no-probe-game-1" 999 ])
+ k8s.SetLog(ns, "no-probe-game-1", "[JOIN] alice\n")
+ let core = newCore k8s notifier (TimeSpan.FromSeconds 30.0)
+ do! core.CheckIdle gameId
+ Assert.Equal(1, k8s.CurrentReplicas(ns, deployment))
+ Assert.Empty(notifier.Sent)
+ }
diff --git a/tests/Fizruk.Tests/FakeCallHelpers.fs b/tests/Fizruk.Tests/FakeCallHelpers.fs
new file mode 100644
index 00000000..e784cf6f
--- /dev/null
+++ b/tests/Fizruk.Tests/FakeCallHelpers.fs
@@ -0,0 +1,33 @@
+module Fizruk.Tests.FakeCallHelpers
+
+open System.Text.Json
+open BotTestInfra
+
+/// The chat_id and text fields of a FakeTgApi-recorded sendMessage call body.
+type ParsedCall =
+ { ChatId: int64 option
+ Text: string option }
+
+let parseCallBody (body: string) : ParsedCall option =
+ try
+ use doc = JsonDocument.Parse body
+ let root = doc.RootElement
+ let chatId =
+ match root.TryGetProperty "chat_id" with
+ | true, v when v.ValueKind = JsonValueKind.Number -> Some(v.GetInt64())
+ | _ -> None
+ let text =
+ match root.TryGetProperty "text" with
+ | true, v when v.ValueKind = JsonValueKind.String -> Some(v.GetString())
+ | _ -> None
+ Some { ChatId = chatId; Text = text }
+ with _ ->
+ None
+
+/// True if any call has the given chat id and its text contains the substring.
+let anyCallHasText (calls: FakeCall array) (chatId: int64) (textSubstring: string) : bool =
+ calls
+ |> Array.exists (fun call ->
+ match parseCallBody call.Body with
+ | Some { ChatId = Some cid; Text = Some text } -> cid = chatId && text.Contains textSubstring
+ | _ -> false)
diff --git a/tests/Fizruk.Tests/Fakes.fs b/tests/Fizruk.Tests/Fakes.fs
new file mode 100644
index 00000000..3c46d639
--- /dev/null
+++ b/tests/Fizruk.Tests/Fakes.fs
@@ -0,0 +1,111 @@
+module Fizruk.Tests.Fakes
+
+open System.Collections.Concurrent
+open System.Threading.Tasks
+open Fizruk
+
+/// Three games (RCON, RakNet, no-probe) and chats covering every ACL shape tests
+/// need: single-game shortcut, multi-game ambiguity, and an unlisted chat id.
+let sampleConfigJson =
+ """
+ {
+ "games": {
+ "factorio": {
+ "displayName": "Factorio",
+ "namespace": "games",
+ "deployment": "factorio",
+ "podSelector": "app=factorio",
+ "container": "factorio",
+ "nodeLabelSelector": "workload=game",
+ "address": "factorio.szer.dev:34197",
+ "players": { "type": "rcon", "host": "factorio.games.svc.cluster.local", "port": 27015, "passwordEnv": "FACTORIO_RCON_PASSWORD" },
+ "activityRegex": "\\[(JOIN|LEAVE|CHAT)\\]",
+ "idleGraceMinutes": 45,
+ "idleWindowMinutes": 60,
+ "startTimeoutMinutes": 1
+ },
+ "minecraft-creative": {
+ "displayName": "Minecraft Creative",
+ "namespace": "games",
+ "deployment": "minecraft-creative",
+ "podSelector": "app=minecraft-creative",
+ "container": "minecraft-server",
+ "nodeLabelSelector": "workload=game",
+ "address": "mc.szer.dev:19132",
+ "players": { "type": "raknet", "host": "minecraft-creative.games.svc.cluster.local", "port": 19132 },
+ "activityRegex": "Player (connected|disconnected)",
+ "idleGraceMinutes": 45,
+ "idleWindowMinutes": 60,
+ "startTimeoutMinutes": 1
+ },
+ "no-probe-game": {
+ "displayName": "No Probe Game",
+ "namespace": "games",
+ "deployment": "no-probe-game",
+ "podSelector": "app=no-probe-game",
+ "container": "server",
+ "nodeLabelSelector": "workload=game",
+ "address": "noprobe.szer.dev:1234",
+ "activityRegex": "\\[(JOIN|LEAVE)\\]",
+ "idleGraceMinutes": 45,
+ "idleWindowMinutes": 60,
+ "startTimeoutMinutes": 1
+ }
+ },
+ "chats": {
+ "-100": ["factorio"],
+ "-200": ["factorio", "minecraft-creative"],
+ "-300": ["no-probe-game"]
+ }
+ }
+ """
+
+let sampleConfig () = Config.parse sampleConfigJson
+
+/// In-memory IK8sGateway double: per-(namespace,name) desired-replicas/pods/log
+/// state that tests set up directly, no network or cluster involved.
+type FakeK8sGateway() =
+ let replicas = ConcurrentDictionary()
+ let pods = ConcurrentDictionary()
+ let nodes = ConcurrentDictionary()
+ let logs = ConcurrentDictionary()
+
+ member _.SetReplicas(ns, deployment, n) = replicas.[(ns, deployment)] <- n
+ member _.SetPods(ns, labelSelector, ps) = pods.[(ns, labelSelector)] <- ps
+ member _.SetNodes(labelSelector, ns_) = nodes.[labelSelector] <- ns_
+ member _.SetLog(ns, podName, text) = logs.[(ns, podName)] <- text
+ member _.CurrentReplicas(ns, deployment) = replicas.GetOrAdd((ns, deployment), 0)
+
+ interface IK8sGateway with
+ member _.GetDesiredReplicas(ns, deployment) = task { return replicas.GetOrAdd((ns, deployment), 0) }
+ member _.ScaleDeployment(ns, deployment, n) = task { replicas.[(ns, deployment)] <- n }
+
+ member _.ListPods(ns, labelSelector) =
+ task {
+ match pods.TryGetValue((ns, labelSelector)) with
+ | true, v -> return v
+ | false, _ -> return []
+ }
+
+ member _.GetPodLog(ns, podName, _container, _sinceSeconds) =
+ task {
+ match logs.TryGetValue((ns, podName)) with
+ | true, v -> return v
+ | false, _ -> return ""
+ }
+
+ member _.ListNodes(labelSelector) =
+ task {
+ match nodes.TryGetValue labelSelector with
+ | true, v -> return v
+ | false, _ -> return []
+ }
+
+/// Records every notification instead of calling Telegram.
+type FakeNotifier() =
+ let sent = ConcurrentQueue()
+ member _.Sent = sent |> List.ofSeq
+ interface INotifier with
+ member _.NotifyChats(chatIds, text) =
+ sent.Enqueue(chatIds, text)
+ Task.FromResult()
diff --git a/tests/Fizruk.Tests/Fizruk.Tests.fsproj b/tests/Fizruk.Tests/Fizruk.Tests.fsproj
new file mode 100644
index 00000000..3da452b0
--- /dev/null
+++ b/tests/Fizruk.Tests/Fizruk.Tests.fsproj
@@ -0,0 +1,41 @@
+
+
+
+ Exe
+ false
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Fizruk.Tests/FizrukContainerFixture.fs b/tests/Fizruk.Tests/FizrukContainerFixture.fs
new file mode 100644
index 00000000..c36e6898
--- /dev/null
+++ b/tests/Fizruk.Tests/FizrukContainerFixture.fs
@@ -0,0 +1,179 @@
+module Fizruk.Tests.ContainerFixture
+
+open System
+open System.IO
+open System.Net.Http
+open System.Net.Http.Json
+open System.Text
+open System.Threading.Tasks
+open DotNet.Testcontainers.Builders
+open DotNet.Testcontainers.Configurations
+open DotNet.Testcontainers.Containers
+open BotTestInfra
+open BotTestInfra.ContainerHelpers
+open Xunit
+
+/// Mounted into the bot container as FIZRUK_CONFIG_PATH: one game with no player
+/// probe, one chat id allowed to control it (matches AllowedChatId below).
+let ConfigJson =
+ """
+ {
+ "games": {
+ "demo-game": {
+ "displayName": "Demo Game",
+ "namespace": "games",
+ "deployment": "demo-game",
+ "podSelector": "app=demo-game",
+ "container": "server",
+ "nodeLabelSelector": "workload=game",
+ "address": "demo.szer.dev:1234",
+ "activityRegex": "\\[(JOIN|LEAVE)\\]",
+ "idleGraceMinutes": 45,
+ "idleWindowMinutes": 60,
+ "startTimeoutMinutes": 20
+ }
+ },
+ "chats": { "100": ["demo-game"] }
+ }
+ """
+
+let AllowedChatId = 100L
+
+let UnknownChatId = 999L
+
+let SecretToken = "fizruk-test-secret"
+
+/// Sentinel BOT_WEBHOOK_URL for the second (webhook-registration-enabled) bot
+/// container — never dialed, FakeTgApi only records the setWebhook call it causes.
+let WebhookUrl = "http://fizruk.invalid/bot"
+
+/// Runs the real Fizruk image (src/Dockerfile.bot, BOT_PROJECT=Fizruk) against a
+/// FakeTgApi container standing in for api.telegram.org, with FIZRUK_FAKE_K8S=true.
+type FizrukContainerFixture() =
+ let solutionDir = CommonDirectoryPath.GetSolutionDirectory()
+ let solutionDirPath = solutionDir.DirectoryPath
+ let network = createNetwork ()
+ let fakeAlias = "fake-tg-api"
+
+ let fakeTgImage, fakeTgBuildLogger =
+ getOrCreateImageSpec "fizruk-tests-fake-tg-api" (fun () ->
+ buildImageSpec solutionDir "./tests/Dockerfile.fake" "fizruk-tests-fake-tg-api" true true
+ [ "FAKE_PROJECT", "FakeTgApi"; "FAKE_PORT", "8080" ])
+ let fakeTgContainer = createFakeTgApiContainer fakeTgImage network fakeAlias
+
+ let botImage, botBuildLogger =
+ getOrCreateImageSpec "fizruk-tests-bot" (fun () ->
+ buildImageSpec solutionDir "./src/Dockerfile.bot" "fizruk-tests-bot" true true [ "BOT_PROJECT", "Fizruk" ])
+
+ // A host-file bind mount, not WithResourceMapping — the latter's docker-cp
+ // tar-stream copy 500s ("broken pipe") against this podman version.
+ let configFilePath = Path.Combine(Path.GetTempPath(), $"fizruk-test-config-{Guid.NewGuid()}.json")
+ do File.WriteAllText(configFilePath, ConfigJson)
+
+ let botContainer =
+ ContainerBuilder(botImage)
+ .WithNetwork(network)
+ .WithPortBinding(80, true)
+ .WithBindMount(configFilePath, "/config/fizruk.json", AccessMode.ReadOnly)
+ // Without label=disable, SELinux-enforcing hosts block reading the bind
+ // mount — a no-op where SELinux isn't in use (see Flyway's bind mount).
+ .WithCreateParameterModifier(fun p -> p.HostConfig.SecurityOpt <- ResizeArray [ "label=disable" ])
+ .WithEnvironment("ASPNETCORE_HTTP_PORTS", "80")
+ .WithEnvironment("FIZRUK_CONFIG_PATH", "/config/fizruk.json")
+ .WithEnvironment("BOT_TELEGRAM_TOKEN", "test-token")
+ .WithEnvironment("BOT_AUTH_TOKEN", SecretToken)
+ .WithEnvironment("TELEGRAM_API_URL", $"http://{fakeAlias}:8080")
+ .WithEnvironment("FIZRUK_FAKE_K8S", "true")
+ .DependsOn(fakeTgContainer)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(80))
+ .Build()
+
+ /// Same image/config, BOT_WEBHOOK_URL set — exercises webhook self-registration
+ /// without a second image build (Testcontainers caches botImage by name).
+ let webhookBotContainer =
+ ContainerBuilder(botImage)
+ .WithNetwork(network)
+ .WithPortBinding(80, true)
+ .WithBindMount(configFilePath, "/config/fizruk.json", AccessMode.ReadOnly)
+ .WithCreateParameterModifier(fun p -> p.HostConfig.SecurityOpt <- ResizeArray [ "label=disable" ])
+ .WithEnvironment("ASPNETCORE_HTTP_PORTS", "80")
+ .WithEnvironment("FIZRUK_CONFIG_PATH", "/config/fizruk.json")
+ .WithEnvironment("BOT_TELEGRAM_TOKEN", "test-token")
+ .WithEnvironment("BOT_AUTH_TOKEN", SecretToken)
+ .WithEnvironment("TELEGRAM_API_URL", $"http://{fakeAlias}:8080")
+ .WithEnvironment("FIZRUK_FAKE_K8S", "true")
+ .WithEnvironment("BOT_WEBHOOK_URL", WebhookUrl)
+ .DependsOn(fakeTgContainer)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(80))
+ .Build()
+
+ let mutable botHttp: HttpClient = null
+ let mutable fakeTgHttp: HttpClient = null
+ let mutable testArtifactsDir: string = null
+ let mutable webhookRegistrationCalls: FakeCall array = [||]
+
+ interface IAsyncLifetime with
+ member _.InitializeAsync() =
+ ValueTask(task {
+ testArtifactsDir <- Path.Combine(solutionDirPath, "test-artifacts", "Fizruk.Tests", "ContainerTests")
+
+ let botBuildTask = buildImageOncePerProcess "fizruk-tests-bot" testArtifactsDir "bot" botImage botBuildLogger
+ let fakeTgBuildTask =
+ buildImageOncePerProcess "fizruk-tests-fake-tg-api" testArtifactsDir "fake-tg-api" fakeTgImage fakeTgBuildLogger
+ do! Task.WhenAll(botBuildTask, fakeTgBuildTask)
+
+ do! fakeTgContainer.StartAsync()
+ do! Task.WhenAll(botContainer.StartAsync(), webhookBotContainer.StartAsync())
+
+ botHttp <- new HttpClient(BaseAddress = Uri($"http://127.0.0.1:{botContainer.GetMappedPublicPort(80)}"))
+ botHttp.Timeout <- TimeSpan.FromSeconds 15.0
+ botHttp.DefaultRequestHeaders.Add("X-Telegram-Bot-Api-Secret-Token", SecretToken)
+
+ fakeTgHttp <- new HttpClient(BaseAddress = Uri($"http://127.0.0.1:{fakeTgContainer.GetMappedPublicPort(8080)}"))
+ fakeTgHttp.Timeout <- TimeSpan.FromSeconds 5.0
+
+ // Snapshot the (fire-and-forget, so not necessarily instant) setWebhook
+ // call now, before any test's ClearFakeCalls() can wipe the evidence.
+ let sw = Diagnostics.Stopwatch.StartNew()
+ let mutable snapshot: FakeCall array = [||]
+ while snapshot.Length = 0 && sw.ElapsedMilliseconds < 10_000L do
+ let! calls = fakeTgHttp.GetFromJsonAsync("/test/calls?method=setWebhook")
+ snapshot <- calls
+ if snapshot.Length = 0 then do! Task.Delay 100
+ webhookRegistrationCalls <- snapshot
+ } :> Task)
+
+ member _.DisposeAsync() =
+ ValueTask(task {
+ let! _ = dumpContainerLogs testArtifactsDir "bot" botContainer
+ let! _ = dumpContainerLogs testArtifactsDir "webhook-bot" webhookBotContainer
+ let! _ = dumpContainerLogs testArtifactsDir "fake-tg-api" fakeTgContainer
+ if not (isNull botHttp) then botHttp.Dispose()
+ if not (isNull fakeTgHttp) then fakeTgHttp.Dispose()
+ do! botContainer.DisposeAsync()
+ do! webhookBotContainer.DisposeAsync()
+ do! fakeTgContainer.DisposeAsync()
+ File.Delete configFilePath
+ } :> Task)
+
+ member _.BotHttp = botHttp
+
+ /// setWebhook calls snapshotted once at startup, independent of any test's
+ /// ClearFakeCalls() — expected to be exactly one, from webhookBotContainer.
+ member _.WebhookRegistrationCalls = webhookRegistrationCalls
+
+ member _.SendUpdate(update: Funogram.Telegram.Types.Update) =
+ task {
+ let json = Encoding.UTF8.GetString(Funogram.Tools.toJson update)
+ use content = new StringContent(json, Encoding.UTF8, "application/json")
+ return! botHttp.PostAsync("/bot", content)
+ }
+
+ member _.ClearFakeCalls() =
+ task {
+ let! _ = fakeTgHttp.DeleteAsync "/test/calls"
+ return ()
+ }
+
+ member _.GetFakeCalls(methodName: string) =
+ task { return! fakeTgHttp.GetFromJsonAsync($"/test/calls?method={methodName}") }
diff --git a/tests/Fizruk.Tests/IdleDecisionTests.fs b/tests/Fizruk.Tests/IdleDecisionTests.fs
new file mode 100644
index 00000000..d98de8fa
--- /dev/null
+++ b/tests/Fizruk.Tests/IdleDecisionTests.fs
@@ -0,0 +1,53 @@
+module Fizruk.Tests.IdleDecisionTests
+
+open Fizruk.IdleDecision
+open Xunit
+
+let private isStop decision =
+ match decision with
+ | Stop -> true
+ | Keep _ -> false
+
+[]
+let ``already stopped is always kept`` () =
+ Assert.False(isStop (decide 0 false None 45 (PlayersCount.Ok 0) (Activity.Ok [])))
+
+[]
+let ``pod not ready is kept`` () =
+ Assert.False(isStop (decide 1 false (Some 999) 45 (PlayersCount.Ok 0) (Activity.Ok [])))
+
+[]
+let ``unknown pod age is kept`` () =
+ Assert.False(isStop (decide 1 true None 45 (PlayersCount.Ok 0) (Activity.Ok [])))
+
+[]
+let ``pod younger than the grace period is kept`` () =
+ Assert.False(isStop (decide 1 true (Some 10) 45 (PlayersCount.Ok 0) (Activity.Ok [])))
+
+[]
+let ``player probe error is kept`` () =
+ Assert.False(isStop (decide 1 true (Some 999) 45 (PlayersCount.Error "timeout") (Activity.Ok [])))
+
+[]
+let ``players online is kept`` () =
+ Assert.False(isStop (decide 1 true (Some 999) 45 (PlayersCount.Ok 2) (Activity.Ok [])))
+
+[]
+let ``activity log error is kept`` () =
+ Assert.False(isStop (decide 1 true (Some 999) 45 (PlayersCount.Ok 0) (Activity.Error "log unavailable")))
+
+[]
+let ``recent activity is kept even with zero players`` () =
+ Assert.False(isStop (decide 1 true (Some 999) 45 (PlayersCount.Ok 0) (Activity.Ok [ "[JOIN] bob" ])))
+
+[]
+let ``no players, no activity, past grace period stops`` () =
+ Assert.True(isStop (decide 1 true (Some 999) 45 (PlayersCount.Ok 0) (Activity.Ok [])))
+
+[]
+let ``NotConfigured players with no activity past grace period stops`` () =
+ Assert.True(isStop (decide 1 true (Some 999) 45 PlayersCount.NotConfigured (Activity.Ok [])))
+
+[]
+let ``pod age exactly at the grace boundary is eligible to stop`` () =
+ Assert.True(isStop (decide 1 true (Some 45) 45 (PlayersCount.Ok 0) (Activity.Ok [])))
diff --git a/tests/Fizruk.Tests/K8sGatewayTests.fs b/tests/Fizruk.Tests/K8sGatewayTests.fs
new file mode 100644
index 00000000..59b297dc
--- /dev/null
+++ b/tests/Fizruk.Tests/K8sGatewayTests.fs
@@ -0,0 +1,19 @@
+module Fizruk.Tests.K8sGatewayTests
+
+open System
+open Fizruk
+open Xunit
+
+[]
+let ``asUtcOffset treats an Unspecified-Kind DateTime as UTC, not local`` () =
+ let dt = DateTime(2026, 9, 19, 12, 0, 0, DateTimeKind.Unspecified)
+ let result = K8sGateway.asUtcOffset dt
+ Assert.Equal(TimeSpan.Zero, result.Offset)
+ Assert.Equal(DateTime(2026, 9, 19, 12, 0, 0, DateTimeKind.Utc), result.UtcDateTime)
+
+[]
+let ``asUtcOffset preserves an already-UTC DateTime`` () =
+ let dt = DateTime(2026, 9, 19, 12, 0, 0, DateTimeKind.Utc)
+ let result = K8sGateway.asUtcOffset dt
+ Assert.Equal(TimeSpan.Zero, result.Offset)
+ Assert.Equal(dt, result.UtcDateTime)
diff --git a/tests/Fizruk.Tests/Program.fs b/tests/Fizruk.Tests/Program.fs
new file mode 100644
index 00000000..394a42f3
--- /dev/null
+++ b/tests/Fizruk.Tests/Program.fs
@@ -0,0 +1,8 @@
+namespace Fizruk.Tests
+
+open Xunit
+open Fizruk.Tests.ContainerFixture
+
+[]
+[)>]
+do ()
diff --git a/tests/Fizruk.Tests/RakNetTests.fs b/tests/Fizruk.Tests/RakNetTests.fs
new file mode 100644
index 00000000..dfddc1ff
--- /dev/null
+++ b/tests/Fizruk.Tests/RakNetTests.fs
@@ -0,0 +1,70 @@
+module Fizruk.Tests.RakNetTests
+
+open System
+open System.Buffers.Binary
+open System.Text
+open Fizruk
+open Xunit
+
+/// Hand-builds a realistic unconnected-pong packet, independently of RakNet's own
+/// encoder, so decodePong is verified against the wire format.
+let private buildPong (serverTime: int64) (serverGuid: int64) (motd: string) : byte[] =
+ let motdBytes = Encoding.UTF8.GetBytes motd
+ let buf = Array.zeroCreate (1 + 8 + 8 + 16 + 2 + motdBytes.Length)
+ buf.[0] <- RakNet.UnconnectedPongId
+ BinaryPrimitives.WriteInt64BigEndian(Span(buf, 1, 8), serverTime)
+ BinaryPrimitives.WriteInt64BigEndian(Span(buf, 9, 8), serverGuid)
+ Array.blit RakNet.magic 0 buf 17 16
+ BinaryPrimitives.WriteUInt16BigEndian(Span(buf, 33, 2), uint16 motdBytes.Length)
+ Array.blit motdBytes 0 buf 35 motdBytes.Length
+ buf
+
+let private sampleMotd = "MCPE;Fizruk Creative;686;1.21.0;3;10;1234567890;Bedrock level;Creative;1;19132;19133;"
+
+[]
+let ``encodePing lays out id, timestamp, magic, and client guid`` () =
+ let packet = RakNet.encodePing 42L 99L
+ Assert.Equal(RakNet.UnconnectedPingId, packet.[0])
+ Assert.Equal(42L, BinaryPrimitives.ReadInt64BigEndian(ReadOnlySpan(packet, 1, 8)))
+ Assert.Equal(RakNet.magic, packet.[9..24])
+ Assert.Equal(99L, BinaryPrimitives.ReadInt64BigEndian(ReadOnlySpan(packet, 25, 8)))
+
+[]
+let ``decodePong parses time, server guid, and motd from a well-formed pong`` () =
+ let pong = buildPong 123456789L 987654321L sampleMotd
+ match RakNet.decodePong pong with
+ | None -> Assert.Fail "expected Some"
+ | Some(time, serverGuid, motd) ->
+ Assert.Equal(123456789L, time)
+ Assert.Equal(987654321L, serverGuid)
+ Assert.Equal(sampleMotd, motd)
+
+[]
+let ``decodePong rejects a packet with the wrong id`` () =
+ let pong = buildPong 1L 2L sampleMotd
+ pong.[0] <- 0x00uy
+ Assert.Equal(None, RakNet.decodePong pong)
+
+[]
+let ``decodePong rejects a packet with corrupted magic`` () =
+ let pong = buildPong 1L 2L sampleMotd
+ pong.[20] <- pong.[20] ^^^ 0xFFuy
+ Assert.Equal(None, RakNet.decodePong pong)
+
+[]
+let ``decodePong rejects a packet shorter than the fixed header`` () =
+ Assert.Equal(None, RakNet.decodePong (Array.zeroCreate 10))
+
+[]
+let ``parseOnlineMax reads fields 5 and 6 of the MCPE motd`` () =
+ Assert.Equal(Some(3, 10), RakNet.parseOnlineMax sampleMotd)
+
+[]
+let ``parseOnlineMax rejects a motd with too few fields`` () =
+ Assert.Equal(None, RakNet.parseOnlineMax "MCPE;only;three")
+
+[]
+let ``end-to-end decode of a built pong feeds parseOnlineMax`` () =
+ let pong = buildPong 1L 2L sampleMotd
+ let onlineMax = RakNet.decodePong pong |> Option.bind (fun (_, _, motd) -> RakNet.parseOnlineMax motd)
+ Assert.Equal(Some(3, 10), onlineMax)
diff --git a/tests/Fizruk.Tests/RconTests.fs b/tests/Fizruk.Tests/RconTests.fs
new file mode 100644
index 00000000..07fe8656
--- /dev/null
+++ b/tests/Fizruk.Tests/RconTests.fs
@@ -0,0 +1,90 @@
+module Fizruk.Tests.RconTests
+
+open System
+open System.IO
+open System.Threading
+open Fizruk
+open Xunit
+
+/// A Stream with independent read/write ends — reads come from a canned buffer,
+/// writes land in a scratch one — so Rcon.runProtocol needs no socket to test.
+type private DuplexTestStream(readSource: Stream, writeSink: Stream) =
+ inherit Stream()
+ override _.CanRead = true
+ override _.CanWrite = true
+ override _.CanSeek = false
+ override _.Length = raise (NotSupportedException())
+ override _.Position
+ with get () = raise (NotSupportedException())
+ and set (_) = raise (NotSupportedException())
+ override _.Flush() = writeSink.Flush()
+ override _.Read(buffer, offset, count) = readSource.Read(buffer, offset, count)
+ override _.Write(buffer, offset, count) = writeSink.Write(buffer, offset, count)
+ override _.Seek(_offset, _origin) = raise (NotSupportedException())
+ override _.SetLength(_value) = raise (NotSupportedException())
+ override _.ReadAsync(buffer: Memory, cancellationToken) = readSource.ReadAsync(buffer, cancellationToken)
+ override _.WriteAsync(buffer: ReadOnlyMemory, cancellationToken) = writeSink.WriteAsync(buffer, cancellationToken)
+
+[]
+let ``encodePacket then decodePacket round-trips id, type, and body`` () =
+ let packet = Rcon.encodePacket 7 Rcon.ServerdataExecCommand "/players online"
+ // The leading 4 bytes are the size field; decodePacket takes everything after it.
+ let payload = packet.[4..]
+ let id, packetType, body = Rcon.decodePacket payload
+ Assert.Equal(7, id)
+ Assert.Equal(Rcon.ServerdataExecCommand, packetType)
+ Assert.Equal("/players online", body)
+
+[]
+let ``encodePacket size field equals the payload length`` () =
+ let packet = Rcon.encodePacket 1 Rcon.ServerdataAuth "secret"
+ let size = System.BitConverter.ToInt32(packet, 0)
+ Assert.Equal(packet.Length - 4, size)
+
+[]
+let ``parsePlayersResponse drops the header and returns each name`` () =
+ let body = "Online players (2):\nAlice\nBob"
+ Assert.Equal([ "Alice"; "Bob" ], Rcon.parsePlayersResponse body)
+
+[]
+let ``parsePlayersResponse with zero players returns an empty list`` () =
+ let body = "Online players (0):"
+ Assert.Equal([], Rcon.parsePlayersResponse body)
+
+[]
+let ``parsePlayersResponse tolerates CRLF line endings`` () =
+ let body = "Online players (1):\r\nCarol\r\n"
+ Assert.Equal([ "Carol" ], Rcon.parsePlayersResponse body)
+
+[]
+let ``runProtocol skips Factorio's empty type-0 auth echo before the real auth response`` () =
+ task {
+ let echoPacket = Rcon.encodePacket 1 Rcon.ServerdataResponseValue ""
+ let authOkPacket = Rcon.encodePacket 1 Rcon.ServerdataAuthResponse ""
+ let playersPacket = Rcon.encodePacket 2 Rcon.ServerdataExecCommand "Online players (2):\nAlice\nBob"
+ let serverBytes = Array.concat [ echoPacket; authOkPacket; playersPacket ]
+ use readSource = new MemoryStream(serverBytes)
+ use writeSink = new MemoryStream()
+ use duplex = new DuplexTestStream(readSource, writeSink)
+
+ let! result = Rcon.runProtocol duplex "secret" CancellationToken.None
+
+ match result with
+ | Ok names -> Assert.Equal([ "Alice"; "Bob" ], names)
+ | Error e -> Assert.Fail $"expected Ok, got Error \"{e}\""
+ }
+
+[]
+let ``runProtocol reports auth failure only once the real auth response id is -1`` () =
+ task {
+ let echoPacket = Rcon.encodePacket 1 Rcon.ServerdataResponseValue ""
+ let authFailPacket = Rcon.encodePacket -1 Rcon.ServerdataAuthResponse ""
+ let serverBytes = Array.append echoPacket authFailPacket
+ use readSource = new MemoryStream(serverBytes)
+ use writeSink = new MemoryStream()
+ use duplex = new DuplexTestStream(readSource, writeSink)
+
+ let! result = Rcon.runProtocol duplex "wrong-password" CancellationToken.None
+
+ Assert.Equal(Error "RCON authentication failed", result)
+ }