Edit autostart, startup order and tags from the info screen
CI / pytest and ruff (pull_request) Successful in 5m31s
CI / pytest and ruff (push) Has been cancelled

The info screen's Resources table shows Autostart (yes/no), Startup
(order, up and down delays) and Tags. o opens a dialog to change them.
Only keys that differ are sent, in one `set <kind> config` call
through the existing set_net. A confirm lists the old and new value
of each key.

Tags are removed with `--delete tags` on set config. The CLI also has
a `delete ct|vm ... tags` command, but it shares the form that
destroys the whole guest, so the app never uses it; nothing in the
code builds a delete ct|vm argv. Inputs are validated before sending:
startup values must be ints >= 0, and tags must match
[A-Za-z0-9_][A-Za-z0-9_+.-]*. They are split on ; , or whitespace and
deduplicated.

Live test on CT 104: onboot 1 -> 0, startup order=5,up=10, tag
tui-test added, then all set back through the dialog. The config
ended identical to the baseline.

Closes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #30.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-03 00:55:02 +01:00
1 parent d97ad22c78
commit f5f3effce5
6 files changed
+634

No files matched your search

+320
View File
@@ -3875,3 +3875,323 @@ def test_guest_matches_tolerates_none_status_and_type():
assert appmod.guest_matches(g, "x")
assert not appmod.guest_matches(g, "is:running")
assert not appmod.guest_matches(g, "type:lxc")
# --- #18: autostart, startup order and tags (spec: "Autostart, startup order and tags") ---
CT_BOOT = {"onboot": 1, "startup": "order=2,up=10",
"tags": "172.16.41.200;community-script;os"} # fmt: skip
async def open_boot(app, pilot, down=1):
await open_info(app, pilot, down=down) # down=1: CT 100 "backrest"
await pilot.press("o")
await settle(app, pilot)
assert isinstance(app.screen, appmod.BootScreen)
def setb(app, **values):
for key, value in values.items():
w = app.screen.query_one(f"#{key}")
w.value = value
async def boot_to_confirm(app, pilot, **values):
setb(app, **values)
await press_button(app, pilot, "#apply")
assert isinstance(app.screen, ConfirmScreen)
return text_of(app.screen)
async def boot_change(app, pilot, **values):
await boot_to_confirm(app, pilot, **values)
await press_button(app, pilot, "#yes")
def any_text(screen, needle):
titles = [str(w.border_title or "") for w in screen.query("*")]
return needle in text_of(screen) or any(needle in t for t in titles)
@pytest.mark.parametrize("onboot", [1, "1"])
async def test_info_resources_show_autostart_startup_tags(
fake, onboot
): # Resources rows
fake.net_configs[100] = {**CT_BOOT, "onboot": onboot}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
rows = resource_rows(app)
assert rows["Autostart"] == "yes"
assert rows["Startup"] == "order 2, up 10 s"
assert rows["Tags"] == "172.16.41.200, community-script, os"
used = in_use_rows(app)
assert used["Autostart"] == used["Startup"] == used["Tags"] == "-"
async def test_info_shows_full_startup_with_down(fake):
fake.net_configs[100] = {"startup": "order=2,up=10,down=30"}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
assert resource_rows(app)["Startup"] == "order 2, up 10 s, down 30 s"
async def test_info_shows_defaults_for_guest_without_boot_keys(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=1)
rows = resource_rows(app)
assert (rows["Autostart"], rows["Startup"], rows["Tags"]) == ("no", "-", "-")
async def test_info_shows_boot_rows_for_vm_too(fake): # LXC and VM
fake.net_configs[108] = {"onboot": 1, "startup": "up=5", "tags": "web"}
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_info(app, pilot, down=0)
rows = resource_rows(app)
assert rows["Autostart"] == "yes"
assert rows["Startup"] == "up 5 s"
assert rows["Tags"] == "web"
async def test_o_opens_boot_screen_prefilled(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
s = app.screen
assert any_text(s, "Boot and tags: backrest (100)")
assert s.query_one("#onboot", Checkbox).value is True
assert s.query_one("#order", Input).value == "2"
assert s.query_one("#up", Input).value == "10"
assert s.query_one("#down", Input).value == "" # empty = not set
assert s.query_one("#tags", Input).value == (
"172.16.41.200; community-script; os"
)
s.query_one("#apply", Button)
s.query_one("#cancel", Button)
async def test_o_opens_boot_screen_empty_for_guest_without_keys(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
s = app.screen
assert s.query_one("#onboot", Checkbox).value is False
for sel in ("#order", "#up", "#down", "#tags"):
assert s.query_one(sel, Input).value == ""
async def test_change_only_onboot_confirms_then_sets_once(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
busy_seen = []
fake.set_hook = lambda: busy_seen.append(set(app.busy))
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
before = len(fake.backups_calls)
text = await boot_to_confirm(app, pilot, onboot=False)
assert any_text(app.screen, "Boot and tags")
assert "onboot" in text.lower() or "autostart" in text.lower()
assert fake.set_calls == [] # nothing before "yes"
await press_button(app, pilot, "#yes")
assert fake.set_calls == [(100, {"onboot": "0"})]
assert busy_seen and busy_seen[0] # guest was busy during the call
assert app.busy == set() and errors(app) == []
assert isinstance(app.screen, appmod.InfoScreen)
assert len(fake.backups_calls) > before # info reloaded
assert resource_rows(app)["Autostart"] == "no"
async def test_enable_onboot_on_guest_without_keys_sends_string_one(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, onboot=True)
assert fake.set_calls == [(100, {"onboot": "1"})]
async def test_change_order_keeps_other_startup_parts(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, order="5")
assert fake.set_calls == [(100, {"startup": "order=5,up=10"})]
assert resource_rows(app)["Startup"] == "order 5, up 10 s"
async def test_set_startup_on_guest_without_keys(fake):
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, order="1", down="20")
assert fake.set_calls == [(100, {"startup": "order=1,down=20"})]
async def test_clearing_all_startup_fields_deletes_startup(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, order="", up="", down="")
assert fake.set_calls == [(100, {"startup": None})]
assert resource_rows(app)["Startup"] == "-"
async def test_adding_a_tag_sends_normalised_tag_string(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
text = await boot_to_confirm(
app, pilot, tags="172.16.41.200; community-script; os; tui-test"
)
assert "tui-test" in text
await press_button(app, pilot, "#yes")
assert fake.set_calls == [
(100, {"tags": "172.16.41.200;community-script;os;tui-test"})
]
assert resource_rows(app)["Tags"].endswith("tui-test")
async def test_clearing_tags_uses_set_config_delete_never_a_delete_command(
fake, monkeypatch
):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
def forbidden(*args, **kwargs): # any CLI call outside the fake would land here
raise AssertionError(f"unexpected CLI call: {args!r}")
monkeypatch.setattr(cli, "run", forbidden)
monkeypatch.setattr(cli.subprocess, "run", forbidden)
await boot_change(app, pilot, tags="")
assert fake.set_calls == [
(100, {"tags": None})
] # only set_net, None -> --delete
assert fake.delete_calls == [] and fake.actions == [] and fake.sets == []
assert errors(app) == []
assert resource_rows(app)["Tags"] == "-"
async def test_combined_changes_go_in_one_set_net_call(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, onboot=False, order="7", tags="")
assert fake.set_calls == [
(100, {"onboot": "0", "startup": "order=7,up=10", "tags": None})
]
@pytest.mark.parametrize(
"values",
[
{"tags": "-x"},
{"tags": "a;b!"},
{"order": "-1"},
{"order": "abc"},
{"up": "-5"},
{"down": "1.5"},
],
)
async def test_invalid_boot_input_errors_without_call_and_dialog_stays(fake, values):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
setb(app, **values)
await press_button(app, pilot, "#apply")
assert errors(app) and fake.set_calls == []
assert isinstance(app.screen, appmod.BootScreen)
assert app.busy == set()
async def test_boot_no_change_gives_info_notify_no_confirm_no_call(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await press_button(app, pilot, "#apply")
assert not isinstance(app.screen, ConfirmScreen)
assert fake.set_calls == [] and errors(app) == []
assert any(sev == "information" for _, sev in app.seen)
@pytest.mark.parametrize("how", ["cancel", "escape"])
async def test_cancel_or_escape_in_boot_screen_makes_no_call(fake, how):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
setb(app, onboot=False, tags="x")
if how == "cancel":
await press_button(app, pilot, "#cancel")
else:
await pilot.press("escape")
await settle(app, pilot)
assert isinstance(app.screen, appmod.InfoScreen)
assert fake.set_calls == [] and app.busy == set()
async def test_declining_boot_confirm_makes_no_call(fake):
fake.net_configs[100] = dict(CT_BOOT)
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_to_confirm(app, pilot, onboot=False)
await press_button(app, pilot, "#no")
assert fake.set_calls == [] and app.busy == set()
async def test_boot_apply_while_guest_busy_warns_and_makes_no_second_call(fake):
fake.net_configs[100] = dict(CT_BOOT)
started, release = threading.Event(), threading.Event()
fake.set_hook = lambda: (started.set(), release.wait(10))
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
try:
await boot_to_confirm(app, pilot, onboot=False)
app.screen.query_one("#yes", Button).press()
for _ in range(200):
if started.is_set():
break
await pilot.pause(0.01)
assert started.is_set()
await to_info(app, pilot)
await pilot.press("o")
await pilot.pause()
if isinstance(
app.screen, appmod.BootScreen
): # refusal may come at apply/yes
setb(app, tags="x")
app.screen.query_one("#apply", Button).press()
await pilot.pause()
if isinstance(app.screen, ConfirmScreen):
app.screen.query_one("#yes", Button).press()
await pilot.pause()
assert any(sev == "warning" for _, sev in app.seen)
assert len(fake.set_calls) == 1
finally:
release.set()
await settle(app, pilot)
assert len(fake.set_calls) == 1
async def test_boot_set_net_failure_notifies_error_and_releases_busy(fake):
fake.net_configs[100] = dict(CT_BOOT)
fake.set_error = cli_error("Error: unable to apply onboot")
app = make_app()
async with app.run_test(size=SIZE) as pilot:
await open_boot(app, pilot)
await boot_change(app, pilot, onboot=False)
assert any("unable to apply onboot" in m for m in errors(app))
assert app.busy == set() and app.is_running
assert len(fake.set_calls) == 1
+116
View File
@@ -1388,3 +1388,119 @@ def test_version_argv_and_strips(fake):
def test_tested_version_constant():
assert cli.TESTED_VERSION == "2.4.0"
# --- #18: startup order and tags (spec: "Autostart, startup order and tags") ----------
@pytest.mark.parametrize(
("args", "expected"),
[
((2, 10, 30), "order=2,up=10,down=30"),
((2, 10, None), "order=2,up=10"),
((2, None, None), "order=2"),
((None, 10, None), "up=10"),
((None, None, 30), "down=30"),
((None, 10, 30), "up=10,down=30"),
((0, 0, 0), "order=0,up=0,down=0"), # 0 is a value, not "unset"
((None, None, None), None),
],
)
def test_startup_value_builds_only_the_given_parts(args, expected):
assert cli.startup_value(*args) == expected
@pytest.mark.parametrize(
"args", [(-1, None, None), (None, -5, None), (None, None, -30), (1, 2, -1)]
)
def test_startup_value_rejects_negatives(args):
with pytest.raises(ValueError):
cli.startup_value(*args)
@pytest.mark.parametrize("bad", ["x", 1.5, [1]])
def test_startup_value_rejects_non_ints(bad):
for args in ((bad, None, None), (None, bad, None), (None, None, bad)):
with pytest.raises(ValueError):
cli.startup_value(*args)
@pytest.mark.parametrize(
("text", "expected"),
[
("order=2,up=10,down=30", (2, 10, 30)),
("3", (3, None, None)), # a bare leading number is the order
("up=5", (None, 5, None)),
("order=2,up=10", (2, 10, None)),
("3,up=5", (3, 5, None)),
("down=7", (None, None, 7)),
(None, (None, None, None)),
("", (None, None, None)),
],
)
def test_parse_startup(text, expected):
assert cli.parse_startup(text) == expected
@given(
st.tuples(*[st.one_of(st.none(), st.integers(min_value=0, max_value=10**6))] * 3)
)
def test_parse_startup_inverts_startup_value_property(parts):
assert cli.parse_startup(cli.startup_value(*parts)) == parts
@pytest.mark.parametrize(
("text", "expected"),
[
("os", "os"),
("a;b;c", "a;b;c"),
("a,b", "a;b"),
("a b", "a;b"), # whitespace separates: two tags, not one with a space
("a;b, c d", "a;b;c;d"),
("a; b; c", "a;b;c"),
("a;b;a;c;b", "a;b;c"), # duplicates dropped, first occurrence wins
("c;a;b", "c;a;b"), # order kept, not sorted
(";;a;;,, b;", "a;b"), # empties dropped
("172.16.41.200", "172.16.41.200"), # IP-like tag is valid
("172.16.41.200;community-script;os", "172.16.41.200;community-script;os"),
("_x;a+b;a.b;a-b;9", "_x;a+b;a.b;a-b;9"),
],
)
def test_tags_value_normalises(text, expected):
assert cli.tags_value(text) == expected
@pytest.mark.parametrize("text", ["", " ", ";", " ; , ;", "\n\t"])
def test_tags_value_empty_is_none(text):
assert cli.tags_value(text) is None
@pytest.mark.parametrize(
"bad", ["-x", "a;b!", "!", "a;-b", ".a", "+a", "a/b", "a;b@c", "ä"]
)
def test_tags_value_rejects_invalid_tags(bad):
with pytest.raises(ValueError):
cli.tags_value(bad)
_TAG = st.from_regex(r"[A-Za-z0-9_][A-Za-z0-9_+.-]*", fullmatch=True)
_SEP = st.sampled_from([";", ",", " ", "; ", ", ", " ", ";;"])
@st.composite
def _valid_tag_text(draw):
tags = draw(st.lists(_TAG, max_size=6))
out = ""
for tag in tags:
out += tag + draw(_SEP)
return out
@given(_valid_tag_text())
def test_tags_value_is_idempotent_property(text):
once = cli.tags_value(text)
if once is None:
assert text.replace(";", "").replace(",", "").strip() == ""
else:
assert cli.tags_value(once) == once
assert len(set(once.split(";"))) == len(once.split(";")) # no duplicates