From 443afc0099770df1f0bc07a31b60450537c79144 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:01:10 +0000 Subject: [PATCH 1/8] fix: register missing queues and replace string literals with Queue enum Added AND_ADMISSIONS, PKI_CERT_ROTATION_EVENTS, DRIFT_EVENTS, and WORLD_HEALTH (plus their DLQs) to the Queue enum and PRIMARY_QUEUES tuple. Updated phase_ands, pki_cert_rotation_worker, drift_controller, and monitoring/service to import and use Queue instead of bare strings, eliminating the runtime risk of sending to unregistered queues. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/core/drift_controller.py | 3 ++- netengine/events/queues.py | 12 ++++++++++++ netengine/monitoring/service.py | 3 ++- netengine/phases/phase_ands.py | 11 ++++++----- netengine/workers/pki_cert_rotation_worker.py | 3 ++- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/netengine/core/drift_controller.py b/netengine/core/drift_controller.py index db69cf4..a3f01ad 100644 --- a/netengine/core/drift_controller.py +++ b/netengine/core/drift_controller.py @@ -12,6 +12,7 @@ from typing import Any, Optional from netengine.core.orchestrator import Orchestrator +from netengine.events.queues import Queue from netengine.events.schema import EventEnvelope from netengine.handlers._base import BasePhaseHandler @@ -330,7 +331,7 @@ async def _emit_drift_event( emitted_by="drift_controller", payload=payload, ) - await self.orchestrator.context.pgmq_client.send("drift_events", event) + await self.orchestrator.context.pgmq_client.send(Queue.DRIFT_EVENTS, event) logger.debug(f"Drift event emitted: {event_type}") except Exception as e: logger.error(f"Failed to emit drift event: {e}") diff --git a/netengine/events/queues.py b/netengine/events/queues.py index 05180f3..011a10a 100644 --- a/netengine/events/queues.py +++ b/netengine/events/queues.py @@ -14,6 +14,10 @@ class Queue(StrEnum): AND_PROVISIONING = "and_provisioning" INWORLD_ADMISSIONS = "inworld_admissions" SERVICES_ADMISSIONS = "services_admissions" + AND_ADMISSIONS = "and_admissions" + PKI_CERT_ROTATION_EVENTS = "pki_cert_rotation_events" + DRIFT_EVENTS = "drift_events" + WORLD_HEALTH = "world_health" # Dead-letter queues (derived from primary names) DNS_UPDATES_DLQ = "dns_updates_dlq" @@ -21,6 +25,10 @@ class Queue(StrEnum): AND_PROVISIONING_DLQ = "and_provisioning_dlq" INWORLD_ADMISSIONS_DLQ = "inworld_admissions_dlq" SERVICES_ADMISSIONS_DLQ = "services_admissions_dlq" + AND_ADMISSIONS_DLQ = "and_admissions_dlq" + PKI_CERT_ROTATION_EVENTS_DLQ = "pki_cert_rotation_events_dlq" + DRIFT_EVENTS_DLQ = "drift_events_dlq" + WORLD_HEALTH_DLQ = "world_health_dlq" # Primary queues only — used for metrics/introspection endpoints @@ -30,4 +38,8 @@ class Queue(StrEnum): Queue.AND_PROVISIONING, Queue.INWORLD_ADMISSIONS, Queue.SERVICES_ADMISSIONS, + Queue.AND_ADMISSIONS, + Queue.PKI_CERT_ROTATION_EVENTS, + Queue.DRIFT_EVENTS, + Queue.WORLD_HEALTH, ) diff --git a/netengine/monitoring/service.py b/netengine/monitoring/service.py index 603e710..b632874 100644 --- a/netengine/monitoring/service.py +++ b/netengine/monitoring/service.py @@ -6,6 +6,7 @@ from netengine.core.pgmq_client import PGMQClient from netengine.diagnostic import ProbeResult, ProbeStatus, build_runner +from netengine.events.queues import Queue from netengine.events.schema import EventEnvelope from netengine.spec.models import NetEngineSpec @@ -87,7 +88,7 @@ async def _run_probe_cycle(self) -> None: ) try: - msg_id = await self._pgmq.send("world_health", event) + msg_id = await self._pgmq.send(Queue.WORLD_HEALTH, event) logger.debug(f"Published world_health event (msg_id: {msg_id})") except Exception as e: logger.error(f"Failed to publish world_health event: {e}", exc_info=True) diff --git a/netengine/phases/phase_ands.py b/netengine/phases/phase_ands.py index 3af0cea..7075be2 100644 --- a/netengine/phases/phase_ands.py +++ b/netengine/phases/phase_ands.py @@ -15,6 +15,7 @@ from datetime import UTC, datetime from typing import Any, Optional +from netengine.events.queues import Queue from netengine.events.schema import EventEnvelope from netengine.handlers._base import BasePhaseHandler from netengine.handlers.context import PhaseContext @@ -346,7 +347,7 @@ async def _consume_org_admission_events( while True: try: - msg = await context.pgmq_client.receive("and_admissions") + msg = await context.pgmq_client.receive(Queue.AND_ADMISSIONS) if not msg: await asyncio.sleep(1) continue @@ -355,7 +356,7 @@ async def _consume_org_admission_events( envelope = EventEnvelope(**json.loads(msg["message"])) if envelope.event_type != "org.admitted": - await context.pgmq_client.delete("and_admissions", msg["msg_id"]) + await context.pgmq_client.delete(Queue.AND_ADMISSIONS, msg["msg_id"]) continue payload = envelope.payload @@ -364,7 +365,7 @@ async def _consume_org_admission_events( if not org_name: logger.warning("org.admitted event missing org_name") - await context.pgmq_client.delete("and_admissions", msg["msg_id"]) + await context.pgmq_client.delete(Queue.AND_ADMISSIONS, msg["msg_id"]) continue logger.info(f"Auto-provisioning AND for org: {org_name}") @@ -379,13 +380,13 @@ def __init__(self, org: str, profile: str) -> None: and_instance = SyntheticAND(org_name, and_profile) await self._provision_and(context, docker, gateway, and_instance, ands_spec) logger.info(f"Auto-provisioned AND for org {org_name}") - await context.pgmq_client.delete("and_admissions", msg["msg_id"]) + await context.pgmq_client.delete(Queue.AND_ADMISSIONS, msg["msg_id"]) except Exception as e: logger.error(f"Failed to process org admission event: {e}") try: await context.pgmq_client.archive_to_dlq( - "and_admissions", msg["msg_id"], str(e) + Queue.AND_ADMISSIONS, msg["msg_id"], str(e) ) except Exception as dlq_err: logger.error(f"Failed to archive to DLQ: {dlq_err}") diff --git a/netengine/workers/pki_cert_rotation_worker.py b/netengine/workers/pki_cert_rotation_worker.py index 4ce2b09..9a3d25e 100644 --- a/netengine/workers/pki_cert_rotation_worker.py +++ b/netengine/workers/pki_cert_rotation_worker.py @@ -7,6 +7,7 @@ from netengine.core.pgmq_client import PGMQClient from netengine.core.state import RuntimeState +from netengine.events.queues import Queue from netengine.events.schema import EventEnvelope from netengine.handlers.pki_handler import PKIHandler @@ -180,6 +181,6 @@ async def _emit_rotation_event( emitted_by="pki_cert_rotation_worker", payload=payload, ) - await self.pgmq.send("pki_cert_rotation_events", event) + await self.pgmq.send(Queue.PKI_CERT_ROTATION_EVENTS, event) except Exception as e: self.logger.debug(f"Failed to emit rotation event: {e}") From 9434c67b0156e36de8ec0ee549d554d5030cdc56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:07:11 +0000 Subject: [PATCH 2/8] feat: warn on unsupported spec fields at load time Added _warn_unsupported() called from all three load_spec paths to emit logger.warning for every field declared in the spec model but not yet implemented: DNSSEC, CRL, OCSP, intermediate CA, real internet mode, service mirrors, upstream resolver, cross-world federation, AND dynamic IP/reverse DNS/BGP, and mail SPF/DMARC defaults. Includes 7 tests. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/spec/loader.py | 88 ++++++++++++++++++++++++++++++++++++++ tests/test_spec_parsing.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/netengine/spec/loader.py b/netengine/spec/loader.py index 6d9587c..a21ad8e 100644 --- a/netengine/spec/loader.py +++ b/netengine/spec/loader.py @@ -1,6 +1,7 @@ """YAML spec loading and validation with OmegaConf composition support.""" import ipaddress +import logging from pathlib import Path from typing import Any, Optional @@ -10,6 +11,8 @@ from netengine.config.loader import ConfigLoader from netengine.spec.models import NetEngineSpec +logger = logging.getLogger(__name__) + class SpecLoadError(Exception): """Raised when spec loading or validation fails.""" @@ -17,6 +20,88 @@ class SpecLoadError(Exception): pass +def _warn_unsupported(spec: NetEngineSpec) -> None: + """Emit warnings for spec fields that are declared but not yet implemented.""" + pki = spec.pki + + if pki.dnssec_enabled: + logger.warning( + "pki.dnssec_enabled is set but DNSSEC is not yet implemented — field will be ignored" + ) + if pki.crl_enabled: + logger.warning( + "pki.crl_enabled is set but CRL is not yet implemented — field will be ignored" + ) + if pki.ocsp_enabled: + logger.warning( + "pki.ocsp_enabled is set but OCSP is not yet implemented — field will be ignored" + ) + if pki.intermediate_ca_enabled: + logger.warning( + "pki.intermediate_ca_enabled is set but intermediate CA is not yet implemented" + " — field will be ignored" + ) + + gw = spec.gateway_portal + if gw.real_internet.mode.value != "isolated": + logger.warning( + f"gateway.real_internet.mode={gw.real_internet.mode.value!r} but real internet" + " mode is not yet implemented — gateway will remain isolated" + ) + if gw.real_internet.service_mirrors: + logger.warning( + "gateway.real_internet.service_mirrors is set but service mirrors are not yet" + " implemented — mirrors will be ignored" + ) + if gw.real_internet.upstream_resolver_enabled: + logger.warning( + "gateway.real_internet.upstream_resolver_enabled is set but upstream resolver" + " is not yet implemented — field will be ignored" + ) + if gw.cross_world.mode.value != "none": + logger.warning( + f"gateway.cross_world.mode={gw.cross_world.mode.value!r} but cross-world" + " federation is not yet implemented — gateway will remain isolated" + ) + if gw.cross_world.peers: + logger.warning( + "gateway.cross_world.peers is set but cross-world federation is not yet" + " implemented — peers will be ignored" + ) + + for profile_name, profile in spec.ands.profiles.items(): + if profile.dynamic_ip: + logger.warning( + f"ands.profiles.{profile_name}.dynamic_ip is set but dynamic IP allocation" + " is not yet implemented — field will be ignored" + ) + if profile.reverse_dns: + logger.warning( + f"ands.profiles.{profile_name}.reverse_dns is set but reverse DNS is not" + " yet implemented — field will be ignored" + ) + if profile.bgp is not None: + logger.warning( + f"ands.profiles.{profile_name}.bgp={profile.bgp!r} but BGP configuration" + " is not yet implemented — field will be ignored" + ) + + mail = spec.world_services.mail + if mail.enabled: + policy = mail.mailbox_policy + if policy is not None: + if policy.spf_default: + logger.warning( + "mail.mailbox_policy.spf_default is set but SPF record generation is" + " not yet implemented — field will be ignored" + ) + if policy.dmarc_default: + logger.warning( + "mail.mailbox_policy.dmarc_default is set but DMARC policy is not yet" + " implemented — field will be ignored" + ) + + def _cross_validate(spec: NetEngineSpec) -> None: """Cross-field validation not expressible in Pydantic field validators. @@ -97,6 +182,7 @@ def load_spec(yaml_path: str | Path) -> NetEngineSpec: raise SpecLoadError(f"Spec validation failed: {e}") _cross_validate(spec) + _warn_unsupported(spec) return spec @@ -156,6 +242,7 @@ def load_spec_with_composition( raise SpecLoadError(f"Spec validation failed: {e}") _cross_validate(spec) + _warn_unsupported(spec) return spec @@ -213,4 +300,5 @@ def load_spec_with_environment( raise SpecLoadError(f"Spec validation failed: {e}") _cross_validate(spec) + _warn_unsupported(spec) return spec diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index fd14626..df690d0 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -137,3 +137,81 @@ def test_tld_defaults(self, single_org_spec: NetEngineSpec) -> None: tld = single_org_spec.dns.tlds[0] assert tld.type == "authoritative" assert tld.listen_ip is not None + + +class TestUnsupportedFieldWarnings: + """_warn_unsupported emits the right warnings for enabled-but-unimplemented fields.""" + + def _make_spec(self, overrides: dict) -> NetEngineSpec: + from netengine.spec.loader import _cross_validate + from pathlib import Path + import yaml + + base = yaml.safe_load((Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text()) + # deep-merge overrides + def _merge(a, b): + for k, v in b.items(): + if isinstance(v, dict) and isinstance(a.get(k), dict): + _merge(a[k], v) + else: + a[k] = v + _merge(base, overrides) + spec = NetEngineSpec(**base) + _cross_validate(spec) + return spec + + def test_dnssec_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"pki": {"dnssec_enabled": True}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("dnssec_enabled" in r.message for r in caplog.records) + + def test_crl_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"pki": {"crl_enabled": True}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("crl_enabled" in r.message for r in caplog.records) + + def test_ocsp_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"pki": {"ocsp_enabled": True}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("ocsp_enabled" in r.message for r in caplog.records) + + def test_intermediate_ca_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"pki": {"intermediate_ca_enabled": True}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("intermediate_ca_enabled" in r.message for r in caplog.records) + + def test_real_internet_mode_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"gateway_portal": {"real_internet": {"mode": "mirrored"}}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("real_internet.mode" in r.message for r in caplog.records) + + def test_cross_world_mode_warns(self, caplog) -> None: + import logging + spec = self._make_spec({"gateway_portal": {"cross_world": {"mode": "peered"}}}) + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) + assert any("cross_world.mode" in r.message for r in caplog.records) + + def test_no_warnings_for_default_spec(self, caplog, minimal_spec) -> None: + import logging + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): + from netengine.spec.loader import _warn_unsupported + _warn_unsupported(minimal_spec) + # dnssec_enabled defaults to True in the model, so one warning is expected for that + assert all("crl" not in r.message and "ocsp" not in r.message for r in caplog.records) From 12af970e420cce67bbf9fbe99120ff5bd64e4f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:08:18 +0000 Subject: [PATCH 3/8] fix: apply black formatting to test_spec_parsing.py Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- tests/test_spec_parsing.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index df690d0..ea2bd7a 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -147,7 +147,10 @@ def _make_spec(self, overrides: dict) -> NetEngineSpec: from pathlib import Path import yaml - base = yaml.safe_load((Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text()) + base = yaml.safe_load( + (Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text() + ) + # deep-merge overrides def _merge(a, b): for k, v in b.items(): @@ -155,6 +158,7 @@ def _merge(a, b): _merge(a[k], v) else: a[k] = v + _merge(base, overrides) spec = NetEngineSpec(**base) _cross_validate(spec) @@ -162,56 +166,70 @@ def _merge(a, b): def test_dnssec_warns(self, caplog) -> None: import logging + spec = self._make_spec({"pki": {"dnssec_enabled": True}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("dnssec_enabled" in r.message for r in caplog.records) def test_crl_warns(self, caplog) -> None: import logging + spec = self._make_spec({"pki": {"crl_enabled": True}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("crl_enabled" in r.message for r in caplog.records) def test_ocsp_warns(self, caplog) -> None: import logging + spec = self._make_spec({"pki": {"ocsp_enabled": True}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("ocsp_enabled" in r.message for r in caplog.records) def test_intermediate_ca_warns(self, caplog) -> None: import logging + spec = self._make_spec({"pki": {"intermediate_ca_enabled": True}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("intermediate_ca_enabled" in r.message for r in caplog.records) def test_real_internet_mode_warns(self, caplog) -> None: import logging + spec = self._make_spec({"gateway_portal": {"real_internet": {"mode": "mirrored"}}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("real_internet.mode" in r.message for r in caplog.records) def test_cross_world_mode_warns(self, caplog) -> None: import logging + spec = self._make_spec({"gateway_portal": {"cross_world": {"mode": "peered"}}}) with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(spec) assert any("cross_world.mode" in r.message for r in caplog.records) def test_no_warnings_for_default_spec(self, caplog, minimal_spec) -> None: import logging + with caplog.at_level(logging.WARNING, logger="netengine.spec.loader"): from netengine.spec.loader import _warn_unsupported + _warn_unsupported(minimal_spec) # dnssec_enabled defaults to True in the model, so one warning is expected for that assert all("crl" not in r.message and "ocsp" not in r.message for r in caplog.records) From 13f43398423aa385d5050f840df8f2604e3dd7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:09:08 +0000 Subject: [PATCH 4/8] fix: apply isort to test_spec_parsing.py Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- tests/test_spec_parsing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_spec_parsing.py b/tests/test_spec_parsing.py index ea2bd7a..21bc287 100644 --- a/tests/test_spec_parsing.py +++ b/tests/test_spec_parsing.py @@ -143,10 +143,12 @@ class TestUnsupportedFieldWarnings: """_warn_unsupported emits the right warnings for enabled-but-unimplemented fields.""" def _make_spec(self, overrides: dict) -> NetEngineSpec: - from netengine.spec.loader import _cross_validate from pathlib import Path + import yaml + from netengine.spec.loader import _cross_validate + base = yaml.safe_load( (Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text() ) From f7a21f24b993131f5b9d081095157ec41904ed77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:15:06 +0000 Subject: [PATCH 5/8] fix: add world_services_output prerequisite for Phase 9 (org_apps) Phase 9 had no entry in PHASE_PREREQUISITES, allowing OrgAppsPhaseHandler to run before Phase 8 (ServicesPhaseHandler) completed. Added world_services_output as a required prerequisite. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/core/phase_graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netengine/core/phase_graph.py b/netengine/core/phase_graph.py index 85a14f3..ba9ca87 100644 --- a/netengine/core/phase_graph.py +++ b/netengine/core/phase_graph.py @@ -43,4 +43,5 @@ 6: ["world_registry_output", "domain_registry_output"], 7: ["identity_inworld_output"], 8: ["ands_output"], + 9: ["world_services_output"], } From be1d33f9c922a9ee4d393d32981e232b070538b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:18:55 +0000 Subject: [PATCH 6/8] feat: make PKI rotation worker reload-aware via _resolve_configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker previously built CertTypeRotationConfig at startup and never refreshed them, so live-reloading rotation_policy had no effect until restart. Added _resolve_configs() which re-reads pki.rotation_policy from state.world_spec on every loop iteration — picking up interval, warning_days, and cert_type_overrides changes immediately after a /reload. Falls back to initial configs on parse error. Added 6 tests. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/workers/pki_cert_rotation_worker.py | 65 ++++++++++++- tests/test_pki_features.py | 97 +++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/netengine/workers/pki_cert_rotation_worker.py b/netengine/workers/pki_cert_rotation_worker.py index 9a3d25e..cb037c7 100644 --- a/netengine/workers/pki_cert_rotation_worker.py +++ b/netengine/workers/pki_cert_rotation_worker.py @@ -13,6 +13,9 @@ logger = logging.getLogger(__name__) +# Built-in cert types always managed by the rotation worker. +_BUILTIN_CERT_TYPES = ["platform_identity", "inworld_identity", "app", "storage"] + @dataclass class CertTypeRotationConfig: @@ -35,17 +38,75 @@ def __init__( ): self.pki_handler = pki_handler self.pgmq = pgmq - self.cert_type_configs = {cfg.cert_type: cfg for cfg in cert_type_configs} + # Initial configs, keyed by cert_type. Used as fallback if spec reload fails. + self._initial_configs: Dict[str, CertTypeRotationConfig] = { + cfg.cert_type: cfg for cfg in cert_type_configs + } + # Per-cert-type callbacks survive spec reloads (they're in-process callables). + self._callbacks: Dict[str, Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]]] = { + cfg.cert_type: cfg.rotation_callback for cfg in cert_type_configs + } self.logger = logging.getLogger(__name__) + def _resolve_configs(self, state: RuntimeState) -> Dict[str, CertTypeRotationConfig]: + """Return the current rotation config, refreshed from world_spec if available. + + On live-reload the world_spec in RuntimeState is updated; re-reading it here + means the worker picks up rotation_policy changes without a restart. + Falls back to the initial configs on any parse error. + """ + if not state.world_spec: + return self._initial_configs + + try: + from netengine.spec.models import NetEngineSpec + + spec = NetEngineSpec(**state.world_spec) + policy = spec.pki.rotation_policy + + if not policy.enabled: + return {} + + extra_types = [t for t in policy.cert_type_overrides if t not in _BUILTIN_CERT_TYPES] + all_cert_types = _BUILTIN_CERT_TYPES + extra_types + + configs: Dict[str, CertTypeRotationConfig] = {} + for cert_type in all_cert_types: + override = policy.cert_type_overrides.get(cert_type) + if isinstance(override, dict): + interval = override.get( + "rotation_interval_hours", policy.default_interval_hours + ) + warning = override.get("expiry_warning_days", policy.default_warning_days) + else: + interval = policy.default_interval_hours + warning = policy.default_warning_days + + configs[cert_type] = CertTypeRotationConfig( + cert_type=cert_type, + rotation_interval_hours=interval, + expiry_warning_days=warning, + rotation_callback=self._callbacks.get(cert_type), + ) + return configs + except Exception as exc: + self.logger.warning( + f"pki_rotation_worker: spec reload failed, using cached config: {exc}" + ) + return self._initial_configs + async def run(self) -> None: """Main worker loop: check expiry per cert type, rotate if needed.""" while True: try: state = RuntimeState.load() + # Re-resolve configs from current spec on every iteration so that + # live reloads changing rotation_policy take effect without restart. + current_configs = self._resolve_configs(state) + # Check each cert type on its own schedule - for cert_type, config in self.cert_type_configs.items(): + for cert_type, config in current_configs.items(): last_check = self._get_last_check_time(state, cert_type) if self._should_check_now(last_check, config.rotation_interval_hours): await self._check_and_rotate_cert_type(state, cert_type, config) diff --git a/tests/test_pki_features.py b/tests/test_pki_features.py index 99d0c3f..626a865 100644 --- a/tests/test_pki_features.py +++ b/tests/test_pki_features.py @@ -356,3 +356,100 @@ def test_no_supervisor_skips_registration(self): handler = PKIPhaseHandler() # Should not raise handler._register_rotation_worker(ctx, MagicMock(), spec) + + +# ───────────────────────────────────────────── +# PKI Rotation Worker — reload-aware config +# ───────────────────────────────────────────── + + +class TestPKICertRotationWorkerReloadAware: + """_resolve_configs picks up rotation_policy changes from world_spec.""" + + def _make_worker(self, initial_interval=24, initial_warning=30): + from netengine.workers.pki_cert_rotation_worker import PKICertRotationWorker + + configs = [ + CertTypeRotationConfig( + cert_type=ct, + rotation_interval_hours=initial_interval, + expiry_warning_days=initial_warning, + ) + for ct in ["platform_identity", "inworld_identity", "app", "storage"] + ] + return PKICertRotationWorker( + pki_handler=MagicMock(), + pgmq=MagicMock(), + cert_type_configs=configs, + ) + + def _make_state(self, interval, warning, extra_overrides=None): + from pathlib import Path + + import yaml + + from netengine.spec.loader import load_spec + + base = yaml.safe_load( + (Path(__file__).parent.parent / "examples" / "minimal.yaml").read_text() + ) + base.setdefault("pki", {})["rotation_policy"] = { + "enabled": True, + "default_interval_hours": interval, + "default_warning_days": warning, + "cert_type_overrides": extra_overrides or {}, + } + from netengine.spec.models import NetEngineSpec + + spec = NetEngineSpec(**base) + state = RuntimeState() + state.world_spec = spec.model_dump() + return state + + def test_resolves_updated_interval_from_world_spec(self): + worker = self._make_worker(initial_interval=24) + state = self._make_state(interval=6, warning=30) + configs = worker._resolve_configs(state) + assert configs["app"].rotation_interval_hours == 6 + + def test_resolves_updated_warning_days_from_world_spec(self): + worker = self._make_worker(initial_warning=30) + state = self._make_state(interval=24, warning=7) + configs = worker._resolve_configs(state) + assert configs["platform_identity"].expiry_warning_days == 7 + + def test_per_type_override_applied(self): + worker = self._make_worker() + state = self._make_state( + interval=24, + warning=30, + extra_overrides={"app": {"rotation_interval_hours": 2, "expiry_warning_days": 5}}, + ) + configs = worker._resolve_configs(state) + assert configs["app"].rotation_interval_hours == 2 + assert configs["app"].expiry_warning_days == 5 + # Other types use defaults + assert configs["storage"].rotation_interval_hours == 24 + + def test_disabled_policy_returns_empty(self): + worker = self._make_worker() + state = self._make_state(interval=24, warning=30) + state.world_spec["pki"]["rotation_policy"]["enabled"] = False + configs = worker._resolve_configs(state) + assert configs == {} + + def test_falls_back_to_initial_configs_on_corrupt_spec(self): + worker = self._make_worker(initial_interval=24) + state = RuntimeState() + state.world_spec = {"invalid": "spec"} + configs = worker._resolve_configs(state) + # Should fall back without raising + assert "app" in configs + assert configs["app"].rotation_interval_hours == 24 + + def test_no_world_spec_returns_initial_configs(self): + worker = self._make_worker(initial_interval=48) + state = RuntimeState() + state.world_spec = None + configs = worker._resolve_configs(state) + assert configs["app"].rotation_interval_hours == 48 From a80040b8f488021a91af10f6f38eb43dd10b3e0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:26:50 +0000 Subject: [PATCH 7/8] feat: add PUT /ands/{name}/profile, /gateway, /services/{name}, /pki/rotation-policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four previously missing API endpoints that allow targeted config updates without a full spec reload: - PUT /ands/{and_name}/profile — update AND profile and optional dns_suffix; validates profile exists in spec before applying - PUT /services/{name} — enable/disable world services (mail, storage) in spec - PUT /gateway — update real_internet_mode, upstream_resolver, cross_world_mode with enum validation on both mode fields - PUT /pki/rotation-policy — patch any rotation policy fields; validates interval/warning values >= 1; picked up by the rotation worker on next iteration (reload-aware worker from prior commit) 17 tests added covering happy path, validation errors, and missing-state cases. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/api/routes.py | 175 ++++++++++++++++++++++ tests/integration/test_m8_operator_api.py | 173 +++++++++++++++++++++ 2 files changed, 348 insertions(+) diff --git a/netengine/api/routes.py b/netengine/api/routes.py index 90a5b8a..c246b89 100644 --- a/netengine/api/routes.py +++ b/netengine/api/routes.py @@ -196,6 +196,34 @@ async def teardown_world( # ───────────────────────────────────────────── +class ServiceToggleRequest(BaseModel): + enabled: bool + + +@router.put("/services/{name}") +async def update_service( + name: str, body: ServiceToggleRequest, user: dict = Depends(require_auth) +) -> dict[str, Any]: + """Enable or disable a named world service (mail, storage) in the spec.""" + state = RuntimeState.load() + + if not state.world_spec: + raise HTTPException(status_code=409, detail="No world spec loaded") + + world_services = state.world_spec.get("world_services") or {} + if name not in world_services: + raise HTTPException( + status_code=404, + detail=f"Service '{name}' not found — known services: {list(world_services)}", + ) + + world_services[name]["enabled"] = body.enabled + state.world_spec["world_services"] = world_services + state.save() + + return {"status": "updated", "service": name, "enabled": body.enabled} + + @router.get("/services") async def get_services(user: dict = Depends(require_auth)) -> dict[str, Any]: """List running NetEngines containers and their status.""" @@ -404,6 +432,44 @@ async def create_and(body: ANDCreateRequest, user: dict = Depends(require_auth)) return {"status": "provisioned", "and": body.name, "org": body.org, "dns_suffix": dns_suffix} +class ANDProfileUpdateRequest(BaseModel): + profile: str + dns_suffix: str = "" + + +@router.put("/ands/{and_name}/profile") +async def update_and_profile( + and_name: str, body: ANDProfileUpdateRequest, user: dict = Depends(require_auth) +) -> dict[str, Any]: + """Update the profile (and optionally dns_suffix) of a provisioned AND instance.""" + state = RuntimeState.load() + + if not state.world_spec: + raise HTTPException(status_code=409, detail="No world spec loaded") + + ands_out = state.ands_output or {} + instances: list[dict[str, Any]] = ands_out.get("instances", []) + instance = next((i for i in instances if i.get("and_name") == and_name), None) + if instance is None: + raise HTTPException(status_code=404, detail=f"AND instance '{and_name}' not found") + profiles = (state.world_spec.get("ands") or {}).get("profiles", {}) + if body.profile not in profiles: + raise HTTPException( + status_code=422, + detail=f"Profile '{body.profile}' not defined in spec" + f" — known profiles: {list(profiles)}", + ) + + instance["profile"] = body.profile + if body.dns_suffix: + instance["dns_suffix"] = body.dns_suffix + + state.ands_output = ands_out + state.save() + + return {"status": "updated", "and": and_name, "profile": body.profile} + + @router.delete("/ands/{and_name}") async def remove_and(and_name: str, user: dict = Depends(require_auth)) -> dict[str, Any]: """Remove an AND instance.""" @@ -512,6 +578,71 @@ async def dns_query( raise HTTPException(status_code=503, detail=f"DNS query failed: {exc}") +# ───────────────────────────────────────────── +# Gateway +# ───────────────────────────────────────────── + + +class GatewayUpdateRequest(BaseModel): + real_internet_mode: str = "" + upstream_resolver_enabled: bool | None = None + upstream_resolver_ip: str = "" + cross_world_mode: str = "" + + +@router.put("/gateway") +async def update_gateway( + body: GatewayUpdateRequest, user: dict = Depends(require_auth) +) -> dict[str, Any]: + """Update gateway portal configuration in the running spec. + + Fields left at their zero-values are not modified. Changes take effect on + the next bootstrap cycle; live gateway reconfiguration requires a full reload. + """ + state = RuntimeState.load() + + if not state.world_spec: + raise HTTPException(status_code=409, detail="No world spec loaded") + + gw = state.world_spec.get("gateway_portal") or {} + real_internet = gw.get("real_internet") or {} + cross_world = gw.get("cross_world") or {} + + valid_ri_modes = {"isolated", "shadowed", "mirrored", "exposed", "custom"} + valid_cw_modes = {"none", "peered", "federated"} + + if body.real_internet_mode: + if body.real_internet_mode not in valid_ri_modes: + raise HTTPException( + status_code=422, + detail=f"Invalid real_internet_mode '{body.real_internet_mode}'" + f" — valid: {valid_ri_modes}", + ) + real_internet["mode"] = body.real_internet_mode + + if body.upstream_resolver_enabled is not None: + real_internet["upstream_resolver_enabled"] = body.upstream_resolver_enabled + + if body.upstream_resolver_ip: + real_internet["upstream_resolver_ip"] = body.upstream_resolver_ip + + if body.cross_world_mode: + if body.cross_world_mode not in valid_cw_modes: + raise HTTPException( + status_code=422, + detail=f"Invalid cross_world_mode '{body.cross_world_mode}'" + f" — valid: {valid_cw_modes}", + ) + cross_world["mode"] = body.cross_world_mode + + gw["real_internet"] = real_internet + gw["cross_world"] = cross_world + state.world_spec["gateway_portal"] = gw + state.save() + + return {"status": "updated", "gateway_portal": gw} + + # ───────────────────────────────────────────── # PKI # ───────────────────────────────────────────── @@ -540,6 +671,50 @@ async def list_certs(user: dict = Depends(require_auth)) -> dict[str, Any]: } +class PKIRotationPolicyUpdateRequest(BaseModel): + enabled: bool | None = None + default_interval_hours: int | None = None + default_warning_days: int | None = None + cert_type_overrides: dict[str, Any] | None = None + + +@router.put("/pki/rotation-policy") +async def update_pki_rotation_policy( + body: PKIRotationPolicyUpdateRequest, user: dict = Depends(require_auth) +) -> dict[str, Any]: + """Update PKI certificate rotation policy in the running spec. + + Only fields provided (non-None) are updated; omitted fields are left as-is. + Changes are picked up by the rotation worker on its next iteration without restart. + """ + state = RuntimeState.load() + + if not state.world_spec: + raise HTTPException(status_code=409, detail="No world spec loaded") + + pki = state.world_spec.get("pki") or {} + policy = pki.get("rotation_policy") or {} + + if body.enabled is not None: + policy["enabled"] = body.enabled + if body.default_interval_hours is not None: + if body.default_interval_hours < 1: + raise HTTPException(status_code=422, detail="default_interval_hours must be >= 1") + policy["default_interval_hours"] = body.default_interval_hours + if body.default_warning_days is not None: + if body.default_warning_days < 1: + raise HTTPException(status_code=422, detail="default_warning_days must be >= 1") + policy["default_warning_days"] = body.default_warning_days + if body.cert_type_overrides is not None: + policy["cert_type_overrides"] = body.cert_type_overrides + + pki["rotation_policy"] = policy + state.world_spec["pki"] = pki + state.save() + + return {"status": "updated", "rotation_policy": policy} + + # ───────────────────────────────────────────── # Identity # ───────────────────────────────────────────── diff --git a/tests/integration/test_m8_operator_api.py b/tests/integration/test_m8_operator_api.py index e5f2f71..0876aa4 100644 --- a/tests/integration/test_m8_operator_api.py +++ b/tests/integration/test_m8_operator_api.py @@ -595,3 +595,176 @@ def test_realms_returns_platform_and_inworld(self, tmp_path, monkeypatch): data = resp.json() assert data["platform_realm"]["realm"] == "platform" assert data["inworld_realm"]["realm"] == "inworld" + + +# ───────────────────────────────────────────── +# New targeted PUT endpoints +# ───────────────────────────────────────────── + + +def _make_world_spec(tmp_path, monkeypatch) -> tuple: + """Return (client, state) with a minimal world_spec pre-loaded.""" + monkeypatch.setenv("NETENGINE_STATE_FILE", str(tmp_path / "state.json")) + monkeypatch.setenv("NETENGINES_BOOTSTRAP_SECRET", "test-secret") + spec = _load_example("minimal.yaml") + state = RuntimeState() + state.world_spec = spec.model_dump() + state.save() + from netengine.api.app import app + + client = TestClient(app) + return client, state + + +AUTH = {"X-Bootstrap-Secret": "test-secret"} + + +class TestUpdateAndProfile: + def test_updates_profile(self, tmp_path, monkeypatch): + client, state = _make_world_spec(tmp_path, monkeypatch) + # Seed an AND instance in state + state2 = RuntimeState.load() + state2.ands_output = { + "instances": [{"and_name": "acme-and", "org": "acme", "profile": "business"}] + } + # Ensure the target profile exists in world_spec + state2.world_spec["ands"]["profiles"]["business"] = {"dhcp": True} + state2.save() + + resp = client.put( + "/api/v1/ands/acme-and/profile", + json={"profile": "business"}, + headers=AUTH, + ) + assert resp.status_code == 200 + assert resp.json()["profile"] == "business" + + def test_404_on_missing_and(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put( + "/api/v1/ands/nonexistent/profile", + json={"profile": "business"}, + headers=AUTH, + ) + assert resp.status_code == 404 + + def test_422_on_unknown_profile(self, tmp_path, monkeypatch): + client, state = _make_world_spec(tmp_path, monkeypatch) + state2 = RuntimeState.load() + state2.ands_output = { + "instances": [{"and_name": "acme-and", "org": "acme", "profile": "business"}] + } + state2.save() + resp = client.put( + "/api/v1/ands/acme-and/profile", + json={"profile": "unknown-profile"}, + headers=AUTH, + ) + assert resp.status_code == 422 + + def test_409_without_world_spec(self, tmp_path, monkeypatch): + monkeypatch.setenv("NETENGINE_STATE_FILE", str(tmp_path / "state.json")) + monkeypatch.setenv("NETENGINES_BOOTSTRAP_SECRET", "test-secret") + from netengine.api.app import app + + client = TestClient(app) + resp = client.put( + "/api/v1/ands/acme-and/profile", json={"profile": "business"}, headers=AUTH + ) + assert resp.status_code == 409 + + +class TestUpdateService: + def test_disables_mail_service(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/services/mail", json={"enabled": False}, headers=AUTH) + assert resp.status_code == 200 + assert resp.json()["enabled"] is False + + state = RuntimeState.load() + assert state.world_spec["world_services"]["mail"]["enabled"] is False + + def test_enables_storage_service(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/services/storage", json={"enabled": True}, headers=AUTH) + assert resp.status_code == 200 + + def test_404_on_unknown_service(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/services/nosuchservice", json={"enabled": True}, headers=AUTH) + assert resp.status_code == 404 + + +class TestUpdateGateway: + def test_updates_real_internet_mode(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/gateway", json={"real_internet_mode": "shadowed"}, headers=AUTH) + assert resp.status_code == 200 + state = RuntimeState.load() + assert state.world_spec["gateway_portal"]["real_internet"]["mode"] == "shadowed" + + def test_422_on_invalid_real_internet_mode(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/gateway", json={"real_internet_mode": "invalid"}, headers=AUTH) + assert resp.status_code == 422 + + def test_updates_cross_world_mode(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/gateway", json={"cross_world_mode": "peered"}, headers=AUTH) + assert resp.status_code == 200 + state = RuntimeState.load() + assert state.world_spec["gateway_portal"]["cross_world"]["mode"] == "peered" + + def test_422_on_invalid_cross_world_mode(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/gateway", json={"cross_world_mode": "bad"}, headers=AUTH) + assert resp.status_code == 422 + + def test_empty_body_is_noop(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/gateway", json={}, headers=AUTH) + assert resp.status_code == 200 + + +class TestUpdatePKIRotationPolicy: + def test_updates_interval(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put( + "/api/v1/pki/rotation-policy", json={"default_interval_hours": 6}, headers=AUTH + ) + assert resp.status_code == 200 + state = RuntimeState.load() + assert state.world_spec["pki"]["rotation_policy"]["default_interval_hours"] == 6 + + def test_disables_rotation(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put("/api/v1/pki/rotation-policy", json={"enabled": False}, headers=AUTH) + assert resp.status_code == 200 + state = RuntimeState.load() + assert state.world_spec["pki"]["rotation_policy"]["enabled"] is False + + def test_422_on_zero_interval(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put( + "/api/v1/pki/rotation-policy", json={"default_interval_hours": 0}, headers=AUTH + ) + assert resp.status_code == 422 + + def test_422_on_zero_warning_days(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + resp = client.put( + "/api/v1/pki/rotation-policy", json={"default_warning_days": 0}, headers=AUTH + ) + assert resp.status_code == 422 + + def test_updates_cert_type_overrides(self, tmp_path, monkeypatch): + client, _ = _make_world_spec(tmp_path, monkeypatch) + overrides = {"app": {"rotation_interval_hours": 2}} + resp = client.put( + "/api/v1/pki/rotation-policy", + json={"cert_type_overrides": overrides}, + headers=AUTH, + ) + assert resp.status_code == 200 + state = RuntimeState.load() + assert state.world_spec["pki"]["rotation_policy"]["cert_type_overrides"] == overrides From 6343eb6debc7c07c11f47f08a19967e0fa4c37f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:33:00 +0000 Subject: [PATCH 8/8] fix: correct world_registry field name in mail_handler and remove false SPF/DMARC warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mail_handler._inject_dns_records referenced spec.world_registry.initial_orgs which does not exist (the field is organizations), causing SPF/DMARC/DKIM/MX records to silently never be injected for any org. Fixed both references to use the correct field name. Also removed the loader warnings claiming SPF/DMARC were unimplemented — the implementation was already present in mail_handler; the warnings were incorrect. Updated the five matching test fixtures to use the correct field name. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01EDXqTHJNBVA9aHbgyQYj2x --- netengine/handlers/mail_handler.py | 4 ++-- netengine/spec/loader.py | 15 --------------- tests/integration/test_m8_mail.py | 8 ++++---- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/netengine/handlers/mail_handler.py b/netengine/handlers/mail_handler.py index dbd3d32..bfc64fd 100644 --- a/netengine/handlers/mail_handler.py +++ b/netengine/handlers/mail_handler.py @@ -200,11 +200,11 @@ async def _inject_dns_records(self) -> list[str]: orgs_configured = [] # Get all orgs from world registry - if not hasattr(spec, "world_registry") or not spec.world_registry.initial_orgs: + if not hasattr(spec, "world_registry") or not spec.world_registry.organizations: self.logger.warning("No orgs found in spec.world_registry") return orgs_configured - for org_spec in spec.world_registry.initial_orgs: + for org_spec in spec.world_registry.organizations: org_name = org_spec.name org_domain = f"{org_name}.internal" diff --git a/netengine/spec/loader.py b/netengine/spec/loader.py index a21ad8e..0a28b97 100644 --- a/netengine/spec/loader.py +++ b/netengine/spec/loader.py @@ -86,21 +86,6 @@ def _warn_unsupported(spec: NetEngineSpec) -> None: " is not yet implemented — field will be ignored" ) - mail = spec.world_services.mail - if mail.enabled: - policy = mail.mailbox_policy - if policy is not None: - if policy.spf_default: - logger.warning( - "mail.mailbox_policy.spf_default is set but SPF record generation is" - " not yet implemented — field will be ignored" - ) - if policy.dmarc_default: - logger.warning( - "mail.mailbox_policy.dmarc_default is set but DMARC policy is not yet" - " implemented — field will be ignored" - ) - def _cross_validate(spec: NetEngineSpec) -> None: """Cross-field validation not expressible in Pydantic field validators. diff --git a/tests/integration/test_m8_mail.py b/tests/integration/test_m8_mail.py index 49ee2f1..24b28e1 100644 --- a/tests/integration/test_m8_mail.py +++ b/tests/integration/test_m8_mail.py @@ -164,7 +164,7 @@ async def test_m8_deploys_postfix_container(self) -> None: world_services = WorldServicesPhase(mail=mail_config, storage=storage_config) spec = MagicMock() spec.world_services = world_services - spec.world_registry.initial_orgs = [] + spec.world_registry.organizations = [] spec.identity_inworld.org_users = [] phase_context = PhaseContext( @@ -207,7 +207,7 @@ async def test_m8_records_deployment_info(self) -> None: world_services = WorldServicesPhase(mail=mail_config, storage=storage_config) spec = MagicMock() spec.world_services = world_services - spec.world_registry.initial_orgs = [] + spec.world_registry.organizations = [] spec.identity_inworld.org_users = [] phase_context = PhaseContext( @@ -365,7 +365,7 @@ async def test_m8_provisions_mailboxes_for_org_users(self) -> None: world_services = WorldServicesPhase(mail=mail_config, storage=storage_config) spec = MagicMock() spec.world_services = world_services - spec.world_registry.initial_orgs = [] + spec.world_registry.organizations = [] spec.identity_inworld.org_users = [org_users] phase_context = PhaseContext( @@ -512,7 +512,7 @@ async def test_m8_output_contains_required_fields(self) -> None: world_services = WorldServicesPhase(mail=mail_config, storage=storage_config) spec = MagicMock() spec.world_services = world_services - spec.world_registry.initial_orgs = [] + spec.world_registry.organizations = [] spec.identity_inworld.org_users = [] phase_context = PhaseContext(