wire: AccountManager HTTP dispatch + EgressManager startup + egress API routes
Unit Tests / test (push) Successful in 11m15s
Unit Tests / test (push) Successful in 11m15s
- add_peer() now calls account_manager.provision() for any installed store service whose manifest declares accounts.manager == 'http', enabling per-peer credential provisioning to third-party HTTP services - reapply_on_startup() calls egress_manager.apply_all() so fwmark rules survive container restarts without manual intervention - add GET /api/egress/status and PUT /api/egress/services/<id>/exit routes so the UI can read and override per-service egress policy - tests: HTTP provision wiring (happy path + non-fatal failure), egress apply_all at startup (wired/unwired/failure cases) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+27
@@ -854,6 +854,33 @@ def connectivity_get_peer_exits():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/egress/status', methods=['GET'])
|
||||||
|
def egress_status():
|
||||||
|
"""Return egress status for all installed services that have an egress config."""
|
||||||
|
try:
|
||||||
|
return jsonify(egress_manager.get_status())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"egress_status: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/egress/services/<service_id>/exit', methods=['PUT'])
|
||||||
|
def egress_set_service_exit(service_id: str):
|
||||||
|
"""Persist and immediately apply a per-service egress override."""
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
exit_type = data.get('exit_type')
|
||||||
|
if not isinstance(exit_type, str):
|
||||||
|
return jsonify({'ok': False, 'error': 'exit_type is required'}), 400
|
||||||
|
result = egress_manager.set_service_exit(service_id, exit_type)
|
||||||
|
if result.get('ok'):
|
||||||
|
return jsonify(result)
|
||||||
|
return jsonify(result), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"egress_set_service_exit({service_id}): {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
debug = os.environ.get('FLASK_DEBUG', '0') == '1'
|
debug = os.environ.get('FLASK_DEBUG', '0') == '1'
|
||||||
app.run(host='0.0.0.0', port=3000, debug=debug)
|
app.run(host='0.0.0.0', port=3000, debug=debug)
|
||||||
@@ -89,6 +89,20 @@ def add_peer():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Peer {peer_name}: {step_name} account creation failed (non-fatal): {e}")
|
logger.warning(f"Peer {peer_name}: {step_name} account creation failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Provision accounts for installed HTTP-backed store services (non-fatal)
|
||||||
|
try:
|
||||||
|
from app import account_manager as _am, config_manager as _cfg, service_registry as _sreg
|
||||||
|
for _svc_id in (_cfg.get_installed_services() or {}):
|
||||||
|
_svc_info = _sreg.get(_svc_id)
|
||||||
|
if _svc_info and (_svc_info.get('accounts') or {}).get('manager') == 'http':
|
||||||
|
try:
|
||||||
|
_am.provision(_svc_id, peer_name)
|
||||||
|
except Exception as _he:
|
||||||
|
logger.warning('Peer %s: HTTP account provision for %s failed (non-fatal): %s',
|
||||||
|
peer_name, _svc_id, _he)
|
||||||
|
except Exception as _am_err:
|
||||||
|
logger.warning('Peer %s: HTTP store provisioning failed (non-fatal): %s', peer_name, _am_err)
|
||||||
|
|
||||||
peer_info = {
|
peer_info = {
|
||||||
'peer': peer_name,
|
'peer': peer_name,
|
||||||
'ip': assigned_ip,
|
'ip': assigned_ip,
|
||||||
|
|||||||
@@ -436,3 +436,10 @@ class ServiceStoreManager(BaseServiceManager):
|
|||||||
self.service_composer.reapply_active_services()
|
self.service_composer.reapply_active_services()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('reapply_on_startup: reapply_active_services failed: %s', e)
|
logger.warning('reapply_on_startup: reapply_active_services failed: %s', e)
|
||||||
|
|
||||||
|
# Re-apply egress fwmark rules
|
||||||
|
if self.egress_manager is not None:
|
||||||
|
try:
|
||||||
|
self.egress_manager.apply_all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('reapply_on_startup: egress apply_all failed: %s', e)
|
||||||
|
|||||||
@@ -372,6 +372,104 @@ def test_delete_nonexistent_peer_returns_gracefully(admin_client, mock_peer_regi
|
|||||||
assert r.status_code in (200, 404)
|
assert r.status_code in (200, 404)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/peers — HTTP store service provisioning ────────────────────────
|
||||||
|
|
||||||
|
def test_create_peer_provisions_http_store_services(
|
||||||
|
auth_mgr, mock_email_mgr, mock_calendar_mgr,
|
||||||
|
mock_file_mgr, mock_wg_mgr, mock_peer_registry):
|
||||||
|
"""When an installed store service has accounts.manager='http',
|
||||||
|
account_manager.provision() must be called for the new peer."""
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = 'test-secret'
|
||||||
|
|
||||||
|
mock_am = MagicMock()
|
||||||
|
mock_am.provision.return_value = {'password': 'generated'}
|
||||||
|
mock_am.store_credentials = MagicMock()
|
||||||
|
|
||||||
|
mock_cfg = MagicMock()
|
||||||
|
mock_cfg.get_installed_services.return_value = {'my-store-app': {}}
|
||||||
|
|
||||||
|
mock_sreg = MagicMock()
|
||||||
|
mock_sreg.get.return_value = {'id': 'my-store-app', 'accounts': {'manager': 'http'}, 'backend': 'cell-my-store-app:8080'}
|
||||||
|
|
||||||
|
patches = [
|
||||||
|
patch('app.auth_manager', auth_mgr),
|
||||||
|
patch('app.email_manager', mock_email_mgr),
|
||||||
|
patch('app.calendar_manager', mock_calendar_mgr),
|
||||||
|
patch('app.file_manager', mock_file_mgr),
|
||||||
|
patch('app.wireguard_manager', mock_wg_mgr),
|
||||||
|
patch('app.peer_registry', mock_peer_registry),
|
||||||
|
patch('app.firewall_manager'),
|
||||||
|
patch('app.account_manager', mock_am),
|
||||||
|
patch('app.config_manager', mock_cfg),
|
||||||
|
patch('app.service_registry', mock_sreg),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
import auth_routes
|
||||||
|
patches.append(patch.object(auth_routes, 'auth_manager', auth_mgr, create=True))
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
started = [p.start() for p in patches]
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200
|
||||||
|
resp = _post_peer(client)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
mock_am.provision.assert_called_once_with('my-store-app', 'alice')
|
||||||
|
finally:
|
||||||
|
for p in patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_peer_http_provision_failure_is_nonfatal(
|
||||||
|
auth_mgr, mock_email_mgr, mock_calendar_mgr,
|
||||||
|
mock_file_mgr, mock_wg_mgr, mock_peer_registry):
|
||||||
|
"""HTTP account provisioning failure must not block peer creation."""
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = 'test-secret'
|
||||||
|
|
||||||
|
mock_am = MagicMock()
|
||||||
|
mock_am.provision.side_effect = RuntimeError('service unavailable')
|
||||||
|
mock_am.store_credentials = MagicMock()
|
||||||
|
|
||||||
|
mock_cfg = MagicMock()
|
||||||
|
mock_cfg.get_installed_services.return_value = {'my-store-app': {}}
|
||||||
|
|
||||||
|
mock_sreg = MagicMock()
|
||||||
|
mock_sreg.get.return_value = {'id': 'my-store-app', 'accounts': {'manager': 'http'}, 'backend': 'cell-my-store-app:8080'}
|
||||||
|
|
||||||
|
patches = [
|
||||||
|
patch('app.auth_manager', auth_mgr),
|
||||||
|
patch('app.email_manager', mock_email_mgr),
|
||||||
|
patch('app.calendar_manager', mock_calendar_mgr),
|
||||||
|
patch('app.file_manager', mock_file_mgr),
|
||||||
|
patch('app.wireguard_manager', mock_wg_mgr),
|
||||||
|
patch('app.peer_registry', mock_peer_registry),
|
||||||
|
patch('app.firewall_manager'),
|
||||||
|
patch('app.account_manager', mock_am),
|
||||||
|
patch('app.config_manager', mock_cfg),
|
||||||
|
patch('app.service_registry', mock_sreg),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
import auth_routes
|
||||||
|
patches.append(patch.object(auth_routes, 'auth_manager', auth_mgr, create=True))
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
started = [p.start() for p in patches]
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200
|
||||||
|
resp = _post_peer(client)
|
||||||
|
assert resp.status_code == 201, 'HTTP provision failure must not block peer creation'
|
||||||
|
finally:
|
||||||
|
for p in patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
# ── POST /api/peers — firewall rollback (A3) ──────────────────────────────────
|
# ── POST /api/peers — firewall rollback (A3) ──────────────────────────────────
|
||||||
|
|
||||||
def test_create_peer_rolls_back_firewall_on_dns_failure(
|
def test_create_peer_rolls_back_firewall_on_dns_failure(
|
||||||
|
|||||||
@@ -771,5 +771,36 @@ class TestRemove(unittest.TestCase):
|
|||||||
composer.remove.assert_called_once_with('myapp', purge_data=True)
|
composer.remove.assert_called_once_with('myapp', purge_data=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReapplyOnStartup(unittest.TestCase):
|
||||||
|
|
||||||
|
def _make_ssm_with_installed(self):
|
||||||
|
ssm = _make_manager(installed={'svc1': {'service_ip': '172.20.1.10', 'iptables_rules': []}})
|
||||||
|
ssm.caddy_manager = MagicMock()
|
||||||
|
return ssm
|
||||||
|
|
||||||
|
def test_reapply_calls_egress_apply_all_when_wired(self):
|
||||||
|
ssm = self._make_ssm_with_installed()
|
||||||
|
mock_egress = MagicMock()
|
||||||
|
ssm.egress_manager = mock_egress
|
||||||
|
with patch('firewall_manager.apply_service_rules'):
|
||||||
|
ssm.reapply_on_startup()
|
||||||
|
mock_egress.apply_all.assert_called_once()
|
||||||
|
|
||||||
|
def test_reapply_skips_egress_when_not_wired(self):
|
||||||
|
"""reapply_on_startup must not raise when egress_manager is None."""
|
||||||
|
ssm = self._make_ssm_with_installed()
|
||||||
|
ssm.egress_manager = None
|
||||||
|
with patch('firewall_manager.apply_service_rules'):
|
||||||
|
ssm.reapply_on_startup() # must not raise
|
||||||
|
|
||||||
|
def test_reapply_egress_failure_is_nonfatal(self):
|
||||||
|
ssm = self._make_ssm_with_installed()
|
||||||
|
mock_egress = MagicMock()
|
||||||
|
mock_egress.apply_all.side_effect = RuntimeError('iptables error')
|
||||||
|
ssm.egress_manager = mock_egress
|
||||||
|
with patch('firewall_manager.apply_service_rules'):
|
||||||
|
ssm.reapply_on_startup() # must not raise
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user