From 88f979f8cb47f80443924c7cd716b4ef8c32bf23 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:16:04 +0100 Subject: [PATCH 1/8] feat(fizruk): add Fizruk on-demand game server bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram bot that starts/stops game server Deployments on /start and /stop and scales them back to 0 once nobody's playing. Config comes from a mounted JSON file (FIZRUK_CONFIG_PATH) mapping games to their Deployment/pod selectors and chats to the games they control; player counts come from a pure RCON or RakNet probe depending on the game. Reuses BotInfra's webhook/observability/readiness plumbing like the other bots but has no database — idle checks run on a plain BackgroundService instead of BotInfra.ScheduledJobs. Co-Authored-By: Claude Fable 5.1 --- Directory.Packages.props | 3 + bots.slnx | 2 + src/Fizruk/BotFrontend.fs | 57 ++++++ src/Fizruk/Commands.fs | 82 +++++++++ src/Fizruk/Config.fs | 142 +++++++++++++++ src/Fizruk/Core.fs | 259 +++++++++++++++++++++++++++ src/Fizruk/Domain.fs | 37 ++++ src/Fizruk/Fizruk.fsproj | 28 +++ src/Fizruk/IdleCheckHostedService.fs | 22 +++ src/Fizruk/IdleDecision.fs | 49 +++++ src/Fizruk/K8sGateway.fs | 74 ++++++++ src/Fizruk/Notifier.fs | 21 +++ src/Fizruk/PlayerProbe.fs | 31 ++++ src/Fizruk/Program.fs | 85 +++++++++ src/Fizruk/RakNet.fs | 76 ++++++++ src/Fizruk/Rcon.fs | 89 +++++++++ src/Fizruk/Telemetry.fs | 17 ++ 17 files changed, 1074 insertions(+) create mode 100644 src/Fizruk/BotFrontend.fs create mode 100644 src/Fizruk/Commands.fs create mode 100644 src/Fizruk/Config.fs create mode 100644 src/Fizruk/Core.fs create mode 100644 src/Fizruk/Domain.fs create mode 100644 src/Fizruk/Fizruk.fsproj create mode 100644 src/Fizruk/IdleCheckHostedService.fs create mode 100644 src/Fizruk/IdleDecision.fs create mode 100644 src/Fizruk/K8sGateway.fs create mode 100644 src/Fizruk/Notifier.fs create mode 100644 src/Fizruk/PlayerProbe.fs create mode 100644 src/Fizruk/Program.fs create mode 100644 src/Fizruk/RakNet.fs create mode 100644 src/Fizruk/Rcon.fs create mode 100644 src/Fizruk/Telemetry.fs diff --git a/Directory.Packages.props b/Directory.Packages.props index eaf32d7..5905c11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,9 @@ + + + diff --git a/bots.slnx b/bots.slnx index 6397936..2af3c0c 100644 --- a/bots.slnx +++ b/bots.slnx @@ -3,6 +3,7 @@ + @@ -14,6 +15,7 @@ + diff --git a/src/Fizruk/BotFrontend.fs b/src/Fizruk/BotFrontend.fs new file mode 100644 index 0000000..e7d3ec2 --- /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 0000000..a8630fd --- /dev/null +++ b/src/Fizruk/Commands.fs @@ -0,0 +1,82 @@ +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 -> + if List.contains name allowed then Resolution.Resolved name else 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 0000000..7d5c9ab --- /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 0000000..01e683c --- /dev/null +++ b/src/Fizruk/Core.fs @@ -0,0 +1,259 @@ +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) + return if ready then "Already running." else "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) + + 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 0000000..a695876 --- /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 0000000..565c98f --- /dev/null +++ b/src/Fizruk/Fizruk.fsproj @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Fizruk/IdleCheckHostedService.fs b/src/Fizruk/IdleCheckHostedService.fs new file mode 100644 index 0000000..bb10182 --- /dev/null +++ b/src/Fizruk/IdleCheckHostedService.fs @@ -0,0 +1,22 @@ +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 { + 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 0000000..a4f162d --- /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/K8sGateway.fs b/src/Fizruk/K8sGateway.fs new file mode 100644 index 0000000..a1d18ea --- /dev/null +++ b/src/Fizruk/K8sGateway.fs @@ -0,0 +1,74 @@ +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 + +/// 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 DateTimeOffset + 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 DateTimeOffset }) + |> List.ofSeq + } diff --git a/src/Fizruk/Notifier.fs b/src/Fizruk/Notifier.fs new file mode 100644 index 0000000..9791520 --- /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 0000000..2219303 --- /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 0000000..b83c77d --- /dev/null +++ b/src/Fizruk/Program.fs @@ -0,0 +1,85 @@ +// 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.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 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) + +%builder.Services.AddSingleton(fun _ -> + 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() + +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 0000000..42bc4b1 --- /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 0000000..7bc1852 --- /dev/null +++ b/src/Fizruk/Rcon.fs @@ -0,0 +1,89 @@ +namespace Fizruk + +open System +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 + + /// 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 + | [] -> [] + + /// Authenticates then runs "/players online" over TCP, returning player names. + 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() + + let readExact (buf: byte[]) = + task { + let mutable offset = 0 + while offset < buf.Length do + let! n = stream.ReadAsync(buf.AsMemory(offset, buf.Length - offset), cts.Token) + 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(), cts.Token) + let! authId, _, _ = readPacket () + if authId = -1 then + return Error "RCON authentication failed" + else + let execPacket = encodePacket 2 ServerdataExecCommand "/players online" + do! stream.WriteAsync(execPacket.AsMemory(), cts.Token) + let! _, _, body = readPacket () + return Ok(parsePlayersResponse body) + 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 0000000..8018713 --- /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") From 3ed65720b5f694ca9a8b8229d838b8e2906f5b23 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:16:09 +0100 Subject: [PATCH 2/8] test(fizruk): add hermetic unit tests for Fizruk Covers command parsing and game-ACL resolution (including the single-game-chat shortcut and unauthorized-chat cases), IdleDecision. decide table-driven over every rule, RCON packet framing and player parsing, RakNet ping/pong packet bytes, config validation failures, and start/stop/status/watcher/idle-stop flows against a fake Kubernetes gateway and a fake notifier. No database, no containers. Co-Authored-By: Claude Fable 5.1 --- tests/Fizruk.Tests/CommandParsingTests.fs | 78 ++++++++++ tests/Fizruk.Tests/ConfigTests.fs | 76 ++++++++++ tests/Fizruk.Tests/CoreFlowTests.fs | 175 ++++++++++++++++++++++ tests/Fizruk.Tests/Fakes.fs | 111 ++++++++++++++ tests/Fizruk.Tests/Fizruk.Tests.fsproj | 33 ++++ tests/Fizruk.Tests/IdleDecisionTests.fs | 53 +++++++ tests/Fizruk.Tests/Program.fs | 6 + tests/Fizruk.Tests/RakNetTests.fs | 70 +++++++++ tests/Fizruk.Tests/RconTests.fs | 35 +++++ 9 files changed, 637 insertions(+) create mode 100644 tests/Fizruk.Tests/CommandParsingTests.fs create mode 100644 tests/Fizruk.Tests/ConfigTests.fs create mode 100644 tests/Fizruk.Tests/CoreFlowTests.fs create mode 100644 tests/Fizruk.Tests/Fakes.fs create mode 100644 tests/Fizruk.Tests/Fizruk.Tests.fsproj create mode 100644 tests/Fizruk.Tests/IdleDecisionTests.fs create mode 100644 tests/Fizruk.Tests/Program.fs create mode 100644 tests/Fizruk.Tests/RakNetTests.fs create mode 100644 tests/Fizruk.Tests/RconTests.fs diff --git a/tests/Fizruk.Tests/CommandParsingTests.fs b/tests/Fizruk.Tests/CommandParsingTests.fs new file mode 100644 index 0000000..7a5f28d --- /dev/null +++ b/tests/Fizruk.Tests/CommandParsingTests.fs @@ -0,0 +1,78 @@ +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) diff --git a/tests/Fizruk.Tests/ConfigTests.fs b/tests/Fizruk.Tests/ConfigTests.fs new file mode 100644 index 0000000..8c9ebb8 --- /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/CoreFlowTests.fs b/tests/Fizruk.Tests/CoreFlowTests.fs new file mode 100644 index 0000000..85f89fe --- /dev/null +++ b/tests/Fizruk.Tests/CoreFlowTests.fs @@ -0,0 +1,175 @@ +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 ``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/Fakes.fs b/tests/Fizruk.Tests/Fakes.fs new file mode 100644 index 0000000..3c46d63 --- /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 0000000..25ffcec --- /dev/null +++ b/tests/Fizruk.Tests/Fizruk.Tests.fsproj @@ -0,0 +1,33 @@ + + + + Exe + false + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Fizruk.Tests/IdleDecisionTests.fs b/tests/Fizruk.Tests/IdleDecisionTests.fs new file mode 100644 index 0000000..d98de8f --- /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/Program.fs b/tests/Fizruk.Tests/Program.fs new file mode 100644 index 0000000..248b760 --- /dev/null +++ b/tests/Fizruk.Tests/Program.fs @@ -0,0 +1,6 @@ +namespace Fizruk.Tests + +open Xunit + +[] +do () diff --git a/tests/Fizruk.Tests/RakNetTests.fs b/tests/Fizruk.Tests/RakNetTests.fs new file mode 100644 index 0000000..dfddc1f --- /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 0000000..b9b00ae --- /dev/null +++ b/tests/Fizruk.Tests/RconTests.fs @@ -0,0 +1,35 @@ +module Fizruk.Tests.RconTests + +open Fizruk +open Xunit + +[] +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) From 11a14fa2360128ed123584323100d95ce0cd12b7 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:16:15 +0100 Subject: [PATCH 3/8] ci(fizruk): add build and deploy workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fizruk-build.yml runs the PR gate via the shared _bot-build.yml. fizruk-deploy.yml pushes ghcr.io/szer/fizruk on merge to main; it does not call _bot-deploy.yml since that requires Flyway, a database, and a WireGuard peer Fizruk doesn't have — it copies just the GHCR login/buildx/build-push steps, matching the arm64 platform the other bots deploy to. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/fizruk-build.yml | 14 +++++++ .github/workflows/fizruk-deploy.yml | 63 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 .github/workflows/fizruk-build.yml create mode 100644 .github/workflows/fizruk-deploy.yml diff --git a/.github/workflows/fizruk-build.yml b/.github/workflows/fizruk-build.yml new file mode 100644 index 0000000..6df94c8 --- /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 0000000..a7ea5ee --- /dev/null +++ b/.github/workflows/fizruk-deploy.yml @@ -0,0 +1,63 @@ +name: "Fizruk: Deploy" + +# Fizruk has no database, so unlike the other bots it skips _bot-deploy.yml +# (Flyway, WireGuard, Loki/Prometheus verification) and just tests + pushes. +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: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-22.04-arm + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Tests + run: dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" + + - name: Upload dotnet test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: "**/*.trx" + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push to GHCR + uses: docker/build-push-action@v5 + with: + push: true + tags: ghcr.io/szer/fizruk:${{ github.sha }}, ghcr.io/szer/fizruk:latest + file: ./src/Dockerfile.bot + build-args: BOT_PROJECT=Fizruk + platforms: linux/arm64 From 5fcf4ea97c1bc23280fa5b9529ebcf749b13563e Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:16:19 +0100 Subject: [PATCH 4/8] docs(fizruk): document Fizruk in the top-level README Adds it to the bot table and repository-structure listing, plus a short section covering commands, config schema, idle shutdown, env vars, and the GHCR image. Co-Authored-By: Claude Fable 5.1 --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index c7fc142..4abe4f9 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,32 @@ 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), and each RCON-probed game's `passwordEnv` variable + (e.g. `FACTORIO_RCON_PASSWORD`). +- **GHCR image**: `ghcr.io/szer/fizruk`. + ## License MIT. See [LICENSE](LICENSE). From 49c2c62d9d891e2dca3f7f4e6c018ecbf6dacb8a Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:33:49 +0100 Subject: [PATCH 5/8] ci(fizruk): unify deploy with the other bots via has-database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _bot-deploy.yml grows a has-database input (default true) that gates every Flyway/migration step and the DB_PROD_* secrets, which become optional (workflow_call can't make secrets conditional, so the migration steps are the only consumers). Existing callers are unaffected — they keep passing migrations-path/db-name/DB secrets exactly as before. fizruk-deploy.yml is now a thin caller with has-database: false, replacing the bespoke workflow that duplicated the GHCR build+push steps; it gets the WireGuard step and ArgoCD verify-deploy for free, like every other bot. The first run of this workflow will fail at verify-deploy until the fizruk ArgoCD app and the WIREGUARD_CONFIG_FIZRUK secret exist on the my-infra side — the image is already pushed to GHCR by then. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/_bot-deploy.yml | 28 ++++++++---- .github/workflows/fizruk-deploy.yml | 69 +++++++++++------------------ 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/.github/workflows/_bot-deploy.yml b/.github/workflows/_bot-deploy.yml index d766fd5..6d86b51 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-deploy.yml b/.github/workflows/fizruk-deploy.yml index a7ea5ee..290e05b 100644 --- a/.github/workflows/fizruk-deploy.yml +++ b/.github/workflows/fizruk-deploy.yml @@ -1,7 +1,5 @@ name: "Fizruk: Deploy" -# Fizruk has no database, so unlike the other bots it skips _bot-deploy.yml -# (Flyway, WireGuard, Loki/Prometheus verification) and just tests + pushes. on: push: branches: [ "main" ] @@ -18,46 +16,33 @@ on: workflow_dispatch: permissions: - contents: read + # 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: - build-and-push: - runs-on: ubuntu-22.04-arm - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - - name: Tests - run: dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" - - - name: Upload dotnet test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: "**/*.trx" - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push to GHCR - uses: docker/build-push-action@v5 - with: - push: true - tags: ghcr.io/szer/fizruk:${{ github.sha }}, ghcr.io/szer/fizruk:latest - file: ./src/Dockerfile.bot - build-args: BOT_PROJECT=Fizruk - platforms: linux/arm64 + 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 }} From ce186af407277092f6b2232c7f5f09bc9c08b918 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:34:01 +0100 Subject: [PATCH 6/8] test(fizruk): add container smoke tests against the real bot image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds src/Dockerfile.bot with BOT_PROJECT=Fizruk (same as the other bots' container fixtures) and runs it against a FakeTgApi container. FIZRUK_FAKE_K8S=true selects the new InMemoryK8sGateway so the container needs no real cluster. Covers: the image builds and the container starts healthy (/health), a /status from an allowed chat produces the expected sendMessage, and a message from an unknown chat produces none. Runs in the same tests/Fizruk.Tests project and assembly (AssemblyFixture), picked up by fizruk-build.yml's existing test command with no workflow change needed. Uses a host-file bind mount (not Testcontainers' WithResourceMapping) for the mounted config — WithResourceMapping's docker-cp tar-stream copy 500s ("broken pipe") against the local podman version. Co-Authored-By: Claude Fable 5.1 --- src/Fizruk/Fizruk.fsproj | 1 + src/Fizruk/InMemoryK8sGateway.fs | 13 ++ src/Fizruk/Program.fs | 9 +- tests/Fizruk.Tests/ContainerTests.fs | 67 +++++++++ tests/Fizruk.Tests/FakeCallHelpers.fs | 33 +++++ tests/Fizruk.Tests/Fizruk.Tests.fsproj | 7 + tests/Fizruk.Tests/FizrukContainerFixture.fs | 139 +++++++++++++++++++ tests/Fizruk.Tests/Program.fs | 2 + 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 src/Fizruk/InMemoryK8sGateway.fs create mode 100644 tests/Fizruk.Tests/ContainerTests.fs create mode 100644 tests/Fizruk.Tests/FakeCallHelpers.fs create mode 100644 tests/Fizruk.Tests/FizrukContainerFixture.fs diff --git a/src/Fizruk/Fizruk.fsproj b/src/Fizruk/Fizruk.fsproj index 565c98f..f17edc0 100644 --- a/src/Fizruk/Fizruk.fsproj +++ b/src/Fizruk/Fizruk.fsproj @@ -5,6 +5,7 @@ + diff --git a/src/Fizruk/InMemoryK8sGateway.fs b/src/Fizruk/InMemoryK8sGateway.fs new file mode 100644 index 0000000..4e2cafd --- /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/Program.fs b/src/Fizruk/Program.fs index b83c77d..a7bb27c 100644 --- a/src/Fizruk/Program.fs +++ b/src/Fizruk/Program.fs @@ -38,9 +38,14 @@ 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 _ -> - let k8sConfig = k8s.KubernetesClientConfiguration.InClusterConfig() - KubernetesGateway(new k8s.Kubernetes(k8sConfig)) :> IK8sGateway) + 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>()) diff --git a/tests/Fizruk.Tests/ContainerTests.fs b/tests/Fizruk.Tests/ContainerTests.fs new file mode 100644 index 0000000..71f6f9c --- /dev/null +++ b/tests/Fizruk.Tests/ContainerTests.fs @@ -0,0 +1,67 @@ +module Fizruk.Tests.ContainerTests + +open System.Net +open System.Net.Http +open BotTestInfra +open Fizruk.Tests.ContainerFixture +open Fizruk.Tests.FakeCallHelpers +open Xunit + +/// 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)) + } diff --git a/tests/Fizruk.Tests/FakeCallHelpers.fs b/tests/Fizruk.Tests/FakeCallHelpers.fs new file mode 100644 index 0000000..e784cf6 --- /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/Fizruk.Tests.fsproj b/tests/Fizruk.Tests/Fizruk.Tests.fsproj index 25ffcec..68cbc6d 100644 --- a/tests/Fizruk.Tests/Fizruk.Tests.fsproj +++ b/tests/Fizruk.Tests/Fizruk.Tests.fsproj @@ -15,17 +15,24 @@ + + + + + + + diff --git a/tests/Fizruk.Tests/FizrukContainerFixture.fs b/tests/Fizruk.Tests/FizrukContainerFixture.fs new file mode 100644 index 0000000..b0408b3 --- /dev/null +++ b/tests/Fizruk.Tests/FizrukContainerFixture.fs @@ -0,0 +1,139 @@ +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" + +/// 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() + + let mutable botHttp: HttpClient = null + let mutable fakeTgHttp: HttpClient = null + let mutable testArtifactsDir: string = null + + 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! botContainer.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 + } :> Task) + + member _.DisposeAsync() = + ValueTask(task { + let! _ = dumpContainerLogs testArtifactsDir "bot" botContainer + 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! fakeTgContainer.DisposeAsync() + File.Delete configFilePath + } :> Task) + + member _.BotHttp = botHttp + + 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/Program.fs b/tests/Fizruk.Tests/Program.fs index 248b760..394a42f 100644 --- a/tests/Fizruk.Tests/Program.fs +++ b/tests/Fizruk.Tests/Program.fs @@ -1,6 +1,8 @@ namespace Fizruk.Tests open Xunit +open Fizruk.Tests.ContainerFixture [] +[)>] do () From 2c18c4f1dc3e84a3eb479b6f5e0d1052ad3ece63 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 15:52:29 +0100 Subject: [PATCH 7/8] fix(fizruk): address code review findings on the RCON auth loop, watcher re-arming, and case-insensitive game names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rcon.probeAsync treated the first packet after SERVERDATA_AUTH as the auth verdict, but Factorio (like Valve servers) echoes an empty SERVERDATA_RESPONSE_VALUE (type 0) first. Extracted the auth+exec exchange into runProtocol(stream, ...) over any Stream, and added a bounded loop that reads until the real SERVERDATA_AUTH_RESPONSE (type 2) is seen before checking id = -1. - GameCore.Start only armed the start-timeout watcher on a fresh scale- up; the "Already starting." branch (and a Fizruk pod restart mid- start) left a game with no watcher and therefore no eventual "ready" or timeout notification. EnsureWatcher is now also called from "Already starting.", and a new ArmPendingWatchers() re-arms every game already scaled up but not yet Ready — called once at IdleCheckHostedService startup. - Commands.resolveGame matched an explicit game-name argument case- sensitively; it's now OrdinalIgnoreCase, resolving to the configured spelling regardless of how the user typed it. - K8sGateway now converts pod StartTime / node CreationTimestamp via asUtcOffset, which treats an Unspecified DateTime.Kind as UTC (as k8s API timestamps always are) instead of letting DateTimeOffset's default constructor apply the local offset. Co-Authored-By: Claude Fable 5.1 --- src/Fizruk/Commands.fs | 5 +- src/Fizruk/Core.fs | 19 ++++- src/Fizruk/IdleCheckHostedService.fs | 5 ++ src/Fizruk/K8sGateway.fs | 13 +++- src/Fizruk/Rcon.fs | 95 +++++++++++++++-------- tests/Fizruk.Tests/CommandParsingTests.fs | 10 +++ tests/Fizruk.Tests/CoreFlowTests.fs | 31 ++++++++ tests/Fizruk.Tests/Fizruk.Tests.fsproj | 1 + tests/Fizruk.Tests/K8sGatewayTests.fs | 19 +++++ tests/Fizruk.Tests/RconTests.fs | 55 +++++++++++++ 10 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 tests/Fizruk.Tests/K8sGatewayTests.fs diff --git a/src/Fizruk/Commands.fs b/src/Fizruk/Commands.fs index a8630fd..37fdc9d 100644 --- a/src/Fizruk/Commands.fs +++ b/src/Fizruk/Commands.fs @@ -72,7 +72,10 @@ module Commands = | Some allowed -> match gameArg with | Some name -> - if List.contains name allowed then Resolution.Resolved name else Resolution.Unknown allowed + // 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 diff --git a/src/Fizruk/Core.fs b/src/Fizruk/Core.fs index 01e683c..65c7858 100644 --- a/src/Fizruk/Core.fs +++ b/src/Fizruk/Core.fs @@ -132,7 +132,13 @@ type GameCore(config: FizrukConfig, k8s: IK8sGateway, notifier: INotifier, time: if desired > 0 then let! pods = k8s.ListPods(game.Namespace, game.PodSelector) let ready = pods |> List.exists (fun p -> not p.Terminating && p.Ready) - return if ready then "Already running." else "Already starting." + 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 @@ -172,6 +178,17 @@ type GameCore(config: FizrukConfig, k8s: IK8sGateway, notifier: INotifier, time: 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 diff --git a/src/Fizruk/IdleCheckHostedService.fs b/src/Fizruk/IdleCheckHostedService.fs index bb10182..1c55939 100644 --- a/src/Fizruk/IdleCheckHostedService.fs +++ b/src/Fizruk/IdleCheckHostedService.fs @@ -13,6 +13,11 @@ type IdleCheckHostedService(core: GameCore, time: TimeProvider, logger: ILogger< 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 diff --git a/src/Fizruk/K8sGateway.fs b/src/Fizruk/K8sGateway.fs index a1d18ea..92c185d 100644 --- a/src/Fizruk/K8sGateway.fs +++ b/src/Fizruk/K8sGateway.fs @@ -16,6 +16,15 @@ type IK8sGateway = 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) = @@ -49,7 +58,7 @@ type KubernetesGateway(client: Kubernetes) = Phase = p.Status.Phase Ready = isReady p.Status.Conditions Terminating = p.Metadata.DeletionTimestamp.HasValue - StartTime = p.Status.StartTime |> Option.ofNullable |> Option.map DateTimeOffset + StartTime = p.Status.StartTime |> Option.ofNullable |> Option.map K8sGateway.asUtcOffset NodeName = p.Spec.NodeName |> Option.ofObj }) |> List.ofSeq } @@ -69,6 +78,6 @@ type KubernetesGateway(client: Kubernetes) = |> Seq.map (fun n -> { Name = n.Metadata.Name Ready = isNodeReady n.Status.Conditions - CreationTimestamp = n.Metadata.CreationTimestamp |> Option.ofNullable |> Option.map DateTimeOffset }) + CreationTimestamp = n.Metadata.CreationTimestamp |> Option.ofNullable |> Option.map K8sGateway.asUtcOffset }) |> List.ofSeq } diff --git a/src/Fizruk/Rcon.fs b/src/Fizruk/Rcon.fs index 7bc1852..27fd1ca 100644 --- a/src/Fizruk/Rcon.fs +++ b/src/Fizruk/Rcon.fs @@ -1,6 +1,7 @@ namespace Fizruk open System +open System.IO open System.Net.Sockets open System.Text open System.Threading @@ -16,6 +17,17 @@ module Rcon = [] 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 @@ -45,7 +57,57 @@ module Rcon = | _header :: names -> names | [] -> [] - /// Authenticates then runs "/players online" over TCP, returning player 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) @@ -53,36 +115,7 @@ module Rcon = use client = new TcpClient() do! client.ConnectAsync(host, port, cts.Token) use stream = client.GetStream() - - let readExact (buf: byte[]) = - task { - let mutable offset = 0 - while offset < buf.Length do - let! n = stream.ReadAsync(buf.AsMemory(offset, buf.Length - offset), cts.Token) - 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(), cts.Token) - let! authId, _, _ = readPacket () - if authId = -1 then - return Error "RCON authentication failed" - else - let execPacket = encodePacket 2 ServerdataExecCommand "/players online" - do! stream.WriteAsync(execPacket.AsMemory(), cts.Token) - let! _, _, body = readPacket () - return Ok(parsePlayersResponse body) + return! runProtocol stream password cts.Token with | :? OperationCanceledException -> return Error "RCON probe timed out" | ex -> return Error ex.Message diff --git a/tests/Fizruk.Tests/CommandParsingTests.fs b/tests/Fizruk.Tests/CommandParsingTests.fs index 7a5f28d..4ed6773 100644 --- a/tests/Fizruk.Tests/CommandParsingTests.fs +++ b/tests/Fizruk.Tests/CommandParsingTests.fs @@ -76,3 +76,13 @@ let ``multi-game chat with no name reports every game for status`` () = 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/CoreFlowTests.fs b/tests/Fizruk.Tests/CoreFlowTests.fs index 85f89fe..999def3 100644 --- a/tests/Fizruk.Tests/CoreFlowTests.fs +++ b/tests/Fizruk.Tests/CoreFlowTests.fs @@ -117,6 +117,37 @@ let ``watcher posts ready once the pod becomes Ready`` () = 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 { diff --git a/tests/Fizruk.Tests/Fizruk.Tests.fsproj b/tests/Fizruk.Tests/Fizruk.Tests.fsproj index 68cbc6d..3da452b 100644 --- a/tests/Fizruk.Tests/Fizruk.Tests.fsproj +++ b/tests/Fizruk.Tests/Fizruk.Tests.fsproj @@ -14,6 +14,7 @@ + diff --git a/tests/Fizruk.Tests/K8sGatewayTests.fs b/tests/Fizruk.Tests/K8sGatewayTests.fs new file mode 100644 index 0000000..59b297d --- /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/RconTests.fs b/tests/Fizruk.Tests/RconTests.fs index b9b00ae..07fe865 100644 --- a/tests/Fizruk.Tests/RconTests.fs +++ b/tests/Fizruk.Tests/RconTests.fs @@ -1,8 +1,30 @@ 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" @@ -33,3 +55,36 @@ let ``parsePlayersResponse with zero players returns an empty list`` () = 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) + } From 1bafdb01bebbf4439d3bb699a1b8e12bd38c72f8 Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Sat, 19 Sep 2026 16:01:46 +0100 Subject: [PATCH 8/8] feat(fizruk): self-register the Telegram webhook when BOT_WEBHOOK_URL is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds BotInfra.WebhookRegistration, an opt-in helper any bot can adopt: when the given webhook URL is blank it logs "webhook self-registration disabled" and does nothing; otherwise it logs getWebhookInfo's before-state (best-effort — the secret token isn't in that response, so this can't gate on it) and then always calls setWebhook (url=BOT_WEBHOOK_URL, secret_token=BOT_AUTH_TOKEN, allowed_updates=["message"], drop_pending_updates=false), retrying a few times with backoff and never throwing. Fizruk wires it up via a small hosted service that fires the call without blocking the host from serving, using its existing ITelegramApi (so TELEGRAM_API_URL redirects it to FakeTgApi in tests exactly like every other outbound call). Only Fizruk's Program.fs calls it — no other bot's behavior changes. Extends the existing container fixture with a second bot container (same cached image, BOT_WEBHOOK_URL set) sharing the same FakeTgApi and network, and snapshots its setWebhook call once at startup so the assertion is independent of other tests' ClearFakeCalls(). Co-Authored-By: Claude Fable 5.1 --- README.md | 11 ++- src/BotInfra/BotInfra.fsproj | 1 + src/BotInfra/WebhookRegistration.fs | 83 +++++++++++++++++++ src/Fizruk/Fizruk.fsproj | 1 + src/Fizruk/Program.fs | 11 +++ .../WebhookRegistrationHostedService.fs | 20 +++++ tests/Fizruk.Tests/ContainerTests.fs | 31 +++++++ tests/Fizruk.Tests/FizrukContainerFixture.fs | 42 +++++++++- 8 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/BotInfra/WebhookRegistration.fs create mode 100644 src/Fizruk/WebhookRegistrationHostedService.fs diff --git a/README.md b/README.md index 4abe4f9..3266ada 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,15 @@ and in a mounted config file. 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), and each RCON-probed game's `passwordEnv` variable - (e.g. `FACTORIO_RCON_PASSWORD`). + (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 diff --git a/src/BotInfra/BotInfra.fsproj b/src/BotInfra/BotInfra.fsproj index e3d4a0d..68b03ec 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 0000000..5b8862f --- /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/Fizruk.fsproj b/src/Fizruk/Fizruk.fsproj index f17edc0..504090c 100644 --- a/src/Fizruk/Fizruk.fsproj +++ b/src/Fizruk/Fizruk.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/Fizruk/Program.fs b/src/Fizruk/Program.fs index a7bb27c..fca6f7c 100644 --- a/src/Fizruk/Program.fs +++ b/src/Fizruk/Program.fs @@ -3,6 +3,7 @@ open System open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting open Microsoft.Extensions.Logging open Fizruk open BotInfra @@ -20,6 +21,8 @@ let botUsername = | "" -> None | u -> Some u +let webhookUrl = getEnvOr "BOT_WEBHOOK_URL" "" + let webhookCfg: WebhookConfig = { BotToken = getEnv "BOT_TELEGRAM_TOKEN" SecretToken = getEnv "BOT_AUTH_TOKEN" @@ -70,6 +73,14 @@ WebhookHost.configureSharedServices webhookCfg builder %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. diff --git a/src/Fizruk/WebhookRegistrationHostedService.fs b/src/Fizruk/WebhookRegistrationHostedService.fs new file mode 100644 index 0000000..ee282e6 --- /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/ContainerTests.fs b/tests/Fizruk.Tests/ContainerTests.fs index 71f6f9c..68538b7 100644 --- a/tests/Fizruk.Tests/ContainerTests.fs +++ b/tests/Fizruk.Tests/ContainerTests.fs @@ -2,11 +2,27 @@ 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 @@ -65,3 +81,18 @@ type ContainerTests(fixture: FizrukContainerFixture) = 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/FizrukContainerFixture.fs b/tests/Fizruk.Tests/FizrukContainerFixture.fs index b0408b3..c36e689 100644 --- a/tests/Fizruk.Tests/FizrukContainerFixture.fs +++ b/tests/Fizruk.Tests/FizrukContainerFixture.fs @@ -43,6 +43,10 @@ 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() = @@ -84,9 +88,29 @@ type FizrukContainerFixture() = .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() = @@ -99,7 +123,7 @@ type FizrukContainerFixture() = do! Task.WhenAll(botBuildTask, fakeTgBuildTask) do! fakeTgContainer.StartAsync() - do! botContainer.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 @@ -107,21 +131,37 @@ type FizrukContainerFixture() = 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)