Skip to content

Commit f80e7e6

Browse files
oliverkurthGitHub Enterprise
authored andcommitted
Merge pull request #67 from vcf/topic/okurth/test-curses-install
test curses install
2 parents 70a57f8 + 129c766 commit f80e7e6

8 files changed

Lines changed: 503 additions & 1 deletion

File tree

.github/workflows/photon-os-installer.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@ jobs:
4242
pip install -r requirements.txt # Ensure this file lists the necessary dependencies
4343
4444
- name: install pytest
45-
run: pip install pytest
45+
run: pip install pytest pexpect
4646

4747
- name: Run Pytest
4848
run: |
4949
pytest -x tests/poi-container-test.py
5050
51+
- name: Run interactive installer dialog tests
52+
run: |
53+
pytest -x tests/test_iso_dialogs.py
54+

.github/workflows/poi-vcf.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@ jobs:
2727
rm -r "$VENV_DIR"
2828
exit $STATUS
2929
30+
dialog-tests:
31+
runs-on: self-hosted
32+
steps:
33+
- name: Checkout code
34+
uses: actions/checkout@v3
35+
36+
- name: Run interactive installer dialog tests
37+
run: |
38+
VENV_DIR="$(mktemp -d /var/tmp/venv.XXXXXX)"
39+
# --without-pip + a --python-targeted install avoids relying on
40+
# ensurepip's bundled wheels, which aren't available for every
41+
# python3 in this runner fleet.
42+
python3 -m venv --without-pip "$VENV_DIR"
43+
# requirements.txt is needed too: the dialog code under test
44+
# (photon_installer/commandutils.py, installer.py) imports
45+
# requests/yaml/OpenSSL/jc transitively, even though the tests
46+
# themselves never touch the network/disk/packages.
47+
python3 -m pip --python "$VENV_DIR/bin/python3" install -r requirements.txt pytest pexpect --index-url https://packages.vcfd.broadcom.net/artifactory/api/pypi/pypi/simple/
48+
"$VENV_DIR/bin/pytest" -x tests/test_iso_dialogs.py
49+
STATUS=$?
50+
rm -r "$VENV_DIR"
51+
exit $STATUS
52+
3053
build-container:
3154
runs-on: [ self-hosted, "docker:root" ]
3255
steps:
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Testing the interactive (curses) installer
2+
3+
The interactive installer's dialog flow (`photon_installer/iso_config.py`
4+
and the screens under `photon_installer/`) is covered by an automated,
5+
scripted walkthrough in `tests/test_iso_dialogs.py`. This doc explains how
6+
that works and how to extend it.
7+
8+
Out of scope here: booting the real ISO end-to-end in a VM. That's handled
9+
separately, and there's already coverage for installing from an ISO image
10+
via other tests/tooling.
11+
12+
## Why this is possible
13+
14+
`Installer.configure()` (`photon_installer/installer.py`) cleanly separates
15+
gathering answers from acting on them:
16+
17+
- The curses dialogs (driven by `IsoConfig.configure()` in
18+
`photon_installer/iso_config.py`) only build up an `install_config` dict.
19+
- `Installer.execute()` is the part that actually partitions disks, installs
20+
packages, etc., and is a separate call that these tests never make.
21+
22+
`iso_config.py` also has a `main()` ("for debugging") that runs just the
23+
dialog flow standalone via `curses.wrapper(IsoConfig.configure, ui_config)`
24+
and prints the resulting JSON:
25+
26+
```
27+
cd photon_installer
28+
python3 iso_config.py -f sample_ui_config.json
29+
```
30+
31+
`photon_installer/sample_ui_config.json` is a minimal working ui_config
32+
(`eula_file_path`/`license_display_title` set to `null`, picking up the
33+
defaults). Both keys are required — `add_ui_pages()` reads them with plain
34+
dict indexing (`ui_config['eula_file_path']`), so a `ui_config.json` missing
35+
either one raises a `KeyError` before the first screen even renders; the
36+
debug `main()` doesn't fill in defaults for them itself. This run uses your
37+
machine's *real* disks (`SelectDisk` isn't stubbed here — only the
38+
automated tests stub it) but is still safe: this path only ever calls
39+
`IsoConfig.configure()`, never `Installer.execute()`, so nothing is written
40+
to disk regardless of what you select.
41+
42+
That's the manual, human-drivable way to click through the screens for a
43+
quick sanity check. `tests/test_iso_dialogs.py` automates the same idea.
44+
45+
Only one screen touches the real system: `SelectDisk` /
46+
`CustomPartition` call `Device.refresh_devices()`
47+
(`photon_installer/device.py`), which shells out to `lsblk`. That's stubbed
48+
out for the tests (see below). `NetworkConfigure` does **not** probe real
49+
network interfaces — it only ever offers a fixed menu of DHCP/static/VLAN
50+
options — so it needs no stubbing.
51+
52+
## Driving the TUI with `pexpect`
53+
54+
`tests/tui_driver.py` wraps `pexpect`: it spawns the dialog process in a
55+
pty and lets tests `expect()` known marker text (window titles, prompts)
56+
and `send()` key sequences (arrows, tab, enter, plain text) in response —
57+
no terminal-emulation/screen-buffer library needed. Each curses screen
58+
renders distinctive, literal strings (e.g. `"Select a disk"`, `"Choose the
59+
hostname for your system"`) that survive being interleaved with
60+
ANSI/cursor-movement bytes, so sequential `expect()` calls reliably confirm
61+
which screen is up before the next batch of keys is sent.
62+
63+
Known limitation: `pexpect` has no screen model, so it can't tell which
64+
menu item currently has the highlight (that's conveyed via color/reverse
65+
video, not text). Tests work around this by scripting a fixed, known-good
66+
key sequence per screen (e.g. "press Down once, then Enter" to move off the
67+
default choice) instead of reading back highlight state.
68+
69+
`TERM` is pinned to `"linux"` in `tui_driver.py` rather than left to the
70+
environment: the `"linux"` terminfo entry maps arrow keys to the plain
71+
`ESC [ A/B/C/D` byte sequences, whereas e.g. `"xterm"`'s terminfo uses
72+
`ESC O A/B/C/D` — an easy way to get silently-ignored keystrokes if `TERM`
73+
varies by environment.
74+
75+
## Fixtures/stubs (`tests/fixtures/`)
76+
77+
`tests/fixtures/iso_config_stub_entrypoint.py` is what `pexpect` actually
78+
spawns. Before calling `IsoConfig().configure()` it patches out three real
79+
external dependencies the dialogs would otherwise pull in, so the tests
80+
have no system-package requirements and no dependence on the machine
81+
they're running on:
82+
83+
- `Device.refresh_devices` → returns a small fixed list of fake disks
84+
(default: one 10 GiB disk at `/dev/fakea`), overridable via the
85+
`POI_TEST_FAKE_DISKS` env var (JSON list of
86+
`{"model", "path", "size_bytes"}`).
87+
- `cracklib.VeryFascistCheck` → faked to accept any password. Password
88+
strength policy isn't what these tests exercise.
89+
- `CommandUtils.generate_password_hash` → faked to avoid shelling out to
90+
the real `mkpasswd` binary (from the `whois` package). The dialogs only
91+
care that *some* string ends up in `install_config['shadow_password']`.
92+
93+
`tests/fixtures/packages_options.json` is a package-options file (the
94+
format normally passed as `--options-file`/`ui_config['options_file']`)
95+
with two visible options, both including `"linux"` and `"linux-rt"` in
96+
their package lists. Two visible options keeps the package-selection screen
97+
active (a single-option file gets silently auto-skipped by
98+
`PackageSelector`), and having two non-conflicting kernel flavors present
99+
keeps the linux-kernel-selection screen active too (`LinuxSelector`
100+
auto-skips itself when fewer than two flavors are available). This makes
101+
both screens deterministically part of the flow regardless of what
102+
environment the test runs in.
103+
104+
## The tests: `tests/test_iso_dialogs.py`
105+
106+
`_run_dialogs()` walks the full screen sequence — license, disk selection,
107+
packages, network, kernel flavor, STIG, hostname, root password (x2),
108+
final confirmation — accepting the default choice at each screen unless a
109+
test overrides one step (currently only the STIG screen, via the
110+
`on_stig_screen` callback). It returns the final `install_config` as a
111+
parsed dict for assertions.
112+
113+
Two tests today:
114+
115+
- `test_happy_path_auto_partition_dhcp` — every default accepted.
116+
- `test_stig_hardening_enabled` — same flow, but presses Down+Enter on the
117+
STIG screen and asserts `ansible`/`additional_packages` show up.
118+
119+
To add another variant (e.g. static IP networking, custom partitioning),
120+
add a new test that calls `_run_dialogs()` with an override callback for
121+
the relevant screen, following the `on_stig_screen` pattern — or extend
122+
`_run_dialogs()` with another optional callback parameter if the new
123+
variant needs to diverge earlier/later in the sequence.
124+
125+
Since this only ever calls `IsoConfig.configure()` (never
126+
`Installer.execute()`), these tests touch no real disks/packages/network
127+
and run in ~1s each — a normal, fast part of CI
128+
(`.github/workflows/photon-os-installer.yml`), unlike a real install.
129+
130+
## Dependency
131+
132+
- `pexpect` (installed in CI via `pip install pytest pexpect`; not added to
133+
`requirements.txt` since it's test-only, not a runtime dependency of the
134+
installer itself).
135+
136+
## Explicitly not covered here
137+
138+
- Booting the actual ISO in QEMU/a VM and driving the installer over a
139+
serial console for a true end-to-end interactive install. This is handled
140+
separately; there's already coverage for installing from an ISO image via
141+
other tests/tooling.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"eula_file_path": null,
3+
"license_display_title": null
4+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# /*
2+
# * Copyright © 2026 VMware, Inc.
3+
# * SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-only
4+
# */
5+
"""Standalone entrypoint that runs only the interactive dialog flow of the
6+
photon-os-installer (IsoConfig.configure()), with the real disk lookup
7+
(normally backed by `lsblk`) replaced by a small set of fake devices, and
8+
prints the resulting install_config as JSON once the dialogs complete.
9+
10+
This never calls Installer.execute() - no disks, packages, or network are
11+
touched by running this. It exists so tests can drive the dialogs with
12+
tui_driver.py the same way iso_config.py's own "for debugging" main() lets
13+
a human do it by hand: `python3 photon_installer/iso_config.py -f ui_config.json`.
14+
15+
Usage: iso_config_stub_entrypoint.py <path to ui_config.json>
16+
17+
Fake disks can be overridden via the POI_TEST_FAKE_DISKS environment
18+
variable: a JSON list of {"model": ..., "path": ..., "size_bytes": ...}
19+
objects. Defaults to a single 10 GiB disk at /dev/fakea.
20+
21+
Besides the disk lookup, two more real-system dependencies of the dialogs
22+
are faked out so this has no system-package requirements beyond Python
23+
itself: password strength checking (normally python3-cracklib) and
24+
password hashing (normally shells out to `mkpasswd` from the `whois`
25+
package). Neither is part of what these tests are exercising - the dialog
26+
flow is - so real cracklib/mkpasswd behavior would only add environment
27+
dependencies without adding coverage.
28+
"""
29+
30+
import curses
31+
import json
32+
import os
33+
import sys
34+
import types
35+
36+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
37+
POI_INSTALLER_DIR = os.path.join(REPO_ROOT, "photon_installer")
38+
sys.path.insert(0, POI_INSTALLER_DIR)
39+
40+
# Fake out cracklib before anything imports it (iso_config.py does `import
41+
# cracklib` at module load time). Any password is considered strong enough.
42+
_fake_cracklib = types.ModuleType("cracklib")
43+
_fake_cracklib.VeryFascistCheck = lambda password: password
44+
sys.modules.setdefault("cracklib", _fake_cracklib)
45+
46+
import device # noqa: E402
47+
from commandutils import CommandUtils # noqa: E402
48+
49+
# Avoid shelling out to the real `mkpasswd` binary; the dialogs only care
50+
# that *some* string ends up in install_config['shadow_password'].
51+
CommandUtils.generate_password_hash = staticmethod(lambda password: f"$fake-hash${password}")
52+
53+
# Printed right before the final install_config JSON, so the driver can
54+
# split the curses screen output from the actual result.
55+
RESULT_MARKER = "===INSTALL_CONFIG_JSON==="
56+
57+
DEFAULT_FAKE_DISKS = [
58+
{"model": "Fake Disk", "path": "/dev/fakea", "size_bytes": 10 * 1024 ** 3},
59+
]
60+
61+
62+
class _FakeDevice:
63+
def __init__(self, model, path, size):
64+
self.model = model
65+
self.path = path
66+
self.size = size
67+
68+
69+
def _load_fake_disks():
70+
raw = os.environ.get("POI_TEST_FAKE_DISKS")
71+
if raw:
72+
return json.loads(raw)
73+
return DEFAULT_FAKE_DISKS
74+
75+
76+
def _human_size(size_bytes):
77+
return f"{size_bytes // (1024 ** 3)}G"
78+
79+
80+
def _make_fake_refresh_devices(fake_disks):
81+
def _fake_refresh_devices(bytes=False):
82+
return [
83+
_FakeDevice(
84+
disk["model"],
85+
disk["path"],
86+
str(disk["size_bytes"]) if bytes else _human_size(disk["size_bytes"]),
87+
)
88+
for disk in fake_disks
89+
]
90+
return _fake_refresh_devices
91+
92+
93+
device.Device.refresh_devices = staticmethod(_make_fake_refresh_devices(_load_fake_disks()))
94+
95+
from iso_config import IsoConfig # noqa: E402
96+
97+
98+
def main():
99+
with open(sys.argv[1]) as f:
100+
ui_config = json.load(f)
101+
102+
install_config = curses.wrapper(IsoConfig().configure, ui_config)
103+
104+
print(RESULT_MARKER)
105+
print(json.dumps(install_config))
106+
107+
108+
if __name__ == "__main__":
109+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"minimal": {
3+
"title": "1. Minimal",
4+
"packages": ["minimal", "linux", "linux-rt"],
5+
"visible": true
6+
},
7+
"full": {
8+
"title": "2. Full",
9+
"packages": ["minimal", "linux", "linux-rt", "full-extra"],
10+
"visible": true
11+
}
12+
}

0 commit comments

Comments
 (0)