Answer "is this a re-run of my install?" from a record, not from the machine
The installer inferred that question from whatever the server happened to look like: a MySQL service exists, the database has tables, the site exists, the venv exists. None of those record who created them. A retry after a failed first install was therefore taken for an upgrade of somebody else's working system, which produced two dead ends on exactly the retry the wizard invites: stage 3 demanded a mandatory backup of a database its own failed attempt had written, and then refused to prune tables it had created minutes earlier, because core migration 7d05 seeds access protocols owned by the computers plugin and any profile without that plugin hit the refusal every single time. An install record at ProgramData\ShopDB-Flask\install-state.json answers it instead. It is written when provisioning STARTS rather than when it finishes, because the run that dies halfway is precisely the run whose retry needs it, and it records what this installer created as it goes, so a crashed run no longer leaves the next one guessing from the machine. During unfinished first provisioning the pre-migration backup becomes advisory and prune may force, since every row present was written by an earlier attempt of the same install. On an established install both stay exactly as they were. The classification is deliberately asymmetric: an install predating this record carries a version stamp and probably real data, so it is treated as established and keeps the mandatory backup. Guessing "first run" there would arm prune --force against live tables. Get-CreatedItems comma-protects its return. A zero-length array returned from a PowerShell function unrolls to $null, and $null.Count is fatal under StrictMode 2.0 - the same fault that made bundle verification fail on every install earlier. The harness caught it before it shipped. Tests: deploy/windows/installer/tests/test-install-state.ps1 exercises new servers, retries, completed installs, unrecorded-but-stamped installs, records naming another directory, corrupt records, and persistence across a crash. tests/test_installer_state.py runs it wherever pwsh exists and asserts the invariants as text everywhere else. Both were confirmed to fail when the prune gate or the comma protection is removed. pytest.ini stops collection walking into deploy/windows/installer/bundle, which is build output holding a complete second copy of the application. Importing every plugin twice made SQLAlchemy refuse a redefined table and the whole suite fail to collect, on a tree with nothing wrong in it, purely because an installer had been built first. It surfaced only when the bundle grew from four plugins to thirteen.
This commit is contained in:
106
tests/test_installer_state.py
Normal file
106
tests/test_installer_state.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Guards on the installer's durable install record.
|
||||
|
||||
The record replaced a set of guesses. "Is this a re-run of MY install?" used to
|
||||
be answered from whatever the machine looked like - a MySQL service exists, the
|
||||
database has tables, the site exists - and none of those say who created them.
|
||||
A retry after a failed first install was therefore taken for an upgrade of a
|
||||
working system, which dead-ended the retry the wizard invites the operator to
|
||||
run.
|
||||
|
||||
Getting it wrong is asymmetric. Answering "still first provisioning" for a
|
||||
server holding real data would let `prune-schema --force` drop live tables. The
|
||||
behavioural checks live in the PowerShell harness next to the installer, run
|
||||
here when pwsh is available; the text assertions below hold everywhere, because
|
||||
CI does not necessarily have pwsh and these are the invariants worth failing a
|
||||
build over.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
INSTALLER = REPO / 'deploy' / 'windows' / 'installer' / 'shopdb-install.ps1'
|
||||
HARNESS = REPO / 'deploy' / 'windows' / 'installer' / 'tests' / 'test-install-state.ps1'
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not INSTALLER.is_file(),
|
||||
reason='installer not in this checkout')
|
||||
|
||||
|
||||
def installer_text():
|
||||
return INSTALLER.read_text(encoding='utf-8', errors='replace')
|
||||
|
||||
|
||||
def test_prune_force_is_gated_on_first_provisioning():
|
||||
"""--force drops tables that contain rows.
|
||||
|
||||
It is correct on a first install, where core migrations seed a few plugin
|
||||
reference tables, and destructive on an upgrade, where those rows are the
|
||||
site's data. The gate must therefore test BOTH "database was empty" and the
|
||||
install record, never the database alone.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "'prune-schema','--yes','--force'" in text, 'the forcing call moved or was renamed'
|
||||
guard = "if ((-not $script:DbWasEmpty) -and (-not $firstRun)) {"
|
||||
assert guard in text, (
|
||||
'the prune gate no longer consults the install record. Forcing on the '
|
||||
'strength of an empty database alone is what made every retry of a '
|
||||
'failed first install fail at stage 3.')
|
||||
|
||||
|
||||
def test_first_provisioning_comes_from_the_record_not_the_database():
|
||||
text = installer_text()
|
||||
assert 'function Test-FirstProvisioning' in text
|
||||
assert '$firstRun = Test-FirstProvisioning' in text, (
|
||||
'stage 3 must ask the record, not infer from table counts')
|
||||
|
||||
|
||||
def test_missing_record_takes_the_safe_side():
|
||||
"""An install predating the record has a version stamp and probably data.
|
||||
|
||||
Treating that as a first run would arm prune --force against it.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "'predates-this-record'" in text, (
|
||||
'the fallback for an unrecorded but stamped install is gone; without it '
|
||||
'an established server can be classified as first provisioning')
|
||||
|
||||
|
||||
def test_created_items_returns_an_array_when_empty():
|
||||
"""A zero-length array returned from a PowerShell function unrolls to $null.
|
||||
|
||||
$null.Count is then a terminating error under Set-StrictMode -Version 2.0 -
|
||||
the same fault that made bundle verification fail on every install. The
|
||||
leading comma is what prevents it.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert 'return ,@()' in text, 'Get-CreatedItems lost its comma protection'
|
||||
assert 'return ,@(Get-StateValue $created $Kind' in text, (
|
||||
'the populated return path lost its comma protection')
|
||||
|
||||
|
||||
def test_record_is_written_before_any_stage_runs():
|
||||
"""Written at the START of provisioning, not the end.
|
||||
|
||||
A run that dies halfway is exactly the run whose retry needs the record.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "if ($Stage -ne 'uninstall') { Initialize-InstallState }" in text
|
||||
start = text.index('Initialize-InstallState }')
|
||||
switch = text.index('switch ($Stage)', start - 400)
|
||||
assert start < switch, 'the record must be initialised before the stage switch'
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('pwsh') is None, reason='pwsh not installed')
|
||||
def test_install_state_behaviour():
|
||||
"""Run the PowerShell harness: retries, upgrades, corrupt and foreign records."""
|
||||
assert HARNESS.is_file(), 'the PowerShell harness is missing'
|
||||
result = subprocess.run(
|
||||
['pwsh', '-NoProfile', '-File', str(HARNESS)],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
assert result.returncode == 0, (
|
||||
'install-record harness failed:\n' + result.stdout + result.stderr)
|
||||
assert 'all checks passed' in result.stdout
|
||||
Reference in New Issue
Block a user