Files
pic/tests/test_install_process.py
T
roof e4c80149f4
Unit Tests / test (push) Successful in 7m34s
fix: start-core missing cell-network creation breaks fresh install
make start-core (called by install.sh step 6) used $(DCF) which includes
docker-compose.services.yml — that file declares cell-network as external:true.
On a fresh machine the network doesn't exist yet, so compose up failed with
"network cell-network declared as external, but could not be found".

Fix: add the same network-create idempotency guard that start and update
already have. Also add 26 regression tests (test_install_process.py) that
verify install.sh structure and that all start-* targets using DCF create
the network before running compose up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:07:00 -04:00

245 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""
Tests for the installation script (install.sh) and Makefile start targets.
These are static-analysis tests — they read the files directly and verify
critical properties without running Docker or making network calls.
Covered:
- install.sh: bash syntax, required steps, idempotency guard, --force flag
- Makefile: every start-* target that uses DCF creates cell-network first
- Makefile: start, update, start-core all include the network-create guard
"""
import os
import re
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
INSTALL_SH = REPO_ROOT / 'install.sh'
MAKEFILE = REPO_ROOT / 'Makefile'
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _makefile_text() -> str:
return MAKEFILE.read_text()
def _install_sh_text() -> str:
return INSTALL_SH.read_text()
def _target_body(makefile: str, target: str) -> str:
"""Return the recipe lines (indented with tab) for a given Makefile target."""
pattern = re.compile(
rf'^{re.escape(target)}:.*?\n((?:\t[^\n]*\n)*)',
re.MULTILINE,
)
m = pattern.search(makefile)
return m.group(1) if m else ''
# ---------------------------------------------------------------------------
# install.sh — syntax and structure
# ---------------------------------------------------------------------------
class TestInstallShSyntax(unittest.TestCase):
"""install.sh must be syntactically valid bash."""
def test_bash_syntax_check(self):
result = subprocess.run(
['bash', '-n', str(INSTALL_SH)],
capture_output=True,
text=True,
)
self.assertEqual(
result.returncode, 0,
f'install.sh has bash syntax errors:\n{result.stderr}',
)
def test_shellcheck_if_available(self):
if not (subprocess.run(['which', 'shellcheck'], capture_output=True).returncode == 0):
self.skipTest('shellcheck not installed')
result = subprocess.run(
['shellcheck', '-S', 'warning', str(INSTALL_SH)],
capture_output=True,
text=True,
)
self.assertEqual(
result.returncode, 0,
f'shellcheck found issues in install.sh:\n{result.stdout}\n{result.stderr}',
)
class TestInstallShStructure(unittest.TestCase):
"""install.sh must contain all required installation steps."""
def setUp(self):
self.text = _install_sh_text()
def test_has_idempotency_guard(self):
"""Script must exit early if already installed (without --force)."""
self.assertIn('.installed', self.text,
'install.sh must check for .installed sentinel file')
self.assertIn('FORCE', self.text,
'install.sh must support a FORCE override')
def test_calls_make_install(self):
self.assertIn('make install', self.text,
'install.sh must call make install')
def test_calls_make_start_core(self):
"""install.sh must start core services after installation."""
self.assertIn('make start-core', self.text,
'install.sh must call make start-core to bring up containers')
def test_waits_for_api_health(self):
"""install.sh must poll the API health endpoint after starting containers."""
self.assertIn('/health', self.text,
'install.sh must wait for API health endpoint')
def test_supports_force_flag(self):
self.assertIn('--force', self.text,
'install.sh must accept --force to bypass idempotency check')
def test_supports_custom_pic_dir(self):
self.assertIn('PIC_DIR', self.text,
'install.sh must respect PIC_DIR environment variable')
def test_clones_from_pic_ngo(self):
self.assertIn('git.pic.ngo', self.text,
'install.sh must clone from git.pic.ngo')
def test_set_euo_pipefail(self):
"""Script must exit on error and on undefined variables."""
self.assertIn('set -euo pipefail', self.text,
'install.sh must use set -euo pipefail for safety')
def test_supports_apt_dnf_apk(self):
"""Script must handle the three supported package managers."""
for pm in ('apt', 'dnf', 'apk'):
self.assertIn(pm, self.text,
f'install.sh must handle {pm} package manager')
def test_checks_docker_available_after_install(self):
self.assertIn('docker', self.text,
'install.sh must verify docker is available')
# ---------------------------------------------------------------------------
# Makefile — network creation before compose up
# ---------------------------------------------------------------------------
NETWORK_CREATE_GUARD = 'docker network create'
class TestMakefileNetworkGuard(unittest.TestCase):
"""Every Makefile target that runs 'docker compose up' with DCF must
create cell-network first.
docker-compose.services.yml declares cell-network as external:true, so
compose up will fail with "network could not be found" on a fresh machine
unless the network is created beforehand.
Regression guard: start-core lacked this guard, causing fresh installs
via install.sh to fail at Step 6.
"""
def setUp(self):
self.mk = _makefile_text()
def _body(self, target: str) -> str:
return _target_body(self.mk, target)
def test_start_creates_network(self):
body = self._body('start')
self.assertIn(NETWORK_CREATE_GUARD, body,
"'make start' must create cell-network before docker compose up")
def test_update_creates_network(self):
body = self._body('update')
self.assertIn(NETWORK_CREATE_GUARD, body,
"'make update' must create cell-network before docker compose up")
def test_start_core_creates_network(self):
"""start-core is called by install.sh — missing guard causes fresh install to fail."""
body = self._body('start-core')
self.assertIn(NETWORK_CREATE_GUARD, body,
"'make start-core' must create cell-network before docker compose up "
"(regression: fresh install via install.sh fails without this)")
def test_network_guard_is_idempotent(self):
"""The guard must use 'inspect' to skip creation when the network exists."""
body = self._body('start-core')
self.assertIn('docker network inspect cell-network', body,
"network guard must check inspect before create (idempotent)")
def test_network_uses_configured_subnet(self):
"""All three start targets must respect the CELL_NETWORK env var for the subnet."""
for target in ('start', 'update', 'start-core'):
body = self._body(target)
self.assertIn('CELL_NETWORK', body,
f"'make {target}' must use CELL_NETWORK env var for subnet")
class TestMakefileTargetPresence(unittest.TestCase):
"""Critical Makefile targets must exist."""
def setUp(self):
self.mk = _makefile_text()
def _has_target(self, name: str) -> bool:
return bool(re.search(rf'^{re.escape(name)}:', self.mk, re.MULTILINE))
def test_start_target_exists(self):
self.assertTrue(self._has_target('start'))
def test_start_core_target_exists(self):
self.assertTrue(self._has_target('start-core'))
def test_update_target_exists(self):
self.assertTrue(self._has_target('update'))
def test_install_target_exists(self):
self.assertTrue(self._has_target('install'))
def test_uninstall_target_exists(self):
self.assertTrue(self._has_target('uninstall'))
def test_test_target_exists(self):
self.assertTrue(self._has_target('test'))
def test_setup_target_exists(self):
self.assertTrue(self._has_target('setup'))
class TestDockerComposeServicesFile(unittest.TestCase):
"""docker-compose.services.yml must declare cell-network as external so
per-service compose stacks can join it without recreating it."""
def setUp(self):
self.path = REPO_ROOT / 'docker-compose.services.yml'
def test_file_exists(self):
self.assertTrue(self.path.exists(),
'docker-compose.services.yml must exist')
def test_cell_network_declared_external(self):
text = self.path.read_text()
self.assertIn('external: true', text,
'docker-compose.services.yml must declare cell-network as external')
def test_cell_network_name_set(self):
text = self.path.read_text()
self.assertIn('cell-network', text)
if __name__ == '__main__':
unittest.main()