#!/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()