prole/docs/plans/junie/todo-1-cfg-save-path-bug.md
chrisfu 6f506a97b2 docs(plans): file todo-1 brief — cfg save path refuses non-string widget values
Followup from commit dba8a2d's findings. ConfigMixin._save_knoe_cfg in
knoe/ui/screens/cfg.py reads Tk widget vars via `var.get()` and writes
the result to conf/<mode>.cfg. When a var is a MagicMock (interactive
./install.py run in a non-Tk context, partially-mocked widgets), the
save path serializes the mock's repr-string into the cfg, then the next
installer pass calls os.makedirs() on those values and produces
directories literally named `<MagicMock name='Canvas().tk.call().strip()'
id='4743999712'>/`.

The brief lays out a TDD approach for Junie:
  1. Write failing test at tests/installer/test_cfg_save_refuses_mock_values.py
     that passes MagicMock widget vars and asserts _save_knoe_cfg raises
     TypeError naming the field.
  2. Implement the minimal fix: a `_str_value(var, field=...)` helper in
     cfg.py that validates widget reads and raises if non-str. Use it
     in the .get()/.strip() chains across lines 103-155.
  3. Verify the 750 existing installer tests still pass.

Brief includes file pointers (cfg.py:44 _save_knoe_cfg, line 102
globals_to_save assembly, line 277 cfg_path.write_text), the canonical
failing test stub, both fix-approach options (per-read validator vs
end-of-flow dict walk), and explicit commit-shape guidance.

Index updates:
  docs/plans/junie/README.md — todo-1 row added under Active
  docs/TODO.md §"In progress" — todo-1 promoted above Phase 3 (smaller
                                scope, easy to land first)

Naming convention: `todo-N-<slug>.md` for follow-up bugs, distinct from
the `NN-<slug>.md` pattern reserved for ranked queue items.
2026-05-05 20:43:44 -04:00

291 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Junie brief — TODO-1: cfg save path leaks `MagicMock` reprs into `conf/k3d.cfg`
> **Self-contained brief.** Filed as a follow-up to commit `dba8a2d`'s
> findings. Single-MR scope. TDD approach — write the failing test
> first, then fix.
---
## 1. Problem
`knoe/ui/screens/cfg.py::ConfigMixin._save_knoe_cfg` reads values from Tk
widget vars via `self.<var>.get()` and writes them to `conf/<mode>.cfg`
(via `_render_knoe_cfg` → `cfg_path.write_text`). When a widget var is a
`unittest.mock.MagicMock` instance instead of a real `tk.StringVar`, the
chain is:
```python
mock_var.get() # returns another MagicMock
.strip() # returns another MagicMock
str(...) # calls MagicMock.__str__ → repr-like string
```
The result: the cfg file gets serialized values like
```ini
KNOE_CONF = <MagicMock name='Canvas().tk.call().strip()' id='4743999712'>
argocd.node_selector = <MagicMock name='mock.StringVar().get().strip()' id='4780691808'>
```
When the installer next reads the file and acts on those values
(`os.makedirs(KNOE_CONF)`, `kubectl apply --context $ctx`, etc.), it
either creates absurdly-named directories on disk, calls subprocesses
with non-existent paths, or does both. We saw the first failure mode
firsthand on 2026-05-02: 10 directories named
`<MagicMock name='Canvas().tk.call().strip()' id='…'>/` appeared in the
repo root with 5–86 install artifacts inside each, and `conf/k3d.cfg`
was corrupted with the mock-strings as values.
Both were cleaned up in `dba8a2d`. **This brief is the durable fix.**
### How the misconfiguration arises
The failure mode is **not in the test suite** — Junie's tests use a
proper `_Var` stub class that returns real strings (see
`tests/installer/test_cfg_save_kubecontext.py::_Var`). The corruption
happened when the user ran `./install.py -c conf/k3d.cfg` interactively
in an environment where the Tk widgets weren't fully initialized
(headless / partial-mock / partial-Tk state — the exact trigger
deserves a small investigation, but the fix should be robust whether
or not we ever reproduce the trigger).
The point: the cfg save path **trusts** that `var.get()` returns a
string. It must not.
## 2. Reproduction (the canonical failing case)
Write this test first, in `tests/installer/test_cfg_save_refuses_mock_values.py`
(new file). Confirm it **fails** before changing any production code.
```python
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from knoe.ui.screens.cfg import ConfigMixin
class _DummyCfgAppWithMockVar(ConfigMixin):
"""Worst-case input: every widget var is a bare MagicMock.
This is what happens when an interactive `./install.py -c conf/k3d.cfg`
run launches the TUI in a context where Tk variables aren't real
StringVars (headless env, partial mock setup, etc.).
"""
def __init__(self, conf_dir: Path):
self._conf_dir = conf_dir
self._cfg_path_override = conf_dir / "knoe.cfg"
self._mode = "k3d"
self._resolved_service_namespace = "knoe-system"
# Every var is a MagicMock — `var.get()` returns MagicMock,
# `.strip()` returns MagicMock, `str(...)` returns a repr.
for name in (
"cluster_env", "selected_kubectx", "db_username", "db_password",
"db_namespace", "cnpg_cluster_name", "db_host_port",
"supabase_pv_node", "supabase_pv_base_dir",
"gitops_node_selector", "argocd_node_selector",
"k3s_server_url", "k3s_token", "prod_artifacts_path",
):
setattr(self, name, MagicMock())
# ConfigMixin needs these helpers; stub them minimally.
def _secret_cfg_value(self, *_args, **_kwargs):
return ""
def _save_ansible_knoe_vault(self, *_args, **_kwargs):
return None
def test_save_knoe_cfg_refuses_non_string_widget_values(tmp_path):
"""The save path must not serialize MagicMock (or any non-str) values
into the cfg file. Either raise a clear TypeError, or skip the
offending field. Silent str()-coercion-to-repr is forbidden.
"""
app = _DummyCfgAppWithMockVar(tmp_path)
cfg_path = tmp_path / "knoe.cfg"
with pytest.raises((TypeError, ValueError)) as excinfo:
app._save_knoe_cfg()
# The error must name the offending field/var so the engineer can
# diagnose without grepping mock-reprs out of files.
assert "MagicMock" in str(excinfo.value) or "non-string" in str(excinfo.value).lower()
# The cfg file must NOT contain any MagicMock string-reprs even if
# save partially succeeded before raising.
if cfg_path.exists():
text = cfg_path.read_text()
assert "<MagicMock" not in text, f"cfg leaked MagicMock repr:\n{text}"
```
This test should fail today: `_save_knoe_cfg` will silently coerce the
mocks via `str()` and write the bad cfg, no exception raised.
A second follow-up test should verify the **real-string path is
unaffected** — re-use the `_Var` stub from
`tests/installer/test_cfg_save_kubecontext.py` (move it to a shared
`tests/installer/conftest.py` if helpful) and assert `_save_knoe_cfg`
still produces a clean cfg file.
## 3. Expected behavior
Two acceptable resolutions; pick whichever has the smaller blast radius.
### Option A — fail loudly at save time (recommended)
A type-validating helper used wherever a widget value is read in
`_save_knoe_cfg`:
```python
def _str_value(var, *, field: str) -> str:
"""Read a Tk var's value and assert it's actually a string."""
raw = var.get() if hasattr(var, "get") else var
if not isinstance(raw, str):
raise TypeError(
f"cfg field {field!r}: expected str from {type(var).__name__}.get(), "
f"got {type(raw).__name__} ({raw!r}). "
"This usually means the Tk widget wasn't initialized properly "
"(headless env, partial mocking, etc.)."
)
return raw
```
Replace every `self.<x>.get()` and `(self.<x>.get() or "").strip()` in
`_save_knoe_cfg` (lines 103–155 in cfg.py) with `_str_value(self.<x>,
field="<X>")` and call `.strip()` on the result if needed. Tests using
the real `_Var` stub keep working unchanged because `_Var.get()`
returns strings. Tests passing `MagicMock` get a clear `TypeError`.
The error must surface BEFORE `cfg_path.write_text(cfg_text)` — i.e.
no partial cfg gets written.
### Option B — validate the assembled `globals_to_save` dict
Less invasive: just before line 277 (`cfg_text = _render_knoe_cfg(...)`),
walk the `inputs` dict and `globals_to_save` dict, raise on any
non-string value, and don't write the file if anything's wrong.
```python
def _validate_cfg_values(name: str, mapping: dict) -> None:
for k, v in mapping.items():
if not isinstance(v, str):
raise TypeError(
f"cfg {name}: field {k!r} has non-string value "
f"{type(v).__name__} ({v!r}). Refusing to serialize."
)
_validate_cfg_values("globals", globals_to_save)
_validate_cfg_values("inputs", inputs)
```
Pros: one validation point. Cons: error message is later in the call
stack and slightly less helpful for debugging the originating widget.
**Pick A** unless the diff for A blows up larger than a screen of
changes — in which case B is fine. Document the choice in the commit.
## 4. Where the bug lives (file pointers)
| File | Lines | What's there |
|---|---|---|
| `knoe/ui/screens/cfg.py` | 44 (`_save_knoe_cfg`) → 277 (`cfg_path.write_text`) | The save flow. The `.get().strip()` pattern is sprinkled across lines 103–155. |
| `knoe/ui/screens/cfg.py` | 102 (`globals_to_save = {...}`) | Where the dict is assembled from widget reads. |
| `tests/installer/test_cfg_save_kubecontext.py` | top | Has the `_Var` stub class. The good pattern — values are real strings. |
| `tests/installer/test_cluster_save_triggers_status_check.py` | (modified by Junie 2026-05-02) | Existing test file; check whether its mocking pattern is similar enough that it could trip the bug if the assertion were broader. |
## 5. TDD approach (Junie convention)
1. **Write the failing test first** at
`tests/installer/test_cfg_save_refuses_mock_values.py` per §2 above.
Run pytest, confirm it fails. Capture the failure mode in a one-line
note for the commit message.
2. **Implement the minimal fix** — Option A or B from §3. Don't refactor
beyond what the fix needs.
3. **Run pytest** until the new test passes. Crucially: also run the
full `tests/installer/` suite (≈750 tests) and make sure none
regressed. The existing `_Var`-based tests should still pass without
modification.
4. **Refactor only if needed** to keep the tests readable. If you move
the `_Var` helper to a shared `conftest.py`, fix all the tests that
imported it inline.
5. **Add a brief README note** in `docs/local-dev-knoe-auth.md` (only
if the failure is something an engineer is likely to hit) — e.g.
"If `./install.py -c conf/k3d.cfg` raises `TypeError: cfg field
'KNOE_CONF': expected str…`, your Tk widgets aren't initialized.
Common cause: …" — only if there's a generally useful diagnostic
path to surface; skip otherwise.
## 6. Don't break
- The 750 existing `tests/installer/` tests must still pass. The fix
should be invisible to any test that uses real strings.
- The existing `_Var` stub (in
`tests/installer/test_cfg_save_kubecontext.py`) must keep working.
Don't tighten the validator beyond `isinstance(value, str)` — accept
any string, including empty.
- The cfg file format must not change. This brief is about input
validation, not serialization format.
## 7. Definition of done
- [ ] New test `test_cfg_save_refuses_mock_values.py` exists; fails on
the unmodified production code; passes after the fix.
- [ ] All `tests/installer/` tests pass (750+, currently 24s wall).
- [ ] `_save_knoe_cfg` raises a clear `TypeError` (or `ValueError`) on
any non-string widget value, naming the offending field. No partial
cfg gets written.
- [ ] Manual sanity: from a Python repl,
```python
from unittest.mock import MagicMock
from knoe.ui.screens.cfg import ConfigMixin
app = type("X", (ConfigMixin,), {})()
app.cluster_env = MagicMock()
...
app._save_knoe_cfg() # → TypeError naming 'cluster_env' (or whatever)
```
- [ ] No `<MagicMock` string ever appears in any committed cfg file.
## 8. Commit shape
```
fix(installer): cfg save refuses non-string widget values
When `./install.py -c conf/k3d.cfg` runs in a non-Tk context (or with
partially-mocked widgets), `ConfigMixin._save_knoe_cfg` previously
called `var.get()` on each widget and serialized the result as-is. If
the var was a MagicMock, `str()` produced repr-strings like
`<MagicMock name='Canvas().tk.call().strip()' id='4743999712'>` which
got written into `conf/k3d.cfg` and then turned into directory names
on the next install pass. Cleaned up in dba8a2d; this is the durable
fix.
knoe/ui/screens/cfg.py _str_value() helper validates each widget
read; non-str values raise TypeError naming
the offending field. No partial cfg gets
written.
tests/installer/test_cfg_save_refuses_mock_values.py NEW; failing
test that produced the original repro
(MagicMock vars across the board); passes
after the fix.
All 750+ installer tests still pass. Closes the followup flagged in
dba8a2d.
```
## 9. Out of scope
- **Investigating why Tk vars were MagicMocks during interactive
runs.** Probably a partial-init path in `KnoeInstaller` when run
outside a real X / Aqua display, or the `mock` test harness leaking
into a real run. Worth a separate followup; this brief is purely
defensive at the save boundary.
- **Schema/validation for cfg keys themselves.** Whether
`argocd.node_selector` should be a node-selector string vs an empty
string is a different question. We're only asserting "is a string."
- **Refactoring `_save_knoe_cfg`** beyond the fix. The function is long
but works; restructuring it is a different MR.