feat: Phase 3 — ServiceComposer deps + store install via per-service compose
Unit Tests / test (push) Successful in 11m21s

ServiceStoreManager.install() now delegates container lifecycle to
ServiceComposer (per-service docker-compose.yml) instead of appending to a
shared compose override. This eliminates IP pool allocation, compose override
rendering, and the single-stack docker exec approach.

Changes:
- service_composer.py: add _resolve_requires(), _resolve_dependents(),
  reapply_active_services() — dependency graph and startup reapply
- service_store_manager.py: rewrite install() and remove() to use
  ServiceComposer; add _fetch_template(); delete _allocate_service_ip(),
  _render_compose_override(), _write_compose_override(); remove() now guards
  against removing services that others depend on
- managers.py: pass service_composer= to ServiceStoreManager
- Tests: 13 new composer dep tests; TestInstall/TestRemove rewritten for
  the new composer-driven path; test_optional_services_feature.py updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 09:33:02 -04:00
parent 0bfe95320b
commit 87c321c1c9
6 changed files with 442 additions and 767 deletions
+33
View File
@@ -277,6 +277,39 @@ class ServiceComposer:
pass
return result
# ── Dependency resolution ─────────────────────────────────────────────
def _resolve_requires(self, manifest: Dict, installed_services: Dict) -> Optional[str]:
"""Return an error string if any required services are missing, else None."""
requires = manifest.get('requires') or []
missing = [r for r in requires if r not in installed_services]
if missing:
return f"Required services not installed: {', '.join(sorted(missing))}"
return None
def _resolve_dependents(self, service_id: str, installed_services: Dict) -> List[str]:
"""Return list of installed service IDs that declare service_id in their requires."""
dependents = []
for svc_id, record in installed_services.items():
if svc_id == service_id:
continue
m = (record.get('manifest') or {})
if service_id in (m.get('requires') or []):
dependents.append(svc_id)
return dependents
def reapply_active_services(self) -> None:
"""Call up() for every installed service that has a compose file. Called at startup."""
installed = self.cm.get_installed_services()
for svc_id in installed:
if not self.has_compose_file(svc_id):
logger.warning('reapply_active_services: no compose file for %s, skipping', svc_id)
continue
result = self.up(svc_id)
if not result.get('ok'):
logger.warning('reapply_active_services: up failed for %s: %s',
svc_id, result.get('error') or result.get('stderr', ''))
# ── Builtin-service lifecycle (main compose stack) ─────────────────────
@staticmethod