From e4c80149f486410c3d02e72265f0bd72174dccae Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Mon, 8 Jun 2026 01:07:00 -0400 Subject: [PATCH] fix: start-core missing cell-network creation breaks fresh install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 2 + tests/test_install_process.py | 244 ++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 tests/test_install_process.py diff --git a/Makefile b/Makefile index 74c0b81..4912fa3 100644 --- a/Makefile +++ b/Makefile @@ -222,6 +222,8 @@ build-webui: start-core: @echo "Starting core services (caddy, dns, wireguard, api, webui)..." + @docker network inspect cell-network >/dev/null 2>&1 || \ + docker network create --driver bridge --subnet "$${CELL_NETWORK:-172.20.0.0/16}" cell-network PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build @echo "Core services started. Run 'make start' to also bring up optional services." diff --git a/tests/test_install_process.py b/tests/test_install_process.py new file mode 100644 index 0000000..dbc9071 --- /dev/null +++ b/tests/test_install_process.py @@ -0,0 +1,244 @@ +#!/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()