feat: port conflict validation, autosave on Apply, extended integration tests
Port conflict validation: - api/port_registry.py: detect_conflicts() checks all service sections for shared port values - api/app.py: returns HTTP 409 on port conflict after existing range validation - webui/src/pages/Settings.jsx: JS-side detectPortConflicts() with useMemo shows inline conflict errors and blocks Save before the request is made; catch blocks surface server error messages (including 409) instead of generic fallbacks Config autosave on Apply: - webui/src/contexts/DraftConfigContext.jsx: new context; Settings registers flush callbacks per section; App calls flushAll() before applyPending() when any section is dirty - webui/src/App.jsx: wraps tree with DraftConfigProvider, handleApply shows 'saving' banner state and awaits flushAll() - webui/src/pages/Settings.jsx: registers identity + per-service flushers; propagates dirty state into context via setDirty; uses refs to avoid stale closures Extended integration test coverage (114 new tests): - tests/integration/test_config_api.py: GET/PUT config, export, import, backup lifecycle - tests/integration/test_network_services.py: DNS records + DHCP reservations CRUD - tests/integration/test_containers.py: list, restart, logs, stats; recovery polling - tests/integration/test_negative_scenarios.py: error-path coverage for all endpoints - tests/test_port_conflicts.py: 20 unit tests for port_registry.detect_conflicts() Pre-commit hook updated to skip tests/integration/ (live-stack tests require a running stack and must be run explicitly via `make test-integration`). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ from service_bus import ServiceBus, EventType
|
||||
from log_manager import LogManager
|
||||
from cell_link_manager import CellLinkManager
|
||||
import firewall_manager
|
||||
from port_registry import PORT_FIELDS, detect_conflicts
|
||||
|
||||
# Context variable for request info
|
||||
request_context = contextvars.ContextVar('request_context', default={})
|
||||
@@ -477,6 +478,14 @@ def update_config():
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': f'{_svc}.{_f} must be an integer between 1 and 65535'}), 400
|
||||
# Validate that no two service sections use the same port number
|
||||
_conflicts = detect_conflicts(config_manager.configs, data)
|
||||
if _conflicts:
|
||||
_msgs = []
|
||||
for _c in _conflicts:
|
||||
_pairs = ', '.join(f"{_s}.{_f}" for _s, _f in _c['conflicts'])
|
||||
_msgs.append(f"port {_c['port']} is used by {_pairs}")
|
||||
return jsonify({'error': 'Port conflict: ' + '; '.join(_msgs)}), 409
|
||||
# Validate WireGuard address (must be valid IP/CIDR)
|
||||
if 'wireguard' in data and isinstance(data['wireguard'], dict):
|
||||
_addr = data['wireguard'].get('address')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Port conflict detection for PIC.
|
||||
|
||||
Maps each service section to the port field names it exposes, and provides
|
||||
detect_conflicts() to find cases where two distinct (section, field) slots
|
||||
resolve to the same integer port value.
|
||||
"""
|
||||
|
||||
# Maps section → list of port field names within that section's config dict.
|
||||
# Must stay in sync with the _port_fields dict in app.py's update_config().
|
||||
PORT_FIELDS = {
|
||||
'network': ['dns_port'],
|
||||
'wireguard': ['port'],
|
||||
'email': ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'],
|
||||
'calendar': ['port'],
|
||||
'files': ['port', 'manager_port'],
|
||||
}
|
||||
|
||||
|
||||
def detect_conflicts(effective_config, incoming_patch):
|
||||
"""
|
||||
Detect port conflicts across all tracked service sections.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
effective_config : dict
|
||||
The current full config as stored (e.g. config_manager.configs).
|
||||
Each key is a section name; the value is a dict of that section's
|
||||
config fields.
|
||||
incoming_patch : dict
|
||||
The partial update the user is trying to save. Values here override
|
||||
whatever is in effective_config for the purpose of conflict checking.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of dict
|
||||
Each element is {'port': <int>, 'conflicts': [(section, field), ...]}.
|
||||
Only entries where 2+ (section, field) pairs share the same port are
|
||||
included. Returns an empty list when there are no conflicts.
|
||||
"""
|
||||
# Build merged view: start from stored config, overlay the patch
|
||||
merged = {}
|
||||
for section in PORT_FIELDS:
|
||||
stored = effective_config.get(section, {}) or {}
|
||||
patch = incoming_patch.get(section, {}) or {}
|
||||
merged[section] = {**stored, **patch}
|
||||
|
||||
# Collect port → [(section, field)] mapping
|
||||
port_map = {}
|
||||
for section, fields in PORT_FIELDS.items():
|
||||
for field in fields:
|
||||
raw = merged[section].get(field)
|
||||
if raw is None or raw == '':
|
||||
continue
|
||||
try:
|
||||
port_val = int(raw)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
port_map.setdefault(port_val, []).append((section, field))
|
||||
|
||||
# Return only entries that have more than one (section, field) slot
|
||||
conflicts = []
|
||||
for port_val, slots in port_map.items():
|
||||
if len(slots) >= 2:
|
||||
conflicts.append({'port': port_val, 'conflicts': slots})
|
||||
|
||||
return conflicts
|
||||
Reference in New Issue
Block a user