e4c80149f4
Unit Tests / test (push) Successful in 7m34s
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>
245 lines
8.8 KiB
Python
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()
|