Repository navigation
feat(fizruk): add Fizruk on-demand game server bot - #507
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
_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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…her re-arming, and case-insensitive game names - 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 <noreply@anthropic.com>
… is set 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is Fizruk
Fizruk (Russian for a PE teacher — the person who blows the whistle to start and
stop the games) is a new Telegram bot,
src/Fizruk, that starts and stopson-demand game server Kubernetes Deployments and shuts them down automatically
once nobody's playing. It has no database — all state lives in the cluster plus
a mounted config file — so it doesn't reuse
_bot-deploy.yml(Flyway, aWireGuard peer, post-deploy verification) or
BotInfra.ScheduledJobs(whichleases jobs through a Postgres table Fizruk doesn't have). It reuses
BotInfra's webhook host, JSON logging, OpenTelemetry setup, and readinessendpoint exactly like the other bots, over the same
BOT_TELEGRAM_TOKEN/BOT_AUTH_TOKENenv vars andX-Telegram-Bot-Api-Secret-Tokenwebhookvalidation.
Config schema
JSON file at
FIZRUK_CONFIG_PATH(a mounted ConfigMap):{ "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": "...", "port": 27015, "passwordEnv": "FACTORIO_RCON_PASSWORD" }, "activityRegex": "\\[(JOIN|LEAVE|CHAT)\\]", "idleGraceMinutes": 45, "idleWindowMinutes": 60, "startTimeoutMinutes": 20 } }, "chats": { "-5304790145": ["factorio"] } }chatsis the whole ACL (chat id -> games it may control, no user ids).playersis optional;typeisrcon,raknet, ornone/absent. Config isvalidated at startup (every game a chat references exists, regexes compile,
passwordEnvset whentype=rcon) and fails fast with a clear error.Command semantics
/start [game],/stop [game],/status [game], optional@botnamesuffix,case-insensitive. Only chats present in
chatsare handled. No name given: achat controlling exactly one game uses it (the shortcut that keeps the
existing Factorio group working unchanged); otherwise
/start//stopaskwhich game, and
/statusreports on all of them. An explicit name outside thechat's ACL gets "This chat controls: a, b". Start/stop are serialised per game
(a lock), so a double
/start//stopis a safe no-op ("Alreadyrunning."/"Already starting."/"Already stopped.").
/startscales to 1 andspawns one watcher per game that polls every 20s until
startTimeoutMinutes,posting "ready at
<address>", "Start cancelled" (if replicas dropped back to0 first), or a timeout message.
/stopprobes players best-effort, scales to0, and reports who was online at shutdown. Notifications go to every chat that
controls that game.
Idle rules
Every 10 minutes (
IdleCheckHostedService, a plainBackgroundService),IdleDecision.decide— a pure function — checks each game in order: notrunning -> keep; pod not ready -> keep; pod age unknown or under
idleGraceMinutes-> keep (protects a freshly-started server from the idletimer); player-probe error -> keep + log; players online -> keep; log-read
error -> keep + log; recent activity-log match -> keep; otherwise scale to 0
and notify "Stopped
<name>: nobody online for the last N min (was upXh Ym)." Player probes are a from-scratch Source RCON client (Factorio) and a
RakNet unconnected-ping client (Minecraft Bedrock), both with pure
packet-framing/parsing functions. Kubernetes access goes through a small
IK8sGatewayinterface (KubernetesClient, in-cluster config) soGameCoreis testable with a fake.
Workflows
.github/workflows/fizruk-build.yml— PR gate via the shared_bot-build.yml, runningtests/Fizruk.Tests(now including thecontainer smoke tests below).
.github/workflows/fizruk-deploy.yml— a thin caller of the shared_bot-deploy.ymlwithhas-database: false._bot-deploy.ymlgrew ahas-databaseinput (defaulttrue, unchanged for vahter/coupon/alita)that gates every Flyway/migration step and the DB connectivity steps;
migrations-path/db-nameinputs and theDB_PROD_*secrets becamenon-required (
workflow_callcan't make secrets conditional, so themigration steps are the only consumers). Fizruk still gets the GHCR
build+push, the WireGuard step, and the ArgoCD verify-deploy step, exactly
like every other bot — it only skips the DB-specific work it has no DB for.
The first run of this workflow will fail at verify-deploy until the
fizrukArgoCD app and theWIREGUARD_CONFIG_FIZRUKsecret exist on themy-infra side; the image is already pushed to GHCR by the time that step
runs.
sre-enabled: falsefor now, matching alita-deploy.yml'snot-yet-settled-in-prod convention for a brand new bot.
Container tests
tests/Fizruk.Testsalso builds the realsrc/Dockerfile.botimage(
BOT_PROJECT=Fizruk) via Testcontainers, the same patterntests/CouponHubBot.Tests/tests/MultiPodTestsuse, and runs it against aFakeTgApicontainer standing in forapi.telegram.org. A newInMemoryK8sGateway(selected viaFIZRUK_FAKE_K8S=true, never set inproduction) stands in for the Kubernetes API so the container needs no real
cluster — every game just reads as stopped. Covers: the image builds and the
container starts healthy (
/health), a/statusfrom an allowed chatproduces the expected
sendMessagecall, and a message from an unknown chatproduces none. These run in the same assembly as the rest of
tests/Fizruk.Tests(anAssemblyFixture), sofizruk-build.yml's existingtest command already covers them with no workflow change.
Deployment (follow-up, not in this PR)
A
my-infraConfigMap forFIZRUK_CONFIG_PATH, a Deployment/Service, and anargocd-image-updaterannotation onghcr.io/szer/fizrukwith tag regexp^[0-9a-f]{40}$— plus the ArgoCD app andWIREGUARD_CONFIG_FIZRUKsecretnoted above.
Code review fixes (after initial CI green)
probeAsynctreated the first packet after SERVERDATA_AUTH asthe auth verdict, but Factorio (like Valve servers) echoes an empty
SERVERDATA_RESPONSE_VALUE (type 0) first. Extracted
runProtocol(stream, ...)over any
Streamand added a bounded loop reading until the realSERVERDATA_AUTH_RESPONSE (type 2) before checking
id = -1. New tests run thereal protocol logic against an in-memory duplex
Stream(no socket) with acanned two-packet auth sequence.
GameCore.Startonly armed the start-timeout watcher on a freshscale-up, so "Already starting." (and a Fizruk pod restart mid-start) left a
game with no watcher and no eventual notification.
EnsureWatcheris nowalso called from "Already starting.", and a new
ArmPendingWatchers()re-arms every game already scaled up but not yet Ready — called once at
IdleCheckHostedServicestartup.resolveGamenow matches an explicit game-name argumentcase-insensitively (
OrdinalIgnoreCase), resolving to the configuredspelling regardless of how the user typed it.
StartTime/nodeCreationTimestampnow convert via anew
asUtcOffsetthat treats anUnspecifiedDateTime.Kindas UTC (as k8sAPI timestamps always are) instead of
DateTimeOffset's default constructorapplying the local offset.
Webhook self-registration (owner decision, after the review fixes)
Fizruk can now self-register its Telegram webhook at startup.
BotInfra. WebhookRegistrationis a new opt-in helper (any bot can adopt it, onlyFizruk does today): unset/empty
BOT_WEBHOOK_URLlogs "webhookself-registration disabled" and does nothing; when set, it logs
getWebhookInfo's before-state (best-effort — that response can't reveal thesecret token) and then unconditionally calls
setWebhook(url= BOT_WEBHOOK_URL,secret_token=BOT_AUTH_TOKEN,allowed_updates= ["message"],drop_pending_updates=false), retrying a few times withbackoff and never crashing the process. Fizruk fires it from a small hosted
service without blocking the host from serving, over the same
ITelegramApievery other outbound call uses (so
TELEGRAM_API_URLredirects it toFakeTgApi in tests). New env var:
BOT_WEBHOOK_URL(optional).The container test fixture grew a second bot container (same cached image,
BOT_WEBHOOK_URLset) sharing the existing FakeTgApi/network, with itssetWebhookcall snapshotted once at startup so the assertion doesn't raceother tests'
ClearFakeCalls(). Two new tests: one asserts that call's url/secret_token/allowed_updates, the other asserts the primary (webhook-url-unset)
container made none.
Test plan
dotnet build bots.slnx -c Release— 0 warnings, 0 errorsdotnet test tests/Fizruk.Tests— 68/68 passed (63 unit + 5 container, after the review-fix and webhook rounds)dotnet test tests/CouponHubBot.Tests— 264/264 passed (Directory.Packages.props changed)dotnet test tests/VahterBanBot.Unit.Tests— 128/128 passed (Directory.Packages.props changed)podman build --format docker --build-arg BOT_PROJECT=Fizruk -f src/Dockerfile.bot .— succeedsactionlint(pinned v1.7.12, matchinglint-workflows.yml) — 0 findings across.github/workflows/**,including
_bot-deploy.ymland every other bot's build/deploy callers🤖 Generated with Claude Code