feat(hooks): fold hooks into Save Configuration#137
Conversation
There was a problem hiding this comment.
PR Review — v1.13.7: Hooks save through Save Configuration
Summary of Changes
Removes the dedicated PUT /instances/<id>/hooks + "Apply & Restart" button, merging hook selection state into the existing PUT /instances/<id>/config save flow. HooksTab is refactored from a self-contained stateful component to a controlled component. The Ansible sync_instance_configs_and_restart.yml playbook is extended to sync the user-hooks/ directory to the game host.
Strengths
- Clean state lift (
HooksTab→EditInstanceConfigModal): eliminates the dual-save inconsistency where hooks and config could be saved independently, leaving the instance in an incoherent state.frontend-react/src/components/instances/HooksTab.jsx - Correct restart semantics: running instances with hook changes are force-restarted; stopped instances stay stopped. Both the backend route (
ui/routes/instance_routes.py:1068–1093) and the frontend toggle (EditInstanceConfigModal.jsx:474–481) enforce this symmetrically. previous_statuspassthrough fixes a latent bug: previously,apply_instance_config_logicreadinstance.statusinside the task, which would already beCONFIGURING(the route sets it before enqueuing). The restored-status logic (set final status to STOPPED whenrestart=Falseand was stopped) never worked correctly before this PR.ui/task_logic/ansible_instance_mgmt.py:2022–2025,ui/tasks.py:2038hookLoadSeqRefcancellation pattern prevents stale state from concurrent or cancelled hook fetches.EditInstanceConfigModal.jsx:342–390os.makedirs(instance_user_hooks_dir, exist_ok=True)prevents rsync failures on legacy instances that never had auser-hooks/directory.ui/routes/instance_routes.py:1063- Rsync filter (
--include=*.so --exclude=*) limits the sync to only.sofiles, so stray files on the game host are not clobbered.ansible/playbooks/sync_instance_configs_and_restart.yml - Good test coverage: new backend tests directly assert the forced-restart and keep-stopped invariants at the API boundary.
tests/test_instance_api_routes.py:1496–1590
Issues
Critical (Must Fix)
None identified.
Important (Should Fix)
1. Reserved filename protection may be absent on the config save path
tests/test_instance_hooks_routes.py:1638–1665 — the old PUT /hooks route rejected force_rate.so (reserved for the system-hook pipeline) with a 400. The new config path calls _filtered_enabled_hooks(enabled_hooks_data, instance_user_hooks_dir), which silently drops entries that don't exist in user-hooks/ but does not appear to check RESERVED_HOOK_FILENAMES. If a user can get force_rate.so into their user-hooks/ directory (e.g., via a rename through PATCH /hooks/files/<filename>), they could shadow the system hook. Verify that either: (a) the upload/rename endpoints reject reserved names, or (b) _filtered_enabled_hooks excludes them. If neither holds, add a reserved-name filter in the config route.
2. confirmDelete does not await onRefresh
frontend-react/src/components/instances/HooksTab.jsx:62–87:
await deleteInstanceHook(instanceId, pendingDelete.filename);
setPendingDelete(null);
onRefresh?.(); // fire-and-forget — no awaitonRefresh calls loadHooks(), which is async. If the hook list refresh is slow and the user immediately clicks Save Configuration, the payload will contain a stale hookEnabledOrder that may still include the just-deleted hook. The backend will filter it out, so no data corruption occurs, but the frontend will show a stale order until the refresh resolves. Awaiting onRefresh and holding off on closing the modal until it completes would be cleaner:
setPendingDelete(null);
await onRefresh?.();3. hooksDirty / hookDiskChanged is not reset after a successful save
EditInstanceConfigModal.jsx:468–473 — hookDiskChanged is set to true when a hook binary is replaced (upload over an existing enabled hook) and is never cleared. If Save Configuration succeeds but the modal remains open (e.g., on a save error retry scenario where the first attempt failed), hooksDirty will remain true, and running instances will keep the restart toggle forced on even though the last save was already applied. The fix is to reset hookDiskChanged (and re-seed initialHookEnabledOrder) after a successful save, just as other tabs reset their dirty state.
Minor (Nice to Have)
4. hooksKeepStopped doesn't restore the restart toggle when the user reverts hook changes
EditInstanceConfigModal.jsx:479–481:
if (hooksForceRestart) setRestartAfterSave(true);
if (hooksKeepStopped) setRestartAfterSave(false);When hooksKeepStopped becomes false (user reverts all hook changes), restartAfterSave stays at false. The toggle is re-enabled (since restartForced becomes false), so the user can manually re-enable it, but the visual state is surprising. Consider storing the pre-force value and restoring it on revert.
5. test_put_hooks_route_removed skips auth
tests/test_instance_hooks_routes.py:1683–1685:
def test_put_hooks_route_removed(client):
resp = client.put('/api/instances/1/hooks', json={'enabled': []})
assert resp.status_code == 405The test doesn't send an auth header and passes because 405 is returned before auth. This is acceptable but note that it tests HTTP routing, not security. Consider adding a complementary test that sends valid auth and confirms 405 (versus a hypothetical 401 masking the route removal).
6. Ansible: no --checksum flag on hook rsync
ansible/playbooks/sync_instance_configs_and_restart.yml:24–34 — rsync defaults to mtime+size comparison. Replaced .so files with identical size but different content (e.g., a patched hook binary re-uploaded at the same size) may not be re-synced if the game host mtime happens to match. Adding --checksum removes this edge case at the cost of a slightly slower sync. Low priority given how .so files are typically managed, but worth noting.
7. enabled_hooks silently drops invalid filenames rather than warning
The old PUT /hooks returned 400 for each invalid or missing entry, giving the client explicit feedback. The new path (_filtered_enabled_hooks) silently drops non-existent entries. This is documented in the API reference and is intentional, but callers have no way to tell whether their hook list was truncated. A response field like "dropped_hooks": ["stale.so"] in the 202 body would aid debugging.
Assessment
Ready to merge? Yes, with the Important issues tracked.
Reasoning: The core refactor is architecturally sound and fixes a real usability pain point. The previous_status fix corrects a latent bug that prevented stopped-instance semantics from working correctly. The Important issues (reserved filename bypass risk, fire-and-forget refresh on delete, dirty-state not reset post-save) are all low-severity in practice given the upload-time validation and backend filtering, but should be addressed in a follow-up to keep the defenses layered correctly.
Fresh ReviewVerdictREQUEST_CHANGES FindingsImportant
Verification
Notes
|
The per-row Replace flow called onChanged?.() with no args, so replacing
an enabled hook's binary on a running instance left hooksDirty false and
did not force the restart toggle. The new .so would not load until a later
manual restart, violating the running-hook-change restart invariant.
Propagate { hooksChanged: hook.enabled } from both replace handlers,
mirroring the upload-over-enabled path in HooksTab.handleUpload.
Addressed in 97ca9b9Fixed the one confirmed issue: replacing an already-enabled hook binary now forces a restart. Root causeThe per-row Replace handlers in FixBoth replace handlers now propagate On the backend
|
Assert the per-row Replace flow propagates { hooksChanged } based on the
hook's enabled state: true for an enabled hook (forces restart), false for
a disabled one. Verified both fail without the HookRow.jsx fix.
|
Test coverage added in f13bac9 (
Both drive the real |
Show the Load Preset and Save Preset buttons on the Hooks tab like every other tab in the instance config editor. Save/Overwrite Preset now capture the live hook draft (enablement + order) instead of re-fetching persisted instance hooks, so a preset saved from the Hooks tab reflects the current selection. Bump v1.13.8.
Summary
user-hooks/during the general config-save playbook and guarantee the local rsync source dir exists.PUT /instances/<id>/hooksapply route andsaveInstanceHooksclient call while keeping system-hook task paths intact.1.13.7.Test Plan
.venv/bin/pytest tests/test_sync_configs_playbook_hooks.py tests/test_instance_api_routes.py tests/test_instance_hooks_routes.py tests/test_instance_hooks_files.py tests/test_task_apply_config.py -q→85 passedpnpm --dir frontend-react exec vitest run src/components/instances/__tests__/EditInstanceConfigModal.test.jsx src/components/instances/__tests__/HooksTab.test.jsx src/services/__tests__/api.test.js→38 passedpnpm --dir frontend-react exec eslint src/components/instances/EditInstanceConfigModal.jsx src/components/instances/HooksTab.jsx src/components/instances/__tests__/EditInstanceConfigModal.test.jsx src/components/instances/__tests__/HooksTab.test.jsx src/services/api.js src/services/__tests__/api.test.js.venv/bin/pytest tests/test_preset_download_routes.py tests/test_preset_import_routes.py tests/test_preset_import_validation.py tests/test_preset_routes.py tests/test_draft_routes.py -q→176 passedpnpm --dir frontend-react exec vitest run src/services/__tests__/api.test.js src/components/presetManager/__tests__/PresetManagerModal.test.jsx src/components/presetManager/__tests__/PresetLoadTab.test.jsx src/components/presetManager/__tests__/PresetSaveTab.test.jsx src/utils/__tests__/presetDownload.test.js→27 passeduser-hooks/custom_hook.so+enabled_hooks.json: download ZIP contained both; import preserved hook file and enabled list.python3 -m compileall -q ui/routes/instance_routes.py ui/tasks.py ui/task_logic/ansible_instance_mgmt.py tests/test_instance_api_routes.py tests/test_task_apply_config.pygit diff --check origin/main...HEADNotes
pnpm lintstill has unrelated pre-existing repo errors; changed frontend files pass targeted eslint.pytestin this shell points at the Hermes venv and lacks repo deps likeredis; backend verification uses.venv/bin/pytestfor this repo.