Files
pic/tests/test_ip_utils.py
T
roof 1c939249e4 feat: replace hardcoded docker-compose IPs with .env-based substitution
docker-compose.yml now uses ${VAR:-default} for every container IP and
the network subnet, so there are no hardcoded addresses in the YAML.

How it works:
- setup_cell.py generates .env at project root from ip_range (gitignored).
- docker-compose reads .env automatically at startup.
- When ip_range changes in Settings, the API writes a new .env via
  ip_utils.write_env_file(); DNS/firewall/vIPs update immediately.
- User runs `make start` to recreate containers with the new IPs.

api/ip_utils.py gains ENV_VAR_NAMES dict and write_env_file(ip_range, path).
The old update_docker_compose_ips() direct-patch approach is removed from app.py.
3 new tests added (TestWriteEnvFile); total 324 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 10:43:33 -04:00

148 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Tests for ip_utils — IP derivation from subnet."""
import sys
import os
import tempfile
import unittest
from pathlib import Path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
import ip_utils
class TestGetServiceIps(unittest.TestCase):
def setUp(self):
self.ips = ip_utils.get_service_ips('172.20.0.0/16')
def test_returns_all_keys(self):
for name in ip_utils.CONTAINER_OFFSETS:
self.assertIn(name, self.ips)
def test_default_subnet_caddy(self):
self.assertEqual(self.ips['caddy'], '172.20.0.2')
def test_default_subnet_dns(self):
self.assertEqual(self.ips['dns'], '172.20.0.3')
def test_default_subnet_api(self):
self.assertEqual(self.ips['api'], '172.20.0.10')
def test_default_subnet_virtual_ips(self):
self.assertEqual(self.ips['vip_calendar'], '172.20.0.21')
self.assertEqual(self.ips['vip_files'], '172.20.0.22')
self.assertEqual(self.ips['vip_mail'], '172.20.0.23')
self.assertEqual(self.ips['vip_webdav'], '172.20.0.24')
def test_different_subnet_shifts_all_ips(self):
ips = ip_utils.get_service_ips('10.0.0.0/24')
self.assertEqual(ips['caddy'], '10.0.0.2')
self.assertEqual(ips['dns'], '10.0.0.3')
self.assertEqual(ips['api'], '10.0.0.10')
self.assertEqual(ips['vip_calendar'], '10.0.0.21')
def test_non_zero_third_octet_subnet(self):
ips = ip_utils.get_service_ips('192.168.5.0/24')
self.assertEqual(ips['caddy'], '192.168.5.2')
self.assertEqual(ips['vip_webdav'], '192.168.5.24')
def test_strict_false_accepts_host_bit_set(self):
# e.g. user types "172.20.0.1/16" — should work same as "172.20.0.0/16"
ips = ip_utils.get_service_ips('172.20.0.1/16')
self.assertEqual(ips['caddy'], '172.20.0.2')
def test_all_ips_are_strings(self):
for name, ip in self.ips.items():
self.assertIsInstance(ip, str, f'{name} is not a string')
def test_all_ips_unique(self):
self.assertEqual(len(set(self.ips.values())), len(self.ips))
class TestGetVirtualIps(unittest.TestCase):
def test_returns_four_services(self):
vips = ip_utils.get_virtual_ips('172.20.0.0/16')
self.assertEqual(set(vips.keys()), {'calendar', 'files', 'mail', 'webdav'})
def test_values_match_get_service_ips(self):
full = ip_utils.get_service_ips('172.20.0.0/16')
vips = ip_utils.get_virtual_ips('172.20.0.0/16')
self.assertEqual(vips['calendar'], full['vip_calendar'])
self.assertEqual(vips['files'], full['vip_files'])
self.assertEqual(vips['mail'], full['vip_mail'])
self.assertEqual(vips['webdav'], full['vip_webdav'])
def test_different_subnet(self):
vips = ip_utils.get_virtual_ips('10.10.0.0/16')
self.assertEqual(vips['calendar'], '10.10.0.21')
self.assertEqual(vips['webdav'], '10.10.0.24')
class TestWriteEnvFile(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.env_path = os.path.join(self.tmp, '.env')
def tearDown(self):
import shutil
shutil.rmtree(self.tmp)
def test_creates_file(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
self.assertTrue(os.path.exists(self.env_path))
def test_returns_true_on_success(self):
result = ip_utils.write_env_file('172.20.0.0/16', self.env_path)
self.assertTrue(result)
def test_returns_false_on_unwritable_path(self):
result = ip_utils.write_env_file('172.20.0.0/16', '/nonexistent/deep/path/.env')
self.assertFalse(result)
def test_contains_cell_network(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
self.assertIn('CELL_NETWORK=172.20.0.0/16', content)
def test_contains_caddy_ip(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
self.assertIn('CADDY_IP=172.20.0.2', content)
def test_contains_all_env_vars(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
content = open(self.env_path).read()
for var in ip_utils.ENV_VAR_NAMES.values():
self.assertIn(var + '=', content, f'{var} missing from .env')
def test_custom_subnet_generates_correct_ips(self):
ip_utils.write_env_file('10.5.0.0/24', self.env_path)
content = open(self.env_path).read()
self.assertIn('CELL_NETWORK=10.5.0.0/24', content)
self.assertIn('CADDY_IP=10.5.0.2', content)
self.assertIn('DNS_IP=10.5.0.3', content)
self.assertIn('API_IP=10.5.0.10', content)
self.assertNotIn('172.20', content)
def test_overwrite_updates_ips(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
ip_utils.write_env_file('10.0.0.0/24', self.env_path)
content = open(self.env_path).read()
self.assertIn('CADDY_IP=10.0.0.2', content)
self.assertNotIn('172.20', content)
def test_each_line_is_key_equals_value(self):
ip_utils.write_env_file('172.20.0.0/16', self.env_path)
for line in open(self.env_path).read().splitlines():
if line.strip():
self.assertIn('=', line, f'Bad line format: {line!r}')
key, _, val = line.partition('=')
self.assertTrue(key.isupper() or '_' in key)
self.assertTrue(val.strip())
if __name__ == '__main__':
unittest.main()