From 0ad8d48e89276cf639dcf233624f5b84981e2c70 Mon Sep 17 00:00:00 2001 From: Stuart Fraser <60394096+stuart902@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:07:03 -0300 Subject: [PATCH] Offer explicitly confirmed ext4 reset during installation --- .github/workflows/build.yml | 49 +++++++- README.md | 16 +++ ci/hardware-test.json | 4 + docs/device-protocol.md | 45 ++++++- docs/releases.md | 2 +- src/orchard_cli/protocol.py | 6 +- src/orchard_cli/service.py | 86 +++++++++++--- tests/test_host_workflow.py | 35 +++++- tests/test_package_boundaries.py | 2 +- tests/test_runtime.py | 2 +- tests/test_storage.py | 195 ++++++++++++++++++++++++++++++- 11 files changed, 409 insertions(+), 33 deletions(-) create mode 100644 ci/hardware-test.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9e7dec..31f0024 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,11 @@ on: hardware_tag: description: Hardware version to pair with this CLI required: true - default: v0.1.10 + default: v0.1.11 + hardware_repository: + description: Repository providing the paired hardware release or workflow artifact + required: true + default: OrchardLinux/hardware hardware_run_id: description: Optional hardware workflow run providing the tested wheel before its first release required: false @@ -24,7 +28,8 @@ jobs: package: runs-on: ubuntu-24.04 env: - HARDWARE_TAG: ${{ inputs.hardware_tag || 'v0.1.10' }} + HARDWARE_REPOSITORY: ${{ inputs.hardware_repository || 'OrchardLinux/hardware' }} + HARDWARE_TAG: ${{ inputs.hardware_tag || 'v0.1.11' }} steps: - uses: actions/checkout@v4 with: @@ -32,23 +37,53 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Read the reviewed PR hardware dependency + if: github.event_name == 'pull_request' + id: hardware-test + run: | + python - <<'PYTHON' + import json, os, re + from pathlib import Path + pin = json.loads(Path('ci/hardware-test.json').read_text()) + assert re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', pin['repository']) + assert re.fullmatch(r'[0-9a-f]{40}', pin['ref']) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write('repository=' + pin['repository'] + '\nref=' + pin['ref'] + '\n') + PYTHON + - name: Check out the exact hardware candidate for PR tests + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + repository: ${{ steps.hardware-test.outputs.repository }} + ref: ${{ steps.hardware-test.outputs.ref }} + path: out/hardware-source + persist-credentials: false + - name: Build the pinned hardware candidate wheel + if: github.event_name == 'pull_request' + env: + HARDWARE_REF: ${{ steps.hardware-test.outputs.ref }} + run: | + test "$(git -C out/hardware-source rev-parse HEAD)" = "$HARDWARE_REF" + python -m pip install build + python -m build --wheel --outdir "$GITHUB_WORKSPACE/out/hardware" out/hardware-source - name: Download pinned released hardware dependency - if: inputs.hardware_run_id == '' + if: github.event_name != 'pull_request' && inputs.hardware_run_id == '' env: GH_TOKEN: ${{ github.token }} run: | + [[ "$HARDWARE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] [[ "$HARDWARE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] mkdir -p out/hardware - gh release download "$HARDWARE_TAG" --repo OrchardLinux/hardware \ + gh release download "$HARDWARE_TAG" --repo "$HARDWARE_REPOSITORY" \ --pattern '*.whl' --pattern SHA256SUMS --dir out/hardware (cd out/hardware && sha256sum --check --ignore-missing SHA256SUMS) - name: Reuse the already-built hardware wheel during first-release bring-up - if: inputs.hardware_run_id != '' + if: github.event_name != 'pull_request' && inputs.hardware_run_id != '' uses: actions/download-artifact@v4 with: name: hardware-packages path: out/hardware - repository: OrchardLinux/hardware + repository: ${{ env.HARDWARE_REPOSITORY }} run-id: ${{ inputs.hardware_run_id }} github-token: ${{ github.token }} - name: Reuse the unchanged CLI distribution @@ -72,6 +107,7 @@ jobs: python -c 'import os, importlib.metadata as m; assert os.environ["HARDWARE_TAG"] == "v" + m.version("orchard-hardware")' mkdir -p out/pair cp dist/*.whl dist/*.tar.gz out/hardware/*.whl out/pair/ + if [[ '${{ github.event_name }}' == 'pull_request' ]]; then cp ci/hardware-test.json out/pair/; fi - name: Check tag matches package version if: github.ref_type == 'tag' env: @@ -102,6 +138,7 @@ jobs: path: out/pair - run: python -m pip install out/pair/*.whl && python -m pip check - run: python -m unittest discover -s tests -v + - run: python -m pip install mypy==1.18.2 && python -m mypy assets: if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' needs: test diff --git a/README.md b/README.md index b982f5f..f21dea5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,22 @@ orchard install omarchy-a The registered name stores a URL. Each resolution fetches the current index; verified images are cached by SHA-256. Publishing a new distro index updates future installations, not an already installed operating system. +## Reset an existing filesystem + +During installation, compatible recovery offers **Keep filesystem** (default), +**Reset filesystem**, or **Cancel** after you select an existing ext4 Linux-data +partition. Reset removes everything on that partition, including other installation +directories, personal files and cached images. It creates no backup, keeps the +partition layout unchanged, and is not secure erasure. Confirm by typing the exact +`reset ` phrase shown by the installer. Installation has its own +confirmation after reset succeeds. + +Older recovery images keep the existing installation workflow without a reset +option. Reset requires rebuilding installer recovery with the supporting hardware +runtime; updating the host CLI alone is insufficient. If reset cannot be verified, +installation stops: do not retry reset automatically. Inspect storage and the saved +boot-scoped `last-format.json` report first. + ## Launch an installed system Connect the device in DFU mode, then run: diff --git a/ci/hardware-test.json b/ci/hardware-test.json new file mode 100644 index 0000000..2e40bc0 --- /dev/null +++ b/ci/hardware-test.json @@ -0,0 +1,4 @@ +{ + "repository": "stuart902/hardware", + "ref": "d6116dc0ca996782275ebc26502f206a5d1cccdb" +} diff --git a/docs/device-protocol.md b/docs/device-protocol.md index 4f62117..cf1eef1 100644 --- a/docs/device-protocol.md +++ b/docs/device-protocol.md @@ -29,10 +29,11 @@ All records include `protocol: 1`. Lost connections during mutations mean unknow | Operation | Request | Result / effect | |---|---|---| | `bootstrap` | public key, profile, ECID, boot ID (serial) | Authorize in RAM, ensure the bundled SSH daemon, return host key and endpoint. No hardware preparation or storage access. | -| `status` | none | Profile, live ECID/boot ID, release, phase/failure state, desktop readiness, device-owned `installation_attempted` flag, formatting receipt and capabilities. | +| `status` | none | Profile, live ECID/boot ID, release, phase/failure state, desktop readiness, device-owned `installation_attempted` flag, formatting receipt and capabilities (including `reset-ext4-v1` in supporting installer recovery). | | `prepare` | release, bytes, sha256 + mixin stdin | Verify fresh-ADT binding/data allowlist; apply in RAM, prepare hardware once, and clean owned leftover staging before target selection. | | `targets` | none | Boot ID, device-issued selection token, capacity estimate and GPT Linux-data partition/signature descriptions; free space for verified ext4. | | `format-target` | token, target ID, boot ID, operation ID, explicit confirmation | Once per recovery boot after preparation: revalidate a Linux-data target without recognized signatures, create ext4 without force/discard, verify UUID and restore fencing. Return a matching receipt; invalidate the old selection. | +| `reset-target` | token, target ID, boot ID, operation ID, expected filesystem UUID, exact `reset ` confirmation | Capability `reset-ext4-v1` required. Reset a verified existing ext4 Linux-data partition; preserve GPT and partition geometry, assign a new UUID, verify emptiness and restore fencing. Shares the once-per-boot permission with `format-target`; return a matching receipt and invalidate the selection. | | `destination` | token, target ID, relative directory | Read-only inspection; return existence, destination identity, free/reclaimable bytes, estimated needed bytes and inode sufficiency. No ownership is inferred from installation markers. | | `install` | selected target/directory, destination_identity, explicit replace boolean, bytes, sha256 + root stdin | Revalidate selection; stream into fresh staging, verify, apply mixin and promote. | | `handoff` | none | Recheck the completed installation, preserve diagnostics and ask recovery PID 1 to switch root. | @@ -62,3 +63,45 @@ Host code must not infer device paths or encode module loads, filesystem offsets - Pre-exec PID 1 failures attempt to restore moved pseudo-filesystem aliases and restart key-only recovery diagnostics, never retrying handoff/storage. Reported failures stop host readiness polling immediately. Kernel failure or failure inside the final root-switch executable can still require DFU recovery. - The desktop shell reports readiness only after constructing its root and successfully capturing a compositor frame to `/dev/null`. The device checks the boot ID, live shell process and owned Wayland socket. - No screenshot is retained. Readiness does not attest touch gestures, brightness interaction or Wi-Fi association. + +## Existing-filesystem reset + +Only supporting installer recovery advertises `reset-ext4-v1`; launcher mode refuses +`reset-target`. The host package version alone does not establish support. Existing +clients and recovery retain the unformatted-only `format-target` operation. + +After selecting ext4, a capable host offers Keep (default), Reset, or Cancel before +inspecting the installation directory. Reset identifies the partition label, ID, +capacity, type and UUID, warns that **all contents of the partition** will be lost +(including other roots, personal files and image caches), and requires the exact +phrase `reset `. No backup is made. Partition boundaries and GPT stay +unchanged; formatting is not secure erasure. Cancelling before dispatch performs +neither formatting nor installation. Existing preparation still performs device-owned +journal replay and cleanup of recognized incomplete staging before this menu; +cancellation is not a guarantee of zero writes during preparation. + +The protocol-1 request is `{selection_token, target, boot_id, operation_id, +expected_filesystem_uuid, confirmation}`. Operation IDs are 32 lowercase hex digits. +Reset accepts only unambiguous ext4 with a nonzero UUID on an eligible GPT Linux-data +partition. The device revalidates the complete selection, GPT, geometry, device +identity, unmounted state, swap/holder exclusions and read-only fencing before writes. +It records uncertainty and consumes the shared formatting permission first. Reset +uses a single formatter force option, never doubled force, and disables discard. + +The reset receipt contains `{operation: "reset-target", boot_id, operation_id, +target, previous_filesystem_uuid, filesystem_uuid, filesystem: "ext4", status: +"complete"}`. Completion requires a new UUID, unchanged partition identity and GPT, +restored fencing, and a read-only emptiness check (only an empty `lost+found` is +allowed), followed by unmounting. The host validates the receipt against its saved +request, then rediscovers the same partition with the new UUID, same capacity and +boot, and a new selection token. Only then may destination selection continue. + +Host and device persist `status: "unknown"` before dispatch/writes respectively. +Interrupted, failed or unverified reset stops installation and is never automatically +retried. Inspect storage and the boot-scoped `last-format.json` report before making +any new destructive choice. Successful reset followed by failed rediscovery retains +the completed receipt but also stops installation. A new recovery boot does not +constitute permission to repeat a reset. Later destination failures or cancellation +retain the completed reset receipt and explicitly report that previous contents +were destroyed. Abort also reports completed resets, and preserves an unknown +reset outcome without entering storage cleanup. diff --git a/docs/releases.md b/docs/releases.md index e516590..f47050c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,7 +4,7 @@ The bootstrap installs `releases/latest/download/requirements.txt`. This file se ## GitHub Actions -`.github/workflows/build.yml` tests on Python 3.9 and 3.12 for pull requests and `main`. It downloads a published hardware wheel and verifies its release checksums; no sibling source checkout is used. The default hardware tag is explicit in the workflow and must be updated with the package dependency when selecting a new pair. Manual dispatch also lets you choose a hardware tag. +`.github/workflows/build.yml` tests on Python 3.9 and 3.12 for pull requests and `main`. PR tests build the exact companion hardware source pinned in `ci/hardware-test.json`; update that pin explicitly when the companion changes. Other builds download a published hardware wheel and verify its release checksums, or use an explicitly selected hardware workflow artifact. The default hardware tag must satisfy the package dependency. Manual dispatch accepts a hardware repository, tag, and optional workflow run ID for candidate pairing. A version tag matching `pyproject.toml` creates a **draft** release containing the wheel, sdist, hashed `requirements.txt` and `SHA256SUMS`. Manual builds only retain workflow artifacts for seven days. The hardware release must be published first; cross-repository drafts are not public dependency URLs. Nothing publishes automatically or operates a device. diff --git a/src/orchard_cli/protocol.py b/src/orchard_cli/protocol.py index abeae10..2af3df9 100644 --- a/src/orchard_cli/protocol.py +++ b/src/orchard_cli/protocol.py @@ -21,7 +21,7 @@ def __init__(self, connection, progress): self.ssh, self.progress = connection.argv, progress def call(self, operation, request=None, *, source=None, size=0, timeout=120): - if operation not in ('status', 'prepare', 'targets', 'destination', 'install', 'handoff', 'diagnostics', 'abort', 'bootstrap', 'revoke-ssh', 'format-target', 'installations', 'launch'): + if operation not in ('status', 'prepare', 'targets', 'destination', 'install', 'handoff', 'diagnostics', 'abort', 'bootstrap', 'revoke-ssh', 'format-target', 'reset-target', 'installations', 'launch'): raise ValueError('Unsupported phase operation') try: self.connection.check() @@ -95,7 +95,9 @@ def collect_errors(): if (timed_out.is_set() or code or errors or len(records) != 1 or (records and records[0].get('operation') != operation)): detail = '; '.join(errors) or b''.join(stderr).decode(errors='replace')[-2000:] - if operation in ('prepare', 'install', 'handoff', 'abort', 'format-target') and 'orchard install' not in detail: + if operation == 'reset-target': + detail += '\nReset outcome is unknown; do not retry reset. Inspect storage and the saved formatting report.' + elif operation in ('prepare', 'install', 'handoff', 'abort', 'format-target') and 'orchard install' not in detail: detail += '\nA fresh recovery boot is required; then run: orchard install' raise DeviceError(f'{operation} failed. {detail}', reported=bool(errors)) return records[0]['result'] diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index b10547f..430f766 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -15,40 +15,67 @@ from .devices import choose -def ask(clock, prompt, input_fn=input): +def ask(clock, prompt, input_fn=input, *, strip=True): with clock.exclude('input'): - return input_fn(prompt).strip() + answer = input_fn(prompt) + return answer.strip() if strip else answer -def initialize_partition(client, clock, observation, row, report_path, *, input_fn=input, output=print): +def initialize_partition(client, clock, observation, row, report_path, *, input_fn=input, output=print, reset=False): if report_path is None: raise ValueError('Formatting requires a persistent host report path') if row['bytes'] < observation['needed_bytes']: raise ValueError('Partition is too small for this image; nothing was formatted') - output('This Linux-data partition has no recognized filesystem signatures; that does not prove it is empty.') - output('Formatting will permanently destroy any contents of this partition. No backup will be created.') - phrase = 'format ' + row['id'] - answer = ask(clock, f'Type "{phrase}" to confirm: ', input_fn) + operation = 'reset-target' if reset else 'format-target' + if reset: + if row['filesystem'] != 'ext4' or not uuid.UUID(row['filesystem_uuid']).int: + raise ValueError('Reset requires a verified existing ext4 filesystem') + output(f"Reset target: {row['label']} · ID {row['id']} · {row['bytes'] / 1024**3:.1f} GiB · ext4") + output('Current filesystem UUID: ' + row['filesystem_uuid']) + output('Reset permanently destroys EVERYTHING on this partition: all installations, personal files, and cached images.') + output('No backup will be created. Partition boundaries and GPT layout stay unchanged. This is not secure erasure.') + else: + output('This Linux-data partition has no recognized filesystem signatures; that does not prove it is empty.') + output('Formatting will permanently destroy any contents of this partition. No backup will be created.') + phrase = ('reset ' if reset else 'format ') + row['id'] + cancelled = ('Partition formatting was not confirmed; no formatting or reset was performed. ' + 'Installation did not start.') + try: + answer = ask(clock, f'Type "{phrase}" to confirm: ', input_fn, strip=not reset) + except (EOFError, KeyboardInterrupt) as error: + raise ValueError(cancelled) from error if answer != phrase: - raise ValueError('Partition formatting was not confirmed; no storage changes were made') + raise ValueError(cancelled) request = {'selection_token': observation['selection_token'], 'target': row['id'], 'boot_id': observation['boot_id'], 'operation_id': uuid.uuid4().hex, 'confirmation': answer} + if reset: + request['expected_filesystem_uuid'] = row['filesystem_uuid'] report = {**request, 'status': 'unknown'} # Persist uncertainty before dispatch. A crash or lost SSH result must never # turn into an implicit second formatting request. save_report(report_path, report) try: - receipt = client.call('format-target', request, timeout=240) + receipt = client.call(operation, request, timeout=240) if (receipt.get('status') != 'complete' or receipt.get('filesystem') != 'ext4' or any(receipt.get(key) != request[key] for key in ('boot_id', 'operation_id', 'target')) or not uuid.UUID(receipt['filesystem_uuid']).int): raise ValueError('Formatting receipt does not match the request') + if reset and (receipt.get('operation') != operation or + receipt.get('previous_filesystem_uuid') != row['filesystem_uuid'] or + receipt['filesystem_uuid'] == row['filesystem_uuid']): + raise ValueError('Reset receipt does not match the selected filesystem') except BaseException as error: save_report(report_path, {**report, 'error': str(error)}) raise RuntimeError('Formatting outcome could not be verified. Do not retry formatting; ' 'inspect the partition and saved report: ' + str(report_path)) from error save_report(report_path, receipt) - refreshed = client.call('targets') + if reset: + output('Filesystem reset completed; all previous contents on partition ' + row['id'] + ' were destroyed.') + try: + refreshed = client.call('targets') + except (DeviceError, ValueError, OSError, EOFError, KeyboardInterrupt) as error: + raise RuntimeError('Formatting completed, but target rediscovery failed; installation stopped. ' + 'Do not repeat formatting. Inspect the saved report: ' + str(report_path)) from error matches = [p for p in refreshed['partitions'] if p['id'] == row['id']] if (refreshed['boot_id'] != request['boot_id'] or refreshed['selection_token'] == request['selection_token'] or len(matches) != 1 or @@ -58,7 +85,8 @@ def initialize_partition(client, clock, observation, row, report_path, *, input_ return refreshed, matches[0] -def select_destination(client, clock, *, input_fn=input, output=print, directory='linux', format_report=None): +def select_destination(client, clock, *, input_fn=input, output=print, directory='linux', format_report=None, + capabilities=()): observation = client.call('targets') rows = observation['partitions'] if not rows: @@ -76,11 +104,40 @@ def select_destination(client, clock, *, input_fn=input, output=print, directory if not answer.isdecimal() or not 1 <= int(answer) <= len(rows): raise ValueError('No valid partition was selected') row = rows[int(answer) - 1] + reset_completed = False + if row['filesystem'] == 'ext4' and 'reset-ext4-v1' in capabilities: + output(' 1. Keep filesystem (default)') + output(' 2. Reset filesystem — destroy everything on this partition') + output(' 3. Cancel') + try: + action = ask(clock, 'Choose an action [1]: ', input_fn) + except (EOFError, KeyboardInterrupt) as error: + raise ValueError('Installation cancelled; filesystem was not reset') from error + if action == '2': + observation, row = initialize_partition(client, clock, observation, row, format_report, + input_fn=input_fn, output=output, reset=True) + reset_completed = True + elif action not in ('', '1'): + raise ValueError('Installation cancelled; filesystem was not reset') if row['filesystem'] == 'unformatted': observation, row = initialize_partition(client, clock, observation, row, format_report, input_fn=input_fn, output=output) if row['filesystem'] != 'ext4': raise ValueError('Selected partition has an unsupported signature; nothing was formatted') + try: + return select_directory(client, clock, observation, row, directory, input_fn=input_fn, output=output, + reset_completed=reset_completed) + except (DeviceError, ValueError, OSError, EOFError, KeyboardInterrupt) as error: + if not reset_completed: + raise + raise RuntimeError('Filesystem reset completed; all previous contents on partition ' + row['id'] + + ' were destroyed. Installation did not complete. ' + str(error) + + '\nDo not repeat reset. Inspect the saved report: ' + str(format_report)) from error + + +def select_directory(client, clock, observation, row, directory, *, input_fn=input, output=print, + reset_completed=False): + unchanged = '' if reset_completed else '; nothing was deleted' while True: request = {'selection_token': observation['selection_token'], 'target': row['id'], 'directory': directory} existing = client.call('destination', request) @@ -95,7 +152,7 @@ def select_destination(client, clock, *, input_fn=input, output=print, directory directory = ask(clock, 'New root directory: ', input_fn) continue if action != '1': - raise ValueError('Installation cancelled; nothing was deleted') + raise ValueError('Installation cancelled' + unchanged) available = existing['free_bytes'] + (existing['reclaimable_bytes'] if replace else 0) needed = existing['needed_bytes'] output('Installation plan:') @@ -104,7 +161,7 @@ def select_destination(client, clock, *, input_fn=input, output=print, directory output(f' {label}: {available / 1024**3:.1f} GiB') output(f' Estimated needed, including margin: {needed / 1024**3:.1f} GiB') if available < needed or not existing['inodes_sufficient']: - raise ValueError('Insufficient space or free inodes; nothing was deleted. ' + raise ValueError('Insufficient space or free inodes' + unchanged + '. ' 'Free space or choose another partition or directory.') if replace: phrase = 'replace ' + directory @@ -279,7 +336,8 @@ def install(manifest=None, *, input_fn=input, progress=None): source=source, size=mixin_record['bytes'], timeout=180) root_path = record.get('default_root', '/linux') destination = select_destination(client, clock, input_fn=input_fn, directory=root_path.lstrip('/'), - format_report=private / 'last-format.json') + format_report=private / 'last-format.json', + capabilities=status.get('capabilities', ())) progress.phase('Installing ' + record.get('name', 'Linux') + '…') with assets['root'].open('rb') as source: result = client.call('install', {**destination, **record['assets']['root']}, diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index 05a74e0..419e1a9 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -62,7 +62,7 @@ def test_experimental_device_shows_caveats_and_still_defaults_to_yes(self): assets.assert_not_called() def test_guided_run_reaches_desktop_with_one_mixin_and_one_root_stream(self): - for filesystem in ('ext4', 'unformatted'): + for filesystem in ('ext4', 'unformatted', 'reset'): with self.subTest(filesystem=filesystem): self.guided_run(filesystem) @@ -97,7 +97,8 @@ class Device: prepared = installed = handed_off = formatted = False def call(self, operation, request=None, **kwargs): if operation == 'status': - return {'release': '1.0', 'boot_id': BOOT, 'ecid':1, 'profile':'device', 'ready': self.handed_off} + return {'release': '1.0', 'boot_id': BOOT, 'ecid':1, 'profile':'device', 'ready': self.handed_off, + 'capabilities': ['reset-ext4-v1'] if filesystem == 'reset' else []} if operation == 'prepare': assert not self.prepared and kwargs['source'].read() == b'private-data' self.prepared = True @@ -106,8 +107,19 @@ def call(self, operation, request=None, **kwargs): return {'selection_token': 'new-token' if self.formatted else 'token', 'boot_id': BOOT, 'needed_bytes': 512 * 1024**2, 'partitions': [ {'id': 'target', 'bytes': 16 * 1024**3, 'label': 'Linux', - 'filesystem': 'ext4' if self.formatted else filesystem, - 'filesystem_uuid': '11111111-1111-4111-8111-111111111111'}]} + 'filesystem': 'ext4' if self.formatted or filesystem == 'reset' else filesystem, + 'filesystem_uuid': ('11111111-1111-4111-8111-111111111111' if self.formatted else + '22222222-2222-4222-8222-222222222222')}]} + if operation == 'reset-target': + assert filesystem == 'reset' and self.prepared and not self.formatted + assert request['confirmation'] == 'reset target' + assert request['expected_filesystem_uuid'] == '22222222-2222-4222-8222-222222222222' + assert json.loads((private / 'last-format.json').read_text())['status'] == 'unknown' + self.formatted = True + return {**{k: request[k] for k in ('boot_id', 'operation_id', 'target')}, + 'operation': operation, 'previous_filesystem_uuid': request['expected_filesystem_uuid'], + 'status': 'complete', 'filesystem': 'ext4', + 'filesystem_uuid': '11111111-1111-4111-8111-111111111111'} if operation == 'format-target': assert self.prepared and not self.formatted and filesystem == 'unformatted' saved = json.loads((private / 'last-format.json').read_text()) @@ -131,7 +143,8 @@ def call(self, operation, request=None, **kwargs): return {'requested': True} raise AssertionError(operation) device = Device() - answers = iter(('y', '', *(['format target'] if filesystem == 'unformatted' else []), + answers = iter(('y', '', *(['format target'] if filesystem == 'unformatted' else + ['2', 'reset target'] if filesystem == 'reset' else []), 'install linux', 'y')) record = {'release': '1.0', 'assets': {'root': {'bytes': 11, 'sha256': 'root-digest'}}} def acquire_assets(*args, **kwargs): @@ -153,7 +166,7 @@ def check_committed_before_readiness(client, progress): result = install(input_fn=lambda _: next(answers), progress=Progress(io.StringIO())) self.assertTrue(result['ready']) self.assertTrue(device.handed_off) - self.assertEqual(device.formatted, filesystem == 'unformatted') + self.assertEqual(device.formatted, filesystem in ('unformatted', 'reset')) if device.formatted: self.assertEqual(json.loads((private / 'last-format.json').read_text())['status'], 'complete') receipt = private / 'last-install.json' @@ -356,6 +369,16 @@ def test_protocol_stream_reports_device_failure_without_retransmission(self): client.call('install', source=io.BytesIO(b'archive'), size=7, timeout=5) self.assertEqual(marker.read_text(), 'x') + def test_reset_protocol_failure_warns_unknown_without_retransmission(self): + with tempfile.TemporaryDirectory() as directory: + marker = Path(directory) / 'attempts' + program = ('import sys,pathlib; p=pathlib.Path(' + repr(str(marker)) + '); ' + 'p.write_text(p.read_text()+"x" if p.exists() else "x"); sys.exit(1)') + client = Client(Connection([sys.executable, '-c', program], SCOPE, lambda: None), Progress(io.StringIO())) + with self.assertRaisesRegex(DeviceError, 'outcome is unknown; do not retry reset'): + client.call('reset-target', {}, timeout=5) + self.assertEqual(marker.read_text(), 'x') + def test_reported_handoff_failure_stops_without_a_readiness_wait(self): from unittest.mock import Mock client = Mock() diff --git a/tests/test_package_boundaries.py b/tests/test_package_boundaries.py index c7caee5..323d66d 100644 --- a/tests/test_package_boundaries.py +++ b/tests/test_package_boundaries.py @@ -40,7 +40,7 @@ def find_spec(self, name, path=None, target=None): from orchard_hardware.apple import adt from orchard_cli.command import main sys.argv=['orchard','distros'];main() -sys.argv=['orchard','resolve',MANIFEST];main() +sys.argv=['orchard','resolve',MANIFEST,'--profile','j71b'];main() '''.replace('MANIFEST', repr(str(FIXTURE.resolve()))) with tempfile.TemporaryDirectory() as store: result = subprocess.run([sys.executable, '-I', '-c', script], cwd=store, diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 90f59a1..3630efd 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -250,7 +250,7 @@ def guard(event, args): sys.addaudithook(guard) from orchard_cli.command import main -sys.argv = ['orchard', 'resolve', INPUT] +sys.argv = ['orchard', 'resolve', INPUT, '--profile', 'j71b'] main() '''.replace('BLOCKED', repr([str(path) for path in blocked])).replace('INPUT', repr(str(inputs / 'index.toml'))) env = {**os.environ, 'ORCHARD_HOME': str(self.root / 'isolated-store'), 'PYTHONDONTWRITEBYTECODE': '1'} diff --git a/tests/test_storage.py b/tests/test_storage.py index 07070de..b859dc9 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -3,7 +3,7 @@ from pathlib import Path import tempfile import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch from orchard_cli.progress import Clock from orchard_cli.protocol import DeviceError @@ -46,6 +46,11 @@ def test_verified_receipt_then_new_selection(self): self.assertEqual(row['filesystem'], 'ext4') self.assertEqual([call.args[0] for call in self.client.call.call_args_list], ['format-target', 'targets']) + def test_initialization_keeps_existing_confirmation_whitespace_handling(self): + self.client.call.side_effect = self.call + self.initialize(' format partition ') + self.assertEqual(self.client.call.call_args_list[0].args[1]['confirmation'], 'format partition') + def test_default_no_wrong_confirmation_and_insufficient_capacity_do_not_dispatch(self): for answer in ('', 'yes', 'format other'): with self.subTest(answer=answer), self.assertRaisesRegex(ValueError, 'not confirmed'): @@ -92,5 +97,193 @@ def test_unsupported_signature_never_formats(self): self.assertFalse(self.report.exists()) +class Reset(unittest.TestCase): + def setUp(self): + Initialization.setUp(self) + self.row.update(filesystem='ext4', label='Linux', + filesystem_uuid='22222222-2222-4222-8222-222222222222') + self.observation['partitions'] = [self.row] + self.messages = [] + self.reset_calls = 0 + self.failure = None + + tearDown = Initialization.tearDown + + def call(self, operation, request=None, **kwargs): + if operation == 'reset-target': + self.reset_calls += 1 + self.assertEqual(json.loads(self.report.read_text())['status'], 'unknown') + self.assertEqual(request['confirmation'], 'reset partition') + self.assertEqual(request['expected_filesystem_uuid'], self.row['filesystem_uuid']) + if isinstance(self.failure, BaseException): + raise self.failure + self.receipt = {**{key: request[key] for key in ('boot_id', 'operation_id', 'target')}, + 'operation': operation, 'status': 'complete', 'filesystem': 'ext4', + 'previous_filesystem_uuid': self.row['filesystem_uuid'], + 'filesystem_uuid': '11111111-1111-4111-8111-111111111111'} + return {**self.receipt, **(self.failure or {})} + if operation == 'targets': + if not self.receipt: + return self.observation + return {**self.observation, 'selection_token': 'after', 'partitions': [ + {**self.row, 'filesystem_uuid': self.receipt['filesystem_uuid']}]} + if operation == 'destination': + self.assertEqual(request['selection_token'], 'after' if self.receipt else 'before') + return {'exists': False, 'identity': None, 'free_bytes': 8 * 1024**3, + 'reclaimable_bytes': 0, 'needed_bytes': 2 * 1024**3, 'inodes_sufficient': True} + raise AssertionError(operation) + + def select(self, answers, capabilities=('reset-ext4-v1',)): + self.client.call.side_effect = self.call + values = iter(answers) + def answer(_): + value = next(values) + if isinstance(value, BaseException): + raise value + return value + return select_destination(self.client, Clock(), input_fn=answer, output=self.messages.append, + format_report=self.report, capabilities=capabilities) + + def test_reset_rediscovery_then_destination_and_complete_receipt(self): + result = self.select(('', '2', 'reset partition', 'install linux')) + self.assertEqual(result['selection_token'], 'after') + self.assertEqual(self.reset_calls, 1) + self.assertEqual(json.loads(self.report.read_text())['status'], 'complete') + text = '\n'.join(self.messages) + for warning in ('EVERYTHING', 'personal files', 'cached images', 'GPT', 'not secure erasure', + 'ID partition', self.row['filesystem_uuid']): + self.assertIn(warning, text) + self.assertEqual([call.args[0] for call in self.client.call.call_args_list], + ['targets', 'reset-target', 'targets', 'destination']) + + def test_post_reset_capacity_failure_retains_destruction_warning_and_receipt(self): + actual = self.call + for reason in ('space', 'inodes'): + self.receipt, self.reset_calls = None, 0 + self.client.reset_mock() + def response(operation, request=None, **kwargs): + result = actual(operation, request, **kwargs) + if operation == 'destination': + result.update(free_bytes=0 if reason == 'space' else result['free_bytes'], + inodes_sufficient=reason != 'inodes') + return result + with patch.object(self, 'call', side_effect=response): + with self.subTest(reason=reason), self.assertRaises(RuntimeError) as error: + self.select(('', '2', 'reset partition')) + message = str(error.exception) + self.assertIn('reset completed', message) + self.assertIn('previous contents on partition partition were destroyed', message) + self.assertIn('Insufficient space or free inodes', message) + self.assertIn(str(self.report), message) + self.assertNotIn('nothing was deleted', message) + self.assertEqual(json.loads(self.report.read_text())['status'], 'complete') + self.assertEqual(self.reset_calls, 1) + self.assertEqual([c.args[0] for c in self.client.call.call_args_list], + ['targets', 'reset-target', 'targets', 'destination']) + + def test_post_reset_cancel_interrupt_and_inspection_failure_preserve_outcome(self): + actual = self.call + for failure in ('wrong confirmation', EOFError(), KeyboardInterrupt(), DeviceError('inspection failed')): + self.receipt, self.reset_calls = None, 0 + self.client.reset_mock() + def response(operation, request=None, **kwargs): + if operation == 'destination' and isinstance(failure, DeviceError): + raise failure + return actual(operation, request, **kwargs) + with patch.object(self, 'call', side_effect=response): + with self.subTest(failure=failure), self.assertRaises(RuntimeError) as error: + self.select(('', '2', 'reset partition', failure)) + self.assertIn('reset completed', str(error.exception)) + self.assertIn('were destroyed', str(error.exception)) + self.assertIn('Do not repeat reset', str(error.exception)) + self.assertNotIn('nothing was deleted', str(error.exception)) + self.assertEqual(json.loads(self.report.read_text())['status'], 'complete') + self.assertEqual(self.reset_calls, 1) + + def test_keep_is_default_and_old_recovery_does_not_offer_reset(self): + for capabilities, answers in ((('reset-ext4-v1',), ('', '', 'install linux')), + (('reset-ext4-v1',), ('', '1', 'install linux')), + ((), ('', 'install linux'))): + self.messages.clear() + self.select(answers, capabilities) + self.assertEqual(self.reset_calls, 0) + self.assertFalse(self.report.exists()) + self.assertEqual(any('Reset filesystem' in text for text in self.messages), bool(capabilities)) + + def test_cancel_wrong_consent_eof_and_interrupt_do_not_mutate(self): + for tail in (('3',), ('invalid',), (EOFError(),), (KeyboardInterrupt(),), + ('2', ''), ('2', 'yes'), ('2', 'reset other'), + ('2', EOFError()), ('2', KeyboardInterrupt())): + with self.subTest(tail=tail), self.assertRaises(ValueError): + self.select(('', *tail)) + self.assertEqual(self.reset_calls, 0) + self.assertFalse(self.report.exists()) + self.assertNotIn('destination', [c.args[0] for c in self.client.call.call_args_list]) + + def test_typed_confirmation_cancellation_does_not_claim_preparation_made_no_writes(self): + for answer in ('', 'yes', 'reset other', EOFError(), KeyboardInterrupt()): + with self.subTest(answer=answer), self.assertRaises(ValueError) as caught: + self.select(('', '2', answer)) + message = str(caught.exception) + self.assertIn('no formatting or reset was performed', message) + self.assertIn('Installation did not start', message) + self.assertNotIn('no storage changes', message) + self.assertEqual(self.reset_calls, 0) + self.assertFalse(self.report.exists()) + self.assertNotIn('destination', [c.args[0] for c in self.client.call.call_args_list]) + + def test_reset_confirmation_requires_literal_phrase_without_whitespace_normalization(self): + for answer in (' reset partition', 'reset partition ', '\treset partition', + 'reset partition\t', '\nreset partition', 'reset partition\n', + '\u00a0reset partition\u00a0', 'reset partition', 'RESET partition'): + with self.subTest(answer=answer), self.assertRaisesRegex(ValueError, 'not confirmed'): + self.select(('', '2', answer)) + self.assertEqual(self.reset_calls, 0) + self.assertFalse(self.report.exists()) + self.assertNotIn('destination', [c.args[0] for c in self.client.call.call_args_list]) + + def test_uncertain_dispatch_or_receipt_never_retries_or_selects_destination(self): + failures = [DeviceError('lost SSH'), KeyboardInterrupt(), EOFError(), + {'operation': 'format-target'}, {'boot_id': 'other'}, {'operation_id': 'other'}, + {'target': 'other'}, {'previous_filesystem_uuid': 'other'}, + {'filesystem_uuid': self.row['filesystem_uuid']}, {'filesystem_uuid': 'invalid'}, + {'filesystem': 'ext3'}, {'status': 'unknown'}] + for failure in failures: + self.failure, self.receipt, self.reset_calls = failure, None, 0 + self.client.reset_mock() + with self.subTest(failure=failure), self.assertRaisesRegex(RuntimeError, 'Do not retry'): + self.select(('', '2', 'reset partition')) + self.assertEqual(self.reset_calls, 1) + self.assertEqual(json.loads(self.report.read_text())['status'], 'unknown') + self.assertNotIn('destination', [c.args[0] for c in self.client.call.call_args_list]) + + def test_rediscovery_failures_keep_success_receipt_and_stop_installation(self): + actual = self.call + for change in ('lost', 'boot', 'token', 'uuid', 'size', 'missing'): + self.receipt, self.reset_calls = None, 0 + self.client.reset_mock() + def response(operation, request=None, **kwargs): + result = actual(operation, request, **kwargs) + if operation != 'targets' or not self.receipt: + return result + if change == 'lost': + raise DeviceError('lost SSH') + if change == 'boot': + return {**result, 'boot_id': 'other'} + if change == 'token': + return {**result, 'selection_token': 'before'} + row = result['partitions'][0] + return {**result, 'partitions': [] if change == 'missing' else [ + {**row, **({'bytes': 1} if change == 'size' else {'filesystem_uuid': 'other'})}]} + self.client.call.side_effect = response + answers = iter(('', '2', 'reset partition')) + with self.subTest(change=change), self.assertRaises((ValueError, RuntimeError)): + select_destination(self.client, Clock(), input_fn=lambda _: next(answers), output=lambda _: None, + format_report=self.report, capabilities=('reset-ext4-v1',)) + self.assertEqual(json.loads(self.report.read_text())['status'], 'complete') + self.assertEqual(self.reset_calls, 1) + self.assertNotIn('destination', [c.args[0] for c in self.client.call.call_args_list]) + + if __name__ == '__main__': unittest.main()