Skip to content

Community feedback: safer saves download, shadow-stamp env mode, maintenance mode, release prep - #2

Merged
seslly merged 26 commits into
mainfrom
community-feedback-fixes
Apr 21, 2026
Merged

seslly merged 26 commits into
mainfrom
community-feedback-fixes

Conversation

@carl-shipstuff

@carl-shipstuff carl-shipstuff commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Saves download now snapshots to /tmp first. tarfile.add() over the live R5/Saved tree races with RocksDB compactions; one reporter saw their world fail to load after a download and the server generate a fresh one. Now shutil.copytree to a tempfile.TemporaryDirectory first, tar the detached copy, stream that — download I/O never touches the live DB.
  • Env-mode no longer clobbers UI config edits. reconcile_server_config writes ServerDescription.last-env-stamp.json after each stamp and only re-stamps a key on later boots when its live value still matches the shadow. Diverged values (UI edit, ldb, manual jq) are logged env-mode: keeping operator-modified <key> and left alone. Fixes the "server name always resets to Windrose bare-linux after restart" report.
  • Maintenance mode flag. R5/.maintenance-mode makes the entrypoint loop-sleep instead of launching Proton. Admin UI's new "Maintenance mode" card toggles via /api/maintenance ({active, restart?} body); handler traps SIGTERM for clean shutdown. Works uniformly across Helm / compose / bare-Linux without needing special scale-down / systemctl stop / docker compose stop privileges.
  • Add upstream dedicated port/ip
  • Cleanup and make release ready

README Admin Console API table gains the four new endpoints. Config-editor card blurb now mentions the shadow-stamp behavior so operators know UI edits survive env-mode restarts.

Test plan

  • Shadow-stamp validated on canary: manual jq edit of ServerName → pod restart → value preserved, entrypoint logged INFO: env-mode: keeping operator-modified ServerName
  • Maintenance round-trip on canary: POST /api/maintenance {active: true} → pod restart → entrypoint honored flag → POST {active: false, restart: true} → game came back up cleanly
  • /api/saves/download end-to-end on canary: produces identical tar contents, game unaffected (no patch to RocksDB)
  • helm lint + helm template + py_compile + bash -n + node -c all green

🤖 Generated with Claude Code

seslly and others added 3 commits April 20, 2026 09:49
…tenance flag

#1 — Saves download snapshot: /api/saves/download used to tarfile.add()
straight from the live R5/Saved tree. That races with RocksDB compactions
(.log → .sst renames mid-walk), and on memory/IO-pressured hosts has been
reported to corrupt the DB — symptom: world fails to load on next boot,
server generates a fresh one. Now snapshot to a /tmp TemporaryDirectory
via shutil.copytree first, tar the scratch copy, stream that. Download
I/O is decoupled from the live DB entirely.

#2 — Shadow-stamp env mode: env-mode reconcile_server_config used to
re-stamp ServerName / MaxPlayerCount / P2P / etc. on every boot, blowing
away UI config-editor edits. Now writes ServerDescription.last-env-stamp.json
after each stamp; the next boot only re-stamps a key whose current value
still matches the shadow. If the value diverges (UI edit, ldb, manual jq),
the entrypoint logs "keeping operator-modified <key>" and leaves it alone.
First-boot (no shadow) stamps everything.

#3 — Maintenance flag: .maintenance-mode file at R5/ makes the entrypoint
loop-sleep instead of launching Proton. UI card toggles it via the new
/api/maintenance GET/POST pair; POST accepts {active, restart?} so
"enable + stop now" and "disable + restart now" flow through a single
interaction. Handler traps SIGTERM so the container still exits cleanly
on shutdown.

Server Config card blurb now mentions the shadow-stamp behavior so
operators know env-mode edits survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PR's first-pass shadow-stamp refresh did `cp live → shadow`, which
captures an operator's divergent value into the shadow. Next boot,
live == shadow (both have the operator value) → we'd stamp over their
edit with the env value. Clobber on the second boot after divergence,
not the first — subtle but user-visible.

Fix: after running the jq filter, build a new shadow via jq-merge
rather than whole-file copy. For each managed key, write the env value
iff we stamped it THIS boot; otherwise preserve the old shadow value.
First boot (no shadow present) seeds every key. Verified with a 3-boot
loop: operator edit on boot 1 survives boots 2 AND 3 (old diff only
got boot 2 right).

Same edit drops the unused `env_val` param from `_env_keep_key`
(SC2034 — what CI actually flagged; the signature was a leftover from
a comparison strategy I didn't end up using).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upgrade case: operators adopting this change on top of an existing
install don't have a shadow file yet. The previous logic (_env_keep_key
returning "false" on first_boot) stamped the live file with env values
on that first post-upgrade boot — which clobbered, e.g., a ServerName
the operator had already customized via ldb or the UI.

New behavior: on first_boot, _env_keep_key returns "true" (skip) for
every managed key. No stamping happens. The shadow still gets seeded
with env values (via the $seed branch in the final jq merge), so on
boot 2 we have a baseline: if live still matches env intent, we stamp;
if operator diverged, we preserve.

Log:
- First-boot skip-all path gets one "no shadow — treating live as
  operator-owned" line instead of a per-key "keeping" spam.
- Normal path still logs per-key on divergence.

Validated locally:
- Upgrade (pre-existing "Digital Ocean Canary", env default
  "Windrose Server"): live preserved across boots 1, 2, 3.
- Fresh install: env change on boot 2 takes effect as expected.
- PersistentServerId preserved (not a managed key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@seslly

seslly commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5ff4a3ea4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/entrypoint.sh
# stamp normally; if operator diverged, we preserve.
_env_keep_key() {
local path="$1" kind="$2"
if [ "${first_boot}" = "true" ]; then echo "true"; return; fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stamp env keys on first boot when no shadow exists

In env mode, a missing shadow file makes _env_keep_key unconditionally return true (skip), so none of the managed fields are stamped on that boot. If ServerDescription.json differs from env at that point (for example custom SERVER_NAME/MAX_PLAYER_COUNT), the subsequent shadow seed records env values, and future boots see live != shadow and keep treating those keys as operator-modified forever. That means env-driven config can stop applying entirely on installs that start without ServerDescription.last-env-stamp.json.

Useful? React with 👍 / 👎.

Comment thread scripts/entrypoint.sh Outdated
Comment on lines +547 to +548
| (if $s_invite == "true" then .InviteCode = $invite else . end)
| (if $s_island == "true" then .WorldIslandId = $islandId else . end)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed optional env keys in the initial shadow stamp

The shadow-refresh block states first boot seeds every key, but InviteCode and WorldIslandId are only written when stamp_* == "true" and are not covered by $seed. Since first boot currently skips all stamps, those two fields are omitted from the shadow; on later boots the comparison becomes live vs null, so INVITE_CODE and WORLD_ISLAND_ID are perpetually considered diverged and never re-applied from env.

Useful? React with 👍 / 👎.

seslly and others added 14 commits April 20, 2026 10:54
PR #2 shipped shadow-stamp for ServerDescription but missed the sibling
ensure_world_layout path, which unconditionally re-stamps WorldName +
WorldPresetType on every env-mode boot. Reported live: operator changes
the world name via UI, restarts, name snaps back to the env default on
boot. Apply the same shadow-stamp treatment to WorldDescription per
island — shadow at `WorldDescription.last-env-stamp.json` next to the
live file, first-boot seeds from env intent without stamping, later
boots compare live to shadow and skip when diverged.

Also land the review nits from the first PR #2 review pass:

- Shadow file corruption guard in reconcile_server_config: `jq empty`
  the shadow before using it as --argjson base. Malformed JSON
  (partial write, manual edit gone wrong) used to exit the entrypoint
  under `set -e` and boot-loop the service. Now we log a warning and
  reseed as if first boot.

- WINDROSE_DOWNLOAD_SCRATCH_DIR env lets operators point
  /api/saves/download's copytree scratch at a PVC-backed path when
  /tmp is tmpfs (RHEL default, some Docker setups). Defaults to /tmp,
  which is fine on most hosts.

- helm/windrose/README.md "Server Config Modes" expanded: mutable mode
  now has a step-by-step recipe, and the env/mutable distinction calls
  out that env-mode preserves UI edits via the shadow-stamp, so mutable
  is only needed for the stronger "entrypoint never touches the file"
  guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Community-reported observation + logs: SteamCMD's app_update
intermittently reverts the idle-CPU patch on restart (it detects the
modified bytes don't match its local manifest and "corrects" them).
The entrypoint then re-applies the patch via maybe_patch_idle_cpu.
Net effect is correct final state, but every reboot paid a
SteamCMD run + patch re-apply cycle, and there was a short window
where the binary was actually unpatched.

Fix: if the effective patch mode is ON (env=1 or override=enabled)
and the binary is already in "patched" state, skip the SteamCMD step
entirely. Uses --print-state for the check (fast path on known MD5s,
no file I/O beyond a few targeted reads).

Trade-off: Windrose game updates won't be pulled on boot when the
patch is active — operator has to set WINDROSE_STEAMCMD_FORCE=1 for
that one boot to refresh. Game updates are rare (monthly at most);
worth the quiet boots in exchange. Documented in README env table.

Partial-install detection (binary missing, dir has content) still
triggers validate, so first-boot/fresh-PVC scenarios are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…place

Old model: mutate the Steam-managed EXE in place. Problem: SteamCMD's
app_update intermittently reverts the modification back to the manifest
version, so every boot paid a "Steam reverts → we re-patch" thrash, and
there was a short window where the live binary was unpatched.

New model: leave the original EXE untouched and maintain a sibling
copy named `WindroseServer-Win64-Shipping.patched.exe` alongside it,
with `WindroseServer-Win64-Shipping.patched-source.md5` recording the
source md5 it was built from. On each boot:

  1. maybe_patch_idle_cpu md5s the source.
  2. If the sibling exists AND the recorded source md5 matches → reuse.
  3. Else rebuild: cp source → sibling, run patch-idle-cpu.py against
     the sibling, write the new source md5 alongside.
  4. Export IDLE_PATCH_EFFECTIVE_EXE pointing at the sibling.
  5. exec proton launches IDLE_PATCH_EFFECTIVE_EXE instead of the
     original.

SteamCMD operates on the original as always and has nothing to
"correct". Updates are picked up automatically — next boot's source
md5 changes, the sibling rebuilds. If a future Windrose version
breaks the patch script's signature scan, the rebuild fails, the
broken sibling is removed, and we fall back to launching the
original with a WARNING log — "until we can't" behavior the user
asked for explicitly.

UI changes:
- idle_patch_binary_state() no longer subprocess-calls the script.
  Checks for the sibling + reads the source-md5 file. Cache keyed on
  (source mtime+size, sibling mtime). New "stale" state indicates
  the source changed since the sibling was built (will rebuild on
  next restart).

Effective-OFF path (env=0 or override=disabled) just removes the
sibling; no more --revert --idempotent dance needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hosts that were running the previous in-place patch model have their
Steam-managed original EXE patched directly. The new sibling flow
tries to cp that original → sibling and then patch the copy, but the
script refuses to re-patch an already-patched binary and errors out,
falling back to launching the original (which happens to still be
patched, so it works by accident).

Fix: before building the sibling, run --revert --idempotent on the
original. On a host migrating from the old model it un-patches the
original cleanly; on a fresh host it's a no-op. Now the sibling-build
flow gets a clean copy to work with, and Steam's view of the file
matches its manifest going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous commit added a --revert --idempotent pre-step to clean the
original EXE on hosts upgrading from the old in-place model. That was
over-engineered — the patch script's --idempotent flag already handles
the degenerate "cp copied an already-patched binary" case: it detects
the patched state, logs "info: already patched", and exits 0.

New flow for the upgrade host (original still patched from old model):
  1. cp original (patched) → sibling (patched)
  2. patch-idle-cpu.py sibling --idempotent → exit 0, no-op
  3. record the patched md5 as cached source md5
  4. launch sibling (patched; server works normally)

SteamCMD on a later boot may restore the original to its manifest
version. At that point the cached md5 no longer matches → rebuild
from clean bytes → records the unpatched md5 as the new cached
source md5. Self-heals.

Original EXE is now never modified by our code. Steam owns it end
to end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wineserver detaches to ppid=1 under Proton's startup path, which means
systemd's cgroup-level SIGTERM on service restart can't always reach
it. On the next start the new proton sits in waitforexitandrun behind
the stale wineserver, and the user sees the old game process (still
holding the pre-patch code in memory) continuing to burn CPU.

Fix: tag every process we spawn with a boot-unique env var
WINDROSE_INSTANCE_TAG, written to a pidfile at
${WINDROSE_PATH}/.last-instance-tag. On next boot, scan /proc/*/environ
for matching tags and SIGKILL those. Scoped per-install via
WINDROSE_PATH (separate installs read separate pidfiles) and per-boot
via "$$-$(date +%s)" (fresh tag each time), so multi-tenant hosts with
shared binaries + per-instance save roots stay safe — we only match
processes we ourselves launched on the last boot.

No effect on k8s/Docker (separate PID namespaces mean no stale procs
to find). Main beneficiary is bare-Linux + compose-pid-host, and
anyone migrating from the old in-place patch model where stale
processes held the old unpatched code in memory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/entrypoint.sh: split the export + assign on WINDROSE_INSTANCE_TAG
  so the subshell exit status isn't masked (shellcheck SC2155).
- bare-linux/install.sh: after enable --now, call try-restart on
  windrose-ui + windrose-game. Without this, re-running install.sh to
  pick up updated UI/entrypoint code was a no-op at the process level
  — the services were already running, enable --now didn't bounce them,
  and the Python UI kept the old server.py in memory. try-restart
  only bounces services that were already active, so first installs
  aren't double-started.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ness

Extends backup retention beyond the bare count cap to fix the
4-18/4-19 data-loss footgun: a burst of test backups today pushed
the user's actual known-good snapshots out of the 5-slot retention
window, and the restore endpoint's only fallback data was the
Apr-19 backup by sheer luck that it hadn't been evicted yet.

Three retention rules, evaluated in create_backup(); a backup
survives if ANY rule keeps it:
  1. Name starts with `manual-` (pinned; never pruned).
  2. In the top-N most recent by name-sort (count-based, default 5).
  3. Younger than WINDROSE_BACKUP_RETAIN_DAYS (default 7).

Rule 3 is the new one — handles the "busy week pushed out my
weekly snapshot" case. Rule 1 via create_backup(pin=True) / POST
/api/backups {"pin": true} / POST /api/backups/{id}/pin.

Two new endpoints: /api/backups/{id}/pin + .../unpin for toggling
the prefix on existing backups without re-creating.

Docstrings on create_backup() / restore_backup() explicitly warn
never to use raw `cp` for recovery — partial-subtree swaps of
Saved/.../Worlds/<id>/ leave game-internal state pointing at
stale files. This was the concrete mistake that turned a bad
restore attempt into a crashloop today.

Data-loss prevention at the entrypoint:
  - preserve_dir moved from /tmp to $WINDROSE_PATH/.steamcmd-preserve-$$
    so a mid-SteamCMD container kill doesn't throw the stash away with
    /tmp on container restart. Abandoned preserve_dirs are detected
    and restored at entrypoint start.
  - ensure_world_layout refuses to bootstrap a fresh world if a
    .had-world-once marker is present and Saved/ is empty, unless
    WINDROSE_ALLOW_FRESH_WORLD=1. Logs a LOUD error pointing the
    operator at backups + the stray preserve_dir they might recover
    from. Would have caught today's silent bootstrap at 16:34.

CI: new python-tests job runs scripts/ui/test_retention.py — 10
stdlib-only unit tests covering the retention logic, including a
reproduction of today's burst-evicts-older-backup case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- entrypoint.sh: drop the unused had_saved local in install_via_steamcmd.
  It was a leftover from an earlier intent I didn't follow through on;
  the abandoned-preserve_dir recovery at entrypoint-start already
  covers the interrupted-stash case without needing this flag.
- test_retention.py: fake_stat signature needs to accept *args/**kwargs
  so Path.stat(follow_symlinks=...) calls in Python 3.13+ dispatch
  through without TypeError. CI runs on 3.13; my local 3.12 didn't
  hit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python 3.13 stopped silently catching OSError inside Path.is_dir(),
so a single unreadable entry during iterdir() was aborting the entire
_prune_backups() sweep — and the matching list_backups() enumeration.
My local Python (3.14) didn't expose this on my first pass; CI's 3.13
caught it.

Wrap each is_dir() call in a try/except OSError: pass so the
iteration keeps going past unreadable dirs. Matches the sensible pre-
3.13 default without relying on it.

test_retention.py's unreadable-dir test was already asserting the
right behavior ("unreadable entries must not be pruned"); it was the
production code that was wrong. Test now verifies the real fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new stdlib-only test files wired into CI's python-tests job:

- test_restore.py (6 scenarios): create_backup → restore_backup
  round-trip, including mutations-wiped-by-restore, post-backup files
  get removed, identity JSONs restored, missing-id raises, and RocksDB
  bytes survive byte-for-byte. Fills the gap where restore_backup had
  no direct coverage — the primitive that today's incident pivoted on.

- test_http.py (7 scenarios): binds Handler to an ephemeral localhost
  port in-process and exercises POST /api/backups, /pin, /unpin,
  /restore via urllib. Covers routing, JSON body parsing, 401 on
  destructive endpoints without auth, 404 on unknown backup id, and
  basic-auth round-trip. No subprocess, no network beyond loopback.

Both run in <1s locally on Python 3.14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined changes that landed together before the per-task commit
rule took effect. Future work follows one commit per task.

Auto-backup scheduler (task #43):
- Idle trigger: N min after last player disconnects, fires once per
  zero-streak, resets when players return. Default 1 min.
- Floor trigger: M h of continuous active session without an auto-backup
  forces one. Default 6 h. Zero on either disables that trigger.
- Manual backups don't reset auto clocks (independent systems, like
  in-game manual saves vs auto-saves).
- Config single source of truth at $R5_DIR/.backup-config.json;
  WINDROSE_AUTO_BACKUP_* env vars only seed defaults until the file
  exists. Atomic temp-file-rename on UI save.
- Retention count default bumped 5 → 10 (hard floor even if scheduler
  stalls). Age + count are OR-combined — age never subtracts.
- .auto marker file + source=auto/manual/manual-pinned tag in
  list_backups + webhook payload includes source + reason.
- Bootstrap re-derives lastAutoBackupAt from newest .auto-marked dir
  so UI container restarts don't cause spurious immediate backups.
- New GET/PUT /api/backup-config with range + type validation.

Default_Backups tab (task #40):
- GET /api/game-backups + POST /api/game-backups/{ts}/restore.
- Restore merges onto live RocksDB tree and creates a pinned safety
  snapshot first. Clearly labeled as world-only in UI + confirm dialog
  (game backups don't include ServerDescription/WorldDescription).
- Tabs in the Backups card: Ours | Default_Backups (game).

Backup payload widened to also capture .backup-config.json and
.idle-patch-override so operator-owned runtime settings survive a
restore. Staged configs intentionally excluded.

Tests: 38 total passing across retention/restore/scheduler/HTTP suites.
New test_auto_backup.py wired into CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure move — no behavior change. Rationale:
- server.py is the backend; ui/ is the frontend bundle — colocating
  them under scripts/ui/ misrepresented their different roles.
- Tests in /tests/ is Python-idiomatic and keeps them out of the
  deployment copy targets (Dockerfile, install.sh).
- Once server.py grows past one file we can convert to a package;
  flat root supports either shape.

Changes:
- scripts/ui/server.py → server.py (STATIC_DIR now resolves to
  Path(__file__).parent / "ui", with UI_STATIC_DIR env override for
  nginx-front deployments).
- scripts/ui/{index.html,app.css,app.js} → ui/
- scripts/ui/test_*.py + test_api.sh → tests/
- Dockerfile: COPY server.py and ui/ into /opt/windrose-ui/
  separately; k8s invocation path unchanged.
- CI: point py_compile + test invocations at new paths.
- bare-Linux install.sh: lay down server.py at ${WINDROSE_INSTALL_DIR}
  root with ui/ sibling; /opt/windrose-ui symlink now targets the
  install dir itself so /opt/windrose-ui/{server.py,ui/} both resolve;
  cleans up any stale scripts/ui/ from prior installs.
- server.py PATCH_SCRIPT_CANDIDATES updated (server.py is now repo-
  root, so patch-idle-cpu.py is parent/scripts/, not parent.parent).
- Docs (README, CLAUDE.md, bare-Linux README, TODO, values.yaml
  comments) refreshed to reflect new paths.

Tests: all 38 pass under the new layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@seslly seslly changed the title Community feedback: safer saves download, shadow-stamp env mode, maintenance mode Community feedback: safer saves download, shadow-stamp env mode, maintenance mode, release prep Apr 21, 2026
seslly and others added 7 commits April 20, 2026 20:04
The previous dockerignore whitelisted only scripts/ and the Dockerfile.
After the repo flattened (server.py + ui/ at root), the Docker build
started failing with "/ui": not found because the COPY targets weren't
in the build context.

Add !server.py, !ui, !ui/** to the whitelist so the COPYs resolve.
Verified locally: docker build now completes through every COPY step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Community feedback asked for a real Start button on bare-Linux. The
only way to do that cleanly is to let the UI drive systemd directly —
previously the UI could only SIGTERM the process and rely on the
container supervisor to bring it back.

New pieces:
- bare-linux/polkit/50-windrose.rules: narrowly grants the 'steam'
  user start/stop/restart on the windrose-{game,xvfb,ui}.service
  units only. Scoped to org.freedesktop.systemd1.manage-units (NOT
  manage-unit-files — no enable/disable/mask escalation). Other
  systemd actions + other users fall through to default polkit.
- install.sh: drops the rule at /etc/polkit-1/rules.d/50-windrose.rules
  when polkit is present, skips cleanly otherwise (UI falls back to
  SIGTERM).
- server.py: _systemctl_available() + systemctl_dispatch() helpers;
  existing /api/server/stop + /api/server/restart prefer systemctl
  when available, fall back to SIGTERM if polkit isn't configured or
  the call fails — operators in a half-broken state still see
  *something* happen.
- New POST /api/server/start — only available when systemctl is
  usable (501 on k8s/compose where the supervisor owns lifecycle).
- /api/status exposes serverControlMode ("systemctl" | "signal") so
  the UI can show/hide the Start button intelligently.
- UI: Stop + Start buttons in the toolbar, visibility + enabled state
  driven off serverControlMode and serverRunning.

Tests: new tests/test_server_control.py with 6 cases covering
detection logic + subprocess dispatch (monkey-patched shutil.which
and subprocess.run so CI doesn't need real systemd).

Docs: CLAUDE.md API table updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windrose 2026-04 added Direct IP Connection — a bypass of the backend
connectivity service where clients join via a raw IP:port rather than
an invite code. Useful when an ISP blocks Windrose's backend or for
LAN-only play. Live ServerDescription.json shape (confirmed on prod):

  UseDirectConnection: bool
  DirectConnectionServerAddress: string
  DirectConnectionServerPort: int (default 7777)
  DirectConnectionProxyAddress: string (default 0.0.0.0)

Plumbing:
- entrypoint.sh env defaults + reconcile_server_config stamp blocks for
  all four fields. Shadow-stamp treats the whole Direct-IP bundle as
  opt-in via USE_DIRECT_CONNECTION presence — unset env leaves existing
  configs alone so upgrades don't silently flip anyone into Direct IP
  mode. Empty address with USE_DIRECT_CONNECTION=true transparently
  reuses P2P_PROXY_ADDRESS (same "my IP" from the host's perspective).
- helm/values.yaml: serverConfig.useDirectConnection (null | bool) +
  serverConfig.directConnection.{serverAddress,serverPort,proxyAddress}
  wrapped in null-guard so env vars only render when explicitly set.
- docker-compose.yaml + bare-linux/install.sh: new env keys in the
  _MANAGED_KEYS list and default env file, empty by default.
- server.py validate_server_description: four new optional fields +
  a range check on port + a required-if-enabled rule on the address
  (empty address with UseDirectConnection=true is the silent-bounce
  footgun called out in the release notes).
- ui/: Advanced > Direct IP Connection collapsible section with all
  four inputs. Checkbox + 3 text/number fields, synced two-way with
  the Raw JSON editor like the rest of the form.

Tests: new tests/test_schema.py with 6 cases covering baseline + valid
enabled + rejected empty-address + disabled-with-empty-address OK +
wrong types + port range. Wired into CI. Full suite (46 tests) passes.

Helm template verified: --set useDirectConnection=true renders all
four env vars; default template omits them entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Community feedback flagged that operators weren't sure which restore
path to reach for when things went sideways. The 16:34 incident earlier
in this PR was a data point: the right answer is never "raw-cp parts
of a backup onto a live tree" — it's restore_backup() through the API.

New ## Recover Your World section, placed between the game-patch update
flow and the invite-code snippet (both are operator recovery/lookup
flows). Covers four recovery paths in order of lightness:

  1. UI backup restore — the default, whole-tree + identity JSONs,
     preserves PSID so backend rebinds to the same island
  2. Default_Backups merge — world-only, takes a pinned safety snapshot
     first, useful when a game auto-backup is more recent than any UI
     backup
  3. Disaster mode — on-disk cp recipe if the UI is crash-looping,
     mirrors exactly what restore_backup() does programmatically
  4. Lost PSID / orphaned island — only path #1 fixes this; game
     Default_Backups won't help since they don't capture identity

Plus Prevention (pin before risky ops, retention math, watching auto
drift, why /tmp is a footgun) and What NOT to do (raw-cp world
subtree, delete ServerDescription, race the scheduler with a direct
file write).

No code changes, doc-only. CLAUDE.md is a symlink to README.md so the
agent-facing copy gets this too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression caught on the VPS rollout (2026-04-21): re-running
install.sh dropped UI_PASSWORD from the env file. Operator's password
was on disk, correctly read into the merge loop, but clobbered back
to empty before the heredoc wrote it out.

Root cause: install.sh pre-assigned managed-key defaults BEFORE the
merge loop:

  UI_PASSWORD="${UI_PASSWORD:-}"     # <- this
  ...
  while IFS='=' read -r _k _v; do
    if [ -z "${!_k+x}" ]; then       # "is var unset?" — always false now
      printf -v "$_k" '%s' "$_v"     # ... so this skips
    fi
  done < existing.env

The [-z "${!_k+x}"] check was meant as "did the operator pass this on
the CLI?" — but the pre-merge ":-" assignment always SET the var
(possibly to empty), so the merge could no longer distinguish "CLI
gave me empty" from "no CLI, default fallback kicked in", and fell
back to skipping every managed key. UI_PASSWORD, the webhook URLs,
UI_ENABLE_ADMIN_WITHOUT_PASSWORD etc. all lost their on-disk values.

Fix: don't pre-assign defaults for managed keys. Move every ${:-...}
default inline into the heredoc so they apply only AFTER the merge
has had a chance to populate from the existing file. The
UI_BIND=0.0.0.0 warning check at the top uses inline ${:-defaults}
so it still works pre-merge without re-introducing the bug.

Regression test: tests/test_install_env_merge.sh extracts the
merge-loop + heredoc, runs it against a simulated pre-existing env
file holding a real UI_PASSWORD + webhook URL + operator extras,
and asserts every one survives. Verified the test catches the
original bug: reverting the fix makes it FAIL with "UI_PASSWORD: want
s3cretOperatorValue got ''" — exactly the symptom we hit on VPS.

Wired into .github/workflows/ci.yml so this can't silently regress.

Re-ran install.sh on the VPS with the fix in place: password
preserved across two consecutive runs, 401/200 auth behavior matches
expectations. No other operator got hit because the VPS was the only
host rolled through the broken install.sh today — prod k8s uses the
Dockerfile + helm path and was never affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n honest knob

Bug caught on prod rollout (2026-04-21): operator set launchArgs="-FPS=120"
on the game command line, expecting `stat srvfps` to bump from 30 to 120.
It stayed at 30 — `-FPS=N` only caps the render loop; the server-side
tick rate is NetServerMaxTickRate in Engine.ini.

Root cause: the entrypoint's Engine.ini seed was seed-once. If the file
existed (which it did on every non-pristine install), NET_SERVER_MAX_TICK_RATE
was silently ignored. Our documented env knob was doing nothing on any
deployment that had ever booted. Prod shipped with NetServerMaxTickRate=30
pinned for ~72h because of this.

Fix:
- scripts/reconcile-engine-ini.sh: new sibling script, exported from the
  monolithic entrypoint so it's unit-testable. Stamps NetServerMaxTickRate
  in both driver sections (UR5NetDriver + stock IpNetDriver belt-and-
  suspenders) and t.MaxFPS in [ConsoleVariables] — t.MaxFPS caps the main
  loop, which on a dedicated server with no rendering is what actually
  drives the game tick, so it must track the net tick rate.
- Uses shadow-stamp: writes $R5/.engine-ini-shadow recording the value
  we last wrote. Next reconcile compares live vs shadow — matching means
  "operator hasn't touched it, update to new env value"; divergence means
  "operator hand-edited, leave alone". Same pattern as the ServerDescription
  reconcile block.
- entrypoint.sh delegates to the script; old seed-once block deleted.
- Dockerfile + bare-linux/install.sh: install the script at
  /usr/local/bin/reconcile-engine-ini.sh.
- Helm: new serverConfig.networkTickRate value (default 60) plumbed into
  NET_SERVER_MAX_TICK_RATE on the container.
- values-local.yaml (prod): networkTickRate=120 to match launchArgs=-FPS=120.
- docker-compose + bare-linux env: new NET_SERVER_MAX_TICK_RATE knob.
- README env table: new row explaining this IS the tick rate knob, plus
  operator guidance on when to raise (120 on beefy hosts for smoother
  combat) vs lower (30 on 1-2 vCPU VPSes to cut CPU/bandwidth).

tests/test_engine_ini_reconcile.sh: 7 cases covering env-unset no-op,
fresh seed, the prod repro (stale existing file + no shadow → reconcile),
shadow-match reconcile, operator-override preservation, idempotent same-
value, and unrelated-keys-preserved across reconcile. Wired into CI.
Verified the test fails on the old seed-once logic — exact regression
signature we hit today.

Rollout: canary upgraded (60→120 reconcile logged), then prod (absent→120
on [ConsoleVariables] + both driver sections reconciled to 120). Game
logs confirm "LogConfig: Set CVar [[t.MaxFPS:120]]" on first boot under
the new image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Community-feedback final gap: we had /api/saves/download but no way to
push a backup back onto a host. Operators wanting to move a server
(home → VPS, node swap, DR rehearsal) had to kubectl cp or SSH in.
And /api/saves/download was saves-only — no ServerDescription.json —
so even with a shelled-in upload, PSID was lost and the backend
rebound to a fresh island.

Two new endpoints, symmetric with the existing backup primitive:

- GET /api/backups/{id}/download — streams the whole backup dir (Saved/
  + ServerDescription + WorldDescription + .backup-config + .idle-patch-
  override) as .tar.gz. Filename is windrose-backup-{id}.tar.gz. Safe
  to tar in-place (backup dirs are immutable post-create_backup).
- POST /api/backups/upload — accepts that exact shape, extracts into a
  new manual-imported-<ts>/ dir (pinned by prefix — operator explicitly
  brought it over, retention shouldn't evict), returns the id so caller
  follows with /api/backups/{id}/restore. Accepts tar.gz / tar / zip by
  magic bytes + filename hint (same extract_archive routing as
  /api/upload). Strict validation — rejects 400 when the archive is
  malformed, missing Saved/, or junk bytes.

UI:
- Per-row Download button in the Backups card — fetches via authFetch
  (not raw <a href>) so basic-auth creds + XHR-suppress logic both work.
  Blob URL + synthetic click fires the browser's Save As dialog.
- Import control next to Create backup now: file input + Upload+pin
  button. Status line shows the imported id so operator knows what
  row to Restore.

4 new HTTP integration tests:
- download → upload → restore round-trip, with byte-level assertion on
  RocksDB MANIFEST contents + identity JSON content preservation
- 404 on unknown backup id
- 400 on non-archive garbage bytes (with the ReadError → ValueError
  coercion that lets the handler return 400 instead of 500)
- 400 on structurally-valid tar missing Saved/ (so a malformed backup
  doesn't land on disk and silently break a later restore)

Docs:
- README "Recover Your World" gets a new "5. Migrate to a new host"
  section with UI flow + curl recipe covering all three deployment
  surfaces.
- CLAUDE.md API table gets the two new rows.

E2E validation (prod → canary, API only, no kubectl cp): downloaded a
4.6 MB backup from prod (invite c030f708, island 7A0A41E...), uploaded
to canary as manual-imported-20260421T044813Z, restored. Canary's
invite + worldIslandId post-restore matched prod's exactly (PSID
preserved through the tarball → backend rebound to the same island).
Canary then restored back to its own pre-test safety snapshot so the
two environments stay separate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@seslly

seslly commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c343e85bba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bare-linux/install.sh
Comment on lines +352 to +353
UI_BIND=${UI_BIND:-127.0.0.1}
UI_PORT=${UI_PORT:-28080}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep UI bind vars initialized for strict-shell execution

On a fresh bare-linux install (no existing env file and no CLI overrides), this expansion only writes defaults into the generated env file; it does not assign shell variables in-process. Because the script runs with set -u, later status output that dereferences ${UI_BIND}/${UI_PORT} aborts with an unbound-variable error, causing install.sh to exit non-zero after doing the install work.

Useful? React with 👍 / 👎.

Comment thread server.py Outdated
payload = json.loads(body) if body else {}
except json.JSONDecodeError:
self._send(HTTPStatus.BAD_REQUEST, "text/plain", b"invalid JSON body\n"); return
want_active = bool(payload.get("active"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate maintenance flags as booleans

Casting with bool(payload.get("active")) means any non-empty JSON value (for example "false") is treated as true, so malformed-but-common clients can unintentionally enable maintenance mode; payload.get("restart") has the same truthiness issue for restart signaling. This makes the API behavior surprising and unsafe unless callers always send strict JSON booleans.

Useful? React with 👍 / 👎.

Two UI nits surfaced during the post-rollout review:

1. Backup list sort was name-based, which groups the "manual-*" and
   "manual-imported-*" prefixes lexicographically separately from the
   bare-timestamp auto entries. Result: a pinned backup from last week
   appears above an auto-backup from five minutes ago, even though the
   column reads "ID (UTC)". Fix: sort list_backups() + list_game_backups()
   by mtime (true chronological) before rendering.

2. body had `margin: 2rem; max-width: 60rem` hardcoded — cramped on
   phones (huge wasted margins on narrow viewports) and a postage stamp
   on widescreens. Replaced with `padding: clamp(0.75rem, 2.5vw, 2rem);
   max-width: 80rem; margin: 0 auto` so content breathes at any size
   without needing per-breakpoint media queries. Also added flex-wrap
   to .row (button rows no longer overflow) and overflow-x:auto on
   .card (tables scroll inside their card on narrow viewports instead
   of blowing out the page).

Deployed to canary + eyeballed at phone, laptop, and ultrawide widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses all four comments from @codex on PR #2:

[P1] entrypoint.sh env-mode shadow seeded env instead of live on
first boot. Meant _env_keep_key stayed "skip forever" whenever the
on-disk ServerDescription differed from env intent on the first
boot after install — env mode silently stopped applying any managed
keys from the second boot onward. Fix: on first boot, seed the
shadow from the LIVE file (snapshot of whatever is on disk). Next
boot's live==shadow comparison can then correctly stamp env when
no operator drift, or preserve when there is.

Side effect: since the shadow now holds real live values after
first boot, InviteCode and WorldIslandId are captured too — no
more "permanently diverged, never re-applied from env" for those
two keys (P2 from the same review, subsumed by the P1 fix).

Corrupt shadow handling: instead of silently treating as first-boot
(which would re-seed with env values and repeat the bug in slow
motion), we now explicitly log + delete + return — next boot goes
through the clean first-boot branch and re-seeds from live.

[P1] bare-linux/install.sh status echo was dereferencing UI_BIND
and UI_PORT under set -u. After the earlier install.sh env-merge
fix removed pre-merge :- defaults (to make operator passwords
survive re-runs), a fresh install with no existing env file + no
CLI overrides left those shell vars unset — the final status
block aborted the script with "unbound variable". Fix: add
post-merge := default assignments right before the heredoc, with
a comment pointing at the earlier bug so the next person doesn't
revert the post-merge block thinking it's the one that wipes
passwords.

[P2] server.py maintenance POST used bool(payload.get("active"))
which treats any non-empty JSON value (including the STRING
"false") as truthy. A misconfigured client could flip maintenance
mode on by mistake. Fix: require real JSON booleans for both
"active" and "restart" fields; 400 otherwise. "active" is
required, "restart" is optional (defaults false).

Tests:
- tests/test_shadow_stamp.sh: 3 scenarios covering first-boot
  shadow-matches-live, second-boot-stamps-on-no-drift, and
  second-boot-preserves-on-operator-drift. Extracts the jq
  reconcile directly so any drift between test and entrypoint.sh
  shows up as a test failure.
- tests/test_install_env_merge.sh: extended with a fresh-install
  set -u test that fails if any of UI_BIND / UI_PORT /
  UI_PASSWORD / UI_ENABLE_ADMIN_WITHOUT_PASSWORD end up unset
  after the merge block.
- tests/test_http.py: new test_maintenance_requires_strict_bool
  covering the 5 bad-shape inputs the old code accepted + 3 good
  shapes that must still succeed. Also extends _TestServer's
  module-level patching to include MAINTENANCE_FLAG_FILE so the
  test doesn't try to touch the host's /home/steam.

All suites pass. Shadow-stamp test also added to CI workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@seslly seslly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tested in canary k8s and digital ocean

@seslly
seslly merged commit 37a6c14 into main Apr 21, 2026
10 checks passed
@seslly
seslly deleted the community-feedback-fixes branch April 21, 2026 08:00
seslly added a commit that referenced this pull request Apr 28, 2026
…laky

User-reported fresh `docker compose up` failed with:
  ERROR! Failed to install app '4129620' (Missing configuration)
  ERROR: SteamCMD exited with 8

Root cause (verified by clean-container A/B): SteamCMD's anonymous
app_update is flaky on the FIRST attempt within a container, then
succeeds reliably on attempt #2 with no other change. Probably a
Steam-side anonymous-license caching quirk that warms up after the
first call. Fresh containers always hit attempt #1.

Reproduced cleanly: cold attempt #1 FAILs, #2 OK + starts downloading,
#3 resumes #2's partial download at ~50-65%. The depots are public
anonymous regardless, the credentials are valid, the manifest is
present — Steam just returns the cryptic "Missing configuration"
on the cold call and accepts the warm one.

Existing canary / prod / VPS installs were unaffected because their
populated WindowsServer/ trees mean every boot's app_update is a
verify-only no-download path that doesn't go through the failing
licensing code. So this regression silently shipped to anyone trying
a clean `docker compose up` while the live deployments kept working.

Fix: retry the heredoc-based app_update up to 3 times, gated on
server_files_present (the .exe being on disk after the run).
SteamCMD resumes partial downloads across retries, so we don't pay
bandwidth twice. Most fresh installs succeed on attempt #2.

Switched from `+command` chain to stdin heredoc while we're here —
heredoc reads more like the official Windrose dedicated-server FAQ
examples that operators paste into interactive SteamCMD for
debugging, and is functionally equivalent post-warm-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
seslly added a commit that referenced this pull request Apr 28, 2026
* entrypoint: retry SteamCMD app_update — fresh-install bootstrap was flaky

User-reported fresh `docker compose up` failed with:
  ERROR! Failed to install app '4129620' (Missing configuration)
  ERROR: SteamCMD exited with 8

Root cause (verified by clean-container A/B): SteamCMD's anonymous
app_update is flaky on the FIRST attempt within a container, then
succeeds reliably on attempt #2 with no other change. Probably a
Steam-side anonymous-license caching quirk that warms up after the
first call. Fresh containers always hit attempt #1.

Reproduced cleanly: cold attempt #1 FAILs, #2 OK + starts downloading,
#3 resumes #2's partial download at ~50-65%. The depots are public
anonymous regardless, the credentials are valid, the manifest is
present — Steam just returns the cryptic "Missing configuration"
on the cold call and accepts the warm one.

Existing canary / prod / VPS installs were unaffected because their
populated WindowsServer/ trees mean every boot's app_update is a
verify-only no-download path that doesn't go through the failing
licensing code. So this regression silently shipped to anyone trying
a clean `docker compose up` while the live deployments kept working.

Fix: retry the heredoc-based app_update up to 3 times, gated on
server_files_present (the .exe being on disk after the run).
SteamCMD resumes partial downloads across retries, so we don't pay
bandwidth twice. Most fresh installs succeed on attempt #2.

Switched from `+command` chain to stdin heredoc while we're here —
heredoc reads more like the official Windrose dedicated-server FAQ
examples that operators paste into interactive SteamCMD for
debugging, and is functionally equivalent post-warm-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix ui container bind

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants