diff --git a/README.md b/README.md
index a9c3a7c..c25e670 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ srxsync check --inventory inv.yaml [--verbose] [--transport {pyez,rustez}]
| Flag | Meaning |
|---|---|
-| `--replace` / `--merge` | Mutually exclusive. `--replace` annotates each category root with `replace="replace"` so Junos wipes slave-specific siblings within synced subtrees. `--merge` only adds/updates. |
+| `--replace` / `--merge` | Mutually exclusive. `--replace` annotates each category root with `replace="replace"` so Junos wipes slave-specific siblings within synced subtrees. `--merge` only adds/updates. Security zones are always merged, never replaced. |
| `--commit-confirmed N` | Commit with an N-minute auto-rollback timer; srxsync calls `commit` again to seal. Default: 5. |
| `--max-parallel N` | Concurrent target workers. Default: 5. |
| `--on-error continue\|abort` | On a per-target failure, continue (exit non-zero at end) or cancel remaining work. Default: continue. |
@@ -133,6 +133,42 @@ ntp:
Then add `ntp` to the `include:` list of whichever targets should receive it.
+## Zone translation
+
+A target can use different zone names than the master. Add a `zone_map`
+(master zone → slave zone) to the target; unlisted zones keep their name.
+
+```yaml
+targets:
+ - host: srx-site-a.example.net
+ auth: { provider: env }
+ include: [objects, policies, nat, zones]
+ zone_map:
+ APP1: APPS
+ APP2: APPS
+ APP3: APPS
+```
+
+- Zone names are rewritten in policies, global policies, NAT rule-sets and
+ address-book `attach`.
+- Policy contexts that collapse into one (`APP1→UNTRUST`, `APP2→UNTRUST` →
+ `APPS→UNTRUST`) are concatenated in master order. A duplicate policy name in
+ a merged context fails that target. `APP1→APP2` becomes `APPS→APPS`.
+- **Zones are never deleted.** `security zones` is always merged, even with
+ `--replace`. A zone fed by several master zones (`APPS`) is pushed as an
+ empty zone: created if missing, otherwise left exactly as the slave has it.
+- srxsync warns (stderr, never fails) when it creates a zone, or a zone exists,
+ with no interfaces — bind one on the slave.
+- Translation is single-pass: `{A: B, B: C}` maps A to B and B to C (no chaining).
+- A warning is also printed when a translated policy references a zone that is
+ missing on the slave and `zones` is not in `include` (the slave commit will fail).
+- A `zone_map` key that isn't a zone on the master prints a warning (likely a
+ typo) and is ignored.
+- `check` only reports zones srxsync manages: zones that exist only on the slave
+ are not drift, and N:1 zones are checked for existence only.
+- Entries in a master zone's *own* address-book are not carried to a merged
+ zone; keep shared objects in a global address-book.
+
## Secrets
Credentials never live in the inventory file. `auth.provider` selects a
diff --git a/docs/superpowers/specs/2026-10-16-zone-translation-design.md b/docs/superpowers/specs/2026-10-16-zone-translation-design.md
new file mode 100644
index 0000000..f6284c1
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-16-zone-translation-design.md
@@ -0,0 +1,164 @@
+# Zone translation — Design Spec
+
+**Date:** 2026-10-16
+**Status:** Approved, ready for implementation planning
+**Issue:** #11 (add zone translation)
+**Branches:** `master` (rustperformance was merged into master in #7, so no separate port)
+
+## Purpose
+
+Master and slave SRXs do not always share a zone layout. The master may have
+`APP1`, `APP2`, `APP3` and `UNTRUST` while a slave has only `APPS` and
+`UNTRUST`. Each target gets a configurable master→slave zone mapping; srxsync
+rewrites every zone reference in the synced config before pushing it.
+
+srxsync runs after every change on the master, so the translation must be
+idempotent and must never delete anything on the slave that the operator
+created there.
+
+## Inventory
+
+New optional per-target key `zone_map`, master zone → slave zone:
+
+```yaml
+targets:
+ - host: srx-site-a.example.net
+ auth: { provider: env }
+ include: [objects, policies, nat, zones]
+ zone_map:
+ APP1: APPS
+ APP2: APPS
+ APP3: APPS
+```
+
+- Zones not listed keep their name (identity). `UNTRUST` above stays `UNTRUST`.
+- `zone_map` must be a mapping of non-empty strings to non-empty strings;
+ anything else is an `InventoryError` at load time.
+- Translation is single-pass, never chained: with `{A: B, B: C}`, master `A`
+ becomes `B` and master `B` becomes `C`.
+- A key that does not exist as a zone on the master is logged as a warning, not
+ an error.
+- `Target` gains `zone_map: dict[str, str]` (empty when absent). No `zone_map`
+ means behaviour is byte-for-byte unchanged.
+
+### Terms
+
+For a target, the **preimage** of a slave zone is the set of master zones that
+translate to it (mapped keys plus an identically named, unmapped master zone).
+
+- **1:1 zone** — preimage has exactly one master zone (identity or rename).
+- **N:1 zone** — preimage has two or more master zones (e.g. `APPS`).
+
+## Translation — `srxsync/zonemap.py`
+
+`ZoneTranslator(zone_map).apply(config) -> etree._Element` returns a translated
+deep copy of the fetched master config; the input is never mutated. The
+orchestrator calls it once per target, before `DiffBuilder`. Errors raise
+`ZoneTranslationError`, which fails only that target.
+
+Zone references rewritten:
+
+| Category | Element | Rule |
+|---|---|---|
+| `policies` | `policy` context `from-zone-name` / `to-zone-name` | Rename, then merge contexts (below) |
+| `policies` | `global/policy/match/from-zone`, `to-zone` | Rename each entry, de-duplicate, keep first-seen order |
+| `nat` | `source`/`destination`/`static` `rule-set/from/zone`, `rule-set/to/zone` | Rename, de-duplicate |
+| `objects` | `address-book/attach/zone` | Rename, de-duplicate |
+| `zones` | `security-zone/name` | 1:1 renamed and kept; N:1 handled below |
+
+### Merging policy contexts
+
+After renaming, two or more contexts can share a `(from, to)` pair — e.g.
+`APP1→UNTRUST`, `APP2→UNTRUST`, `APP3→UNTRUST` all become `APPS→UNTRUST`.
+
+- Contexts are merged into one, its policies concatenated in master document
+ order: all of the first context's policies (internal order kept), then the
+ second's, and so on. Rule order on the slave therefore matches master order.
+- A duplicate policy name within a merged context raises
+ `ZoneTranslationError` naming the policy and both source contexts. No
+ auto-renaming.
+- Contexts between zones that merge (`APP1→APP2`) become intra-zone
+ (`APPS→APPS`) and are kept, merged by the same rule.
+
+## Zones are additive only
+
+srxsync never deletes a zone on a slave.
+
+- The `zones` category is always loaded as merge. `DiffBuilder._mark_replace`
+ never sets `replace="replace"` on `security/zones`, even under `--replace`.
+ Other categories keep their `--replace` behaviour. This also stops
+ `--replace` from wiping zone interface bindings, a side effect of the
+ interface prune that exists today.
+- **1:1 zones** — the translated zone (interfaces pruned as today) is merged
+ onto the slave. A zone the slave lacks is created.
+- **N:1 zones** — only an empty shell, `APPS`,
+ is pushed. The master zones' settings (host-inbound-traffic, screen,
+ zone address-book) are not carried over. In merge mode the shell creates the
+ zone if missing and is a no-op if present, so the slave's own settings for
+ `APPS` are never touched.
+
+Verified 2026-10-16 with `commit check` on lab `vsrx-ci`: zones with no
+interfaces, plus policies and source-NAT rule-sets referencing them, commit
+cleanly. An empty zone does not block a push; it only carries no traffic.
+
+## Empty-zone warning
+
+Before loading, the push fetches the target's `/configuration/security/zones`
+(one extra RPC). For every zone the translated payload references on the slave:
+
+- missing on the slave → `WARNING : creating zone APPS with no interfaces — bind an interface on the slave (set security zones security-zone APPS interfaces )`
+- present with no `interfaces` → same message, "zone APPS exists with no interfaces".
+
+Warnings never fail a target or change the exit code. They are emitted for
+`push`, `push --dry-run` and `check` (check already fetches target zones when
+`zones` is included; otherwise it fetches them for this purpose).
+
+## Results and CLI
+
+- `TargetResult` and `DriftLine` gain `warnings: list[str]` (default empty).
+- The CLI prints each warning on its own line after the summary table, prefixed
+ with `WARNING`, to stderr.
+- `ZoneTranslationError` surfaces as a normal per-target failure (`FAIL` row,
+ `--on-error` honoured).
+
+## Drift detection
+
+`check` translates the source with the same `ZoneTranslator` before comparing.
+
+- Policies, NAT and address-book compare the translated source against the target.
+- 1:1 zones compare content (interfaces pruned, as today).
+- N:1 zones are dropped from the content comparison on both sides; drift is
+ reported only when the zone is missing on the slave.
+
+## Known limitation
+
+Address entries defined in a master zone's own address-book (`security-zone
+APP1 address-book`) are not carried to an N:1 slave zone. A pushed policy that
+references one fails the slave's commit, and commit-confirmed rolls it back.
+The fix is to put shared objects in a global address-book.
+
+## Testing
+
+Unit tests with XML fixtures (no devices):
+
+- `zone_map` inventory validation: absent, valid, wrong types, empty strings
+- identity when `zone_map` is empty — payload unchanged
+- 1:1 rename across policies, global policies, NAT, address-book attach, zones
+- N:1 policy contexts concatenated in master order
+- duplicate policy name in a merged context → `ZoneTranslationError`
+- `APP1→APP2` becomes `APPS→APPS`
+- de-duplication of global-policy, NAT and attach zone lists
+- N:1 zone pushed as an empty shell; master zone settings absent
+- `security/zones` never carries `replace` under `--replace`; other roots do
+- warning generated for missing zone and for an existing zone with no interfaces
+- drift: translated source in sync with a correctly translated target; N:1 zone
+ existence-only
+
+Integration (lab, optional): push a mapped inventory to a lab vSRX and confirm
+the commit and the warning.
+
+## Out of scope
+
+- Merging or templating N:1 zone settings
+- Translating names other than zones (routing instances, interfaces)
+- Deleting slave zones that no longer have a master counterpart
diff --git a/srxsync/cli.py b/srxsync/cli.py
index e34a2cc..4f8e1c1 100644
--- a/srxsync/cli.py
+++ b/srxsync/cli.py
@@ -5,6 +5,7 @@
import argparse
import asyncio
import sys
+from collections.abc import Iterable
from pathlib import Path
from srxsync.categories import CategoryModel
@@ -99,6 +100,7 @@ def _print_push_summary(summary: PushSummary) -> None:
status = "OK" if r.ok else "FAIL"
err = r.error or ""
print(f"{r.host:40s} {status:10s} {r.duration_s:>8.2f}s {err}")
+ _print_warnings(warning for result in summary.results for warning in result.warnings)
def _print_drift_summary(summary: DriftSummary, *, verbose: bool) -> None:
@@ -116,6 +118,13 @@ def _print_drift_summary(summary: DriftSummary, *, verbose: bool) -> None:
if verbose:
for p in line.differing_paths:
print(f" - {p}")
+ _print_warnings(warning for line in summary.reports for warning in line.warnings)
+
+
+def _print_warnings(warnings: Iterable[str]) -> None:
+ """Print each warning to stderr prefixed with ``WARNING ``."""
+ for warning in warnings:
+ print(f"WARNING {warning}", file=sys.stderr)
if __name__ == "__main__":
diff --git a/srxsync/diff.py b/srxsync/diff.py
index c5cd267..1ed71a5 100644
--- a/srxsync/diff.py
+++ b/srxsync/diff.py
@@ -7,6 +7,8 @@
from lxml import etree
+from srxsync.zonemap import ZONES_PATH
+
@dataclass(frozen=True)
class DiffBuilder:
@@ -38,8 +40,13 @@ def _mark_replace(self, out: etree._Element) -> None:
the element at that path in the payload and set replace="replace".
Junos then replaces that subtree wholesale on the target device — the
semantic users expect from --replace.
+
+ security/zones is always merged: srxsync never deletes a slave zone, and
+ replacing it would also wipe the (pruned) interface bindings.
"""
for abs_path in self.paths:
+ if abs_path == ZONES_PATH:
+ continue
rel = abs_path.removeprefix("/configuration/")
for node in out.xpath(rel):
node.set("replace", "replace")
diff --git a/srxsync/drift.py b/srxsync/drift.py
index 360e5f0..0bc1c46 100644
--- a/srxsync/drift.py
+++ b/srxsync/drift.py
@@ -7,6 +7,7 @@
from lxml import etree
from srxsync.diff import DiffBuilder
+from srxsync.zonemap import ZONES_PATH
@dataclass(frozen=True)
@@ -23,11 +24,17 @@ def in_sync(self) -> bool:
class DriftDetector:
paths: list[str]
prune: list[str]
+ merged_zones: frozenset[str] = frozenset()
def diff(self, source: etree._Element, target: etree._Element, host: str = "") -> DriftReport:
builder = DiffBuilder(paths=self.paths, prune=self.prune)
src_scoped = builder.build(source)
tgt_scoped = builder.build(target)
+ missing_merged = self._missing_merged_zones(tgt_scoped)
+ if ZONES_PATH in self.paths:
+ self._drop_slave_only_zones(src_scoped, tgt_scoped)
+ self._drop_merged_zones(src_scoped)
+ self._drop_merged_zones(tgt_scoped)
differing: list[str] = []
for abs_path in self.paths:
@@ -40,10 +47,39 @@ def diff(self, source: etree._Element, target: etree._Element, host: str = "") -
# not be sorted: a reordered-but-identical-set list is drift.
src_nodes = [_canonicalize(n) for n in src_scoped.findall(rel)]
tgt_nodes = [_canonicalize(n) for n in tgt_scoped.findall(rel)]
- if src_nodes != tgt_nodes:
+ zone_missing = abs_path == ZONES_PATH and bool(missing_merged)
+ if zone_missing or src_nodes != tgt_nodes:
differing.append(abs_path)
return DriftReport(host=host, differing_paths=differing)
+ def _missing_merged_zones(self, target_scoped: etree._Element) -> set[str]:
+ """N:1 zones are slave-owned: only their existence on the target is checked."""
+ present = {
+ name.text for name in target_scoped.xpath("security/zones/security-zone/name")
+ }
+ return set(self.merged_zones) - present
+
+ def _drop_slave_only_zones(
+ self, source_scoped: etree._Element, target_scoped: etree._Element
+ ) -> None:
+ """Ignore zones only the target has: push never deletes zones, so they are unfixable."""
+ source_names = {
+ name.text for name in source_scoped.xpath("security/zones/security-zone/name")
+ }
+ for zone in target_scoped.xpath("security/zones/security-zone"):
+ if zone.findtext("name") not in source_names:
+ zone.getparent().remove(zone)
+ if source_scoped.xpath("security/zones"):
+ return
+ for zones in target_scoped.xpath("security/zones[not(*)]"):
+ zones.getparent().remove(zones)
+
+ def _drop_merged_zones(self, scoped: etree._Element) -> None:
+ """Remove merged zones' security-zone elements so their content is not compared."""
+ for zone in scoped.xpath("security/zones/security-zone"):
+ if zone.findtext("name") in self.merged_zones:
+ zone.getparent().remove(zone)
+
def _canonicalize(node: etree._Element) -> bytes:
result = etree.tostring(node, method="c14n2")
diff --git a/srxsync/inventory.py b/srxsync/inventory.py
index 2094a91..98416ed 100644
--- a/srxsync/inventory.py
+++ b/srxsync/inventory.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -31,6 +31,7 @@ class Target:
host: str
auth: Auth
include: list[str]
+ zone_map: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -90,4 +91,26 @@ def _parse_target(raw: dict[str, Any], *, known_categories: set[str]) -> Target:
host=raw["host"],
auth=_parse_auth(raw["auth"]),
include=include,
+ zone_map=_parse_zone_map(raw["host"], raw.get("zone_map")),
)
+
+
+def _parse_zone_map(host: str, raw: Any) -> dict[str, str]:
+ """Validate a target's master→slave zone_map; None or absent means no translation."""
+ if raw is None:
+ return {}
+ if not isinstance(raw, dict):
+ raise InventoryError(f"target {host} zone_map must be a mapping")
+ for master_zone, slave_zone in raw.items():
+ valid = (
+ isinstance(master_zone, str)
+ and isinstance(slave_zone, str)
+ and master_zone
+ and slave_zone
+ )
+ if not valid:
+ raise InventoryError(
+ f"target {host} zone_map entries must be non-empty strings: "
+ f"{master_zone!r}: {slave_zone!r}"
+ )
+ return dict(raw)
diff --git a/srxsync/orchestrator.py b/srxsync/orchestrator.py
index c091fe3..5be815e 100644
--- a/srxsync/orchestrator.py
+++ b/srxsync/orchestrator.py
@@ -17,6 +17,8 @@
from srxsync.results import DriftLine, DriftSummary, PushSummary, TargetResult
from srxsync.secrets import get_secret
from srxsync.transport import PyEZTransport, Transport
+from srxsync.zonemap import ZONES_PATH, ZoneTranslator
+from srxsync.zonewarn import zone_warnings
@dataclass(frozen=True)
@@ -112,15 +114,20 @@ def _push_target(
t = self._tx()
try:
paths, prune = self._cats.resolve(target.include)
+ translator = ZoneTranslator(target.zone_map)
+ translated = translator.apply(source_xml)
secret = get_secret(host=target.host, auth=target.auth)
t.connect(target.host, secret.username, secret.password, ssh_key=secret.ssh_key_path)
- payload = DiffBuilder(paths=paths, prune=list(prune)).build(source_xml, mode=cfg.mode)
+ payload = DiffBuilder(paths=paths, prune=list(prune)).build(translated, mode=cfg.mode)
+ warnings = _unknown_key_warnings(translator, source_xml, target.host)
+ warnings += zone_warnings(payload, t.fetch([ZONES_PATH]), host=target.host)
if cfg.dry_run:
return TargetResult(
host=target.host,
ok=True,
duration_s=time.monotonic() - start,
+ warnings=warnings,
)
t.load(payload, mode=cfg.mode)
@@ -130,6 +137,7 @@ def _push_target(
host=target.host,
ok=True,
duration_s=time.monotonic() - start,
+ warnings=warnings,
)
except Exception as e:
# Deliberately broad: lock, timeout, and secret/Vault failures are
@@ -153,15 +161,26 @@ def _check_target(self, target: Target, source_xml: etree._Element) -> DriftLine
t = self._tx()
try:
paths, prune = self._cats.resolve(target.include)
+ translator = ZoneTranslator(target.zone_map)
+ translated = translator.apply(source_xml)
secret = get_secret(host=target.host, auth=target.auth)
t.connect(target.host, secret.username, secret.password, ssh_key=secret.ssh_key_path)
target_xml = t.fetch(paths)
- detector = DriftDetector(paths=paths, prune=list(prune))
- rep: DriftReport = detector.diff(source_xml, target_xml, host=target.host)
+ zone_view = target_xml if ZONES_PATH in paths else t.fetch([ZONES_PATH])
+ detector = DriftDetector(
+ paths=paths,
+ prune=list(prune),
+ merged_zones=translator.merged_zones(source_xml),
+ )
+ rep: DriftReport = detector.diff(translated, target_xml, host=target.host)
+ payload = DiffBuilder(paths=paths, prune=list(prune)).build(translated)
+ warnings = _unknown_key_warnings(translator, source_xml, target.host)
+ warnings += zone_warnings(payload, zone_view, host=target.host)
return DriftLine(
host=target.host,
in_sync=rep.in_sync,
differing_paths=list(rep.differing_paths),
+ warnings=warnings,
)
except Exception as e:
# See _push_target: lock, timeout, and secret/Vault failures are
@@ -170,3 +189,13 @@ def _check_target(self, target: Target, source_xml: etree._Element) -> DriftLine
finally:
with contextlib.suppress(Exception):
t.close()
+
+
+def _unknown_key_warnings(
+ translator: ZoneTranslator, source_xml: etree._Element, host: str
+) -> list[str]:
+ """Warn about zone_map keys that name no zone on the master (likely typos)."""
+ return [
+ f"{host}: zone_map key {key} is not a zone on the master"
+ for key in translator.unknown_keys(source_xml)
+ ]
diff --git a/srxsync/results.py b/srxsync/results.py
index 4239af1..a6592ff 100644
--- a/srxsync/results.py
+++ b/srxsync/results.py
@@ -11,6 +11,7 @@ class TargetResult:
ok: bool
error: str | None = None
duration_s: float = 0.0
+ warnings: list[str] = field(default_factory=list)
@dataclass(frozen=True)
@@ -28,6 +29,7 @@ class DriftLine:
in_sync: bool
differing_paths: list[str] = field(default_factory=list)
error: str | None = None
+ warnings: list[str] = field(default_factory=list)
@dataclass(frozen=True)
diff --git a/srxsync/zonemap.py b/srxsync/zonemap.py
new file mode 100644
index 0000000..765e741
--- /dev/null
+++ b/srxsync/zonemap.py
@@ -0,0 +1,152 @@
+"""Per-target zone-name translation of a fetched master config."""
+
+from __future__ import annotations
+
+import copy
+from dataclasses import dataclass
+
+from lxml import etree
+
+ZONES_PATH = "/configuration/security/zones"
+
+# Leaf-list zone references: (XPath of the parent, child tag holding a zone name).
+_ZONE_LISTS = (
+ ("security/policies/global/policy/match", "from-zone"),
+ ("security/policies/global/policy/match", "to-zone"),
+ ("security/nat/*/rule-set/from", "zone"),
+ ("security/nat/*/rule-set/to", "zone"),
+)
+
+
+class ZoneTranslationError(ValueError):
+ """Raised when a translated config cannot be built for a target."""
+
+
+@dataclass(frozen=True)
+class ZoneTranslator:
+ """Rewrites master zone names to a target's zone names (master → slave)."""
+
+ zone_map: dict[str, str]
+
+ def translate(self, zone: str) -> str:
+ """Return the slave name for a master zone; unmapped zones keep their name."""
+ return self.zone_map.get(zone, zone)
+
+ def apply(self, config: etree._Element) -> etree._Element:
+ """Return a translated deep copy of ``config``; the input is never mutated."""
+ translated = copy.deepcopy(config)
+ if not self.zone_map:
+ return translated
+ self._rename_zone_lists(translated)
+ self._rename_attach_zones(translated)
+ self._rename_policy_contexts(translated)
+ self._rename_zone_definitions(translated)
+ return translated
+
+ def _rename_zone_lists(self, config: etree._Element) -> None:
+ for parent_xpath, tag in _ZONE_LISTS:
+ for parent in config.xpath(parent_xpath):
+ seen: set[str] = set()
+ for entry in parent.findall(tag):
+ entry.text = self.translate(entry.text or "")
+ if entry.text in seen:
+ parent.remove(entry)
+ continue
+ seen.add(entry.text)
+
+ def _rename_attach_zones(self, config: etree._Element) -> None:
+ for attach in config.xpath("security/address-book/attach"):
+ seen: set[str] = set()
+ for zone in attach.findall("zone"):
+ name = zone.find("name")
+ if name is None:
+ continue
+ name.text = self.translate(name.text or "")
+ if name.text in seen:
+ attach.remove(zone)
+ continue
+ seen.add(name.text)
+
+ def merged_zones(self, config: etree._Element) -> frozenset[str]:
+ """Slave zones fed by two or more master zones (N:1); their settings stay slave-owned."""
+ if not config.xpath("security/zones"):
+ return frozenset()
+ master_zones = set(_master_zone_names(config))
+ preimage_sizes: dict[str, int] = {}
+ for master_zone in self.zone_map:
+ if master_zone not in master_zones:
+ continue
+ slave_zone = self.zone_map[master_zone]
+ preimage_sizes[slave_zone] = preimage_sizes.get(slave_zone, 0) + 1
+ for master_zone in master_zones:
+ if master_zone in self.zone_map:
+ continue
+ if master_zone in preimage_sizes:
+ preimage_sizes[master_zone] += 1
+ return frozenset(zone for zone, size in preimage_sizes.items() if size >= 2)
+
+ def unknown_keys(self, config: etree._Element) -> list[str]:
+ """zone_map keys that are not zones on the master; empty if zones were not fetched."""
+ if not config.xpath("security/zones"):
+ return []
+ master_zones = set(_master_zone_names(config))
+ return [key for key in self.zone_map if key not in master_zones]
+
+ def _rename_policy_contexts(self, config: etree._Element) -> None:
+ """Rename context zones and merge contexts that collapse onto the same pair."""
+ for policies in config.xpath("security/policies"):
+ keepers: dict[tuple[str, str], etree._Element] = {}
+ # rule name -> "FROM->TO" of the master context it came from, per kept pair
+ rule_origins: dict[tuple[str, str], dict[str, str]] = {}
+ for context in policies.findall("policy"):
+ from_node = context.find("from-zone-name")
+ to_node = context.find("to-zone-name")
+ if from_node is None or to_node is None:
+ continue
+ origin = f"{from_node.text}->{to_node.text}"
+ from_node.text = self.translate(from_node.text or "")
+ to_node.text = self.translate(to_node.text or "")
+ pair = (from_node.text, to_node.text)
+ keeper = keepers.get(pair)
+ if keeper is None:
+ keepers[pair] = context
+ rule_origins[pair] = {
+ rule.findtext("name") or "": origin for rule in context.findall("policy")
+ }
+ continue
+ origins = rule_origins[pair]
+ for rule in context.findall("policy"):
+ rule_name = rule.findtext("name") or ""
+ if rule_name in origins:
+ raise ZoneTranslationError(
+ f"duplicate policy {rule_name} in merged context "
+ f"{pair[0]}->{pair[1]}: from {origins[rule_name]} and {origin}"
+ )
+ origins[rule_name] = origin
+ keeper.append(rule)
+ policies.remove(context)
+
+ def _rename_zone_definitions(self, config: etree._Element) -> None:
+ """Rename 1:1 zones; reduce N:1 zones to one empty shell (settings stay slave-owned)."""
+ merged = self.merged_zones(config)
+ for zones in config.xpath("security/zones"):
+ shells_written: set[str] = set()
+ for zone in zones.findall("security-zone"):
+ name = zone.find("name")
+ if name is None:
+ continue
+ slave_name = self.translate(name.text or "")
+ if slave_name not in merged:
+ name.text = slave_name
+ continue
+ if slave_name in shells_written:
+ zones.remove(zone)
+ continue
+ shell = etree.Element("security-zone")
+ etree.SubElement(shell, "name").text = slave_name
+ zones.replace(zone, shell)
+ shells_written.add(slave_name)
+
+
+def _master_zone_names(config: etree._Element) -> list[str]:
+ return [name.text or "" for name in config.xpath("security/zones/security-zone/name")]
diff --git a/srxsync/zonewarn.py b/srxsync/zonewarn.py
new file mode 100644
index 0000000..3865130
--- /dev/null
+++ b/srxsync/zonewarn.py
@@ -0,0 +1,67 @@
+"""Warn about slave zones that srxsync creates or references but that carry no interfaces."""
+
+from __future__ import annotations
+
+from lxml import etree
+
+_SPECIAL_ZONES = frozenset({"junos-host", "any"})
+
+_REFERENCE_XPATHS = (
+ "security/policies/policy/from-zone-name",
+ "security/policies/policy/to-zone-name",
+ "security/policies/global/policy/match/from-zone",
+ "security/policies/global/policy/match/to-zone",
+ "security/nat/*/rule-set/from/zone",
+ "security/nat/*/rule-set/to/zone",
+ "security/address-book/attach/zone/name",
+)
+
+
+def zone_warnings(
+ payload: etree._Element, target_config: etree._Element, host: str
+) -> list[str]:
+ """Return warnings for payload zones that are new, empty, or missing on the target."""
+ defined_in_payload = set(_zone_names(payload))
+ referenced = set(defined_in_payload)
+ for xpath in _REFERENCE_XPATHS:
+ referenced.update(node.text or "" for node in payload.xpath(xpath))
+ referenced -= _SPECIAL_ZONES
+ referenced.discard("")
+
+ target_has_interfaces = {
+ zone.findtext("name") or "": zone.find("interfaces") is not None
+ for zone in target_config.xpath("security/zones/security-zone")
+ }
+
+ warnings: list[str] = []
+ for zone in sorted(referenced):
+ warning = _warning_for(zone, host, target_has_interfaces, defined_in_payload)
+ if warning is not None:
+ warnings.append(warning)
+ return warnings
+
+
+def _warning_for(
+ zone: str,
+ host: str,
+ target_has_interfaces: dict[str, bool],
+ defined_in_payload: set[str],
+) -> str | None:
+ hint = (
+ "bind an interface on the slave "
+ f"(set security zones security-zone {zone} interfaces )"
+ )
+ if zone in target_has_interfaces:
+ if target_has_interfaces[zone]:
+ return None
+ return f"{host}: zone {zone} exists with no interfaces — {hint}"
+ if zone in defined_in_payload:
+ return f"{host}: creating zone {zone} with no interfaces — {hint}"
+ return (
+ f'{host}: zone {zone} is referenced but missing on the slave — add "zones" to include '
+ "or create it"
+ )
+
+
+def _zone_names(config: etree._Element) -> list[str]:
+ return [name.text or "" for name in config.xpath("security/zones/security-zone/name")]
diff --git a/tests/fixtures/configs/zones_master.xml b/tests/fixtures/configs/zones_master.xml
new file mode 100644
index 0000000..5e86100
--- /dev/null
+++ b/tests/fixtures/configs/zones_master.xml
@@ -0,0 +1,80 @@
+
+
+
+ global
+ WEB10.1.0.10/32
+
+ APP1
+ APP2
+
+
+
+
+ APP1
+ ping
+ ge-0/0/1.0
+
+
+ APP2
+ app2-screen
+ ge-0/0/2.0
+
+
+ APP3
+ ge-0/0/3.0
+
+
+ UNTRUST
+ untrust-screen
+ ge-0/0/0.0
+
+
+
+
+ APP1
+ UNTRUST
+ app1-webanyanyjunos-http
+
+
+ APP1
+ APP2
+ app1-to-app2anyanyany
+
+
+ APP2
+ UNTRUST
+ app2-webanyanyjunos-http
+ app2-dnsanyanyjunos-dns-udp
+
+
+ APP3
+ UNTRUST
+ app3-sshanyanyjunos-ssh
+
+
+
+ global-deny-telnet
+ anyanyjunos-telnetAPP1APP2UNTRUST
+
+
+
+
+
+
+
+ apps-out
+ APP1APP2APP3
+ UNTRUST
+ r10.0.0.0/0
+
+
+
+
+ inbound
+ UNTRUST
+ d1203.0.113.10/32
+
+
+
+
+
diff --git a/tests/fixtures/inventory/zone_map.yaml b/tests/fixtures/inventory/zone_map.yaml
new file mode 100644
index 0000000..b2bfb5b
--- /dev/null
+++ b/tests/fixtures/inventory/zone_map.yaml
@@ -0,0 +1,14 @@
+source:
+ host: srx-master.example.net
+ auth: { provider: env }
+targets:
+ - host: srx-a.example.net
+ auth: { provider: env }
+ include: [policies, zones]
+ zone_map:
+ APP1: APPS
+ APP2: APPS
+ APP3: APPS
+ - host: srx-b.example.net
+ auth: { provider: env }
+ include: [policies]
diff --git a/tests/fixtures/inventory/zone_map_bad.yaml b/tests/fixtures/inventory/zone_map_bad.yaml
new file mode 100644
index 0000000..622ac2e
--- /dev/null
+++ b/tests/fixtures/inventory/zone_map_bad.yaml
@@ -0,0 +1,8 @@
+source:
+ host: srx-master.example.net
+ auth: { provider: env }
+targets:
+ - host: srx-a.example.net
+ auth: { provider: env }
+ include: [policies]
+ zone_map: [APP1, APPS]
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 0806e33..4897c87 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -4,7 +4,8 @@
import pytest
-from srxsync.cli import build_parser
+from srxsync.cli import _print_drift_summary, _print_push_summary, build_parser
+from srxsync.results import DriftLine, DriftSummary, PushSummary, TargetResult
def test_push_requires_mode():
@@ -97,3 +98,21 @@ def test_transport_rejects_unknown_value() -> None:
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["push", "--inventory", "inv.yaml", "--merge", "--transport", "grpc"])
+
+
+def test_push_summary_prints_warnings_to_stderr(capsys):
+ summary = PushSummary(
+ results=[TargetResult(host="srx-a", ok=True, warnings=["srx-a: creating zone APPS"])]
+ )
+ _print_push_summary(summary)
+ captured = capsys.readouterr()
+ assert "WARNING srx-a: creating zone APPS" in captured.err
+ assert "WARNING" not in captured.out
+
+
+def test_drift_summary_prints_warnings_to_stderr(capsys):
+ summary = DriftSummary(
+ reports=[DriftLine(host="srx-a", in_sync=True, warnings=["srx-a: zone APPS exists"])]
+ )
+ _print_drift_summary(summary, verbose=False)
+ assert "WARNING srx-a: zone APPS exists" in capsys.readouterr().err
diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py
index aefb4a6..16a9d22 100644
--- a/tests/unit/test_diff.py
+++ b/tests/unit/test_diff.py
@@ -131,3 +131,14 @@ def test_list_category_keeps_every_sibling_entry():
assert len(servers) == 2
names = {s.findtext("name") for s in servers}
assert names == {"8.8.8.8", "1.1.1.1"}
+
+
+def test_replace_mode_never_marks_zones():
+ src = _load()
+ builder = DiffBuilder(
+ paths=["/configuration/security/policies", "/configuration/security/zones"],
+ prune=["security-zone/interfaces"],
+ )
+ out = builder.build(src, mode="replace")
+ assert out.find(".//policies").get("replace") == "replace"
+ assert out.find(".//zones").get("replace") is None
diff --git a/tests/unit/test_drift.py b/tests/unit/test_drift.py
index de0989f..5eca6fd 100644
--- a/tests/unit/test_drift.py
+++ b/tests/unit/test_drift.py
@@ -98,3 +98,58 @@ def test_detects_system_category_drift_granularity():
assert "/configuration/system/name-server" in rep.differing_paths
assert "/configuration/system/syslog" not in rep.differing_paths
assert "/configuration/system/domain-name" not in rep.differing_paths
+
+
+def _zones(*zones: str) -> etree._Element:
+ body = "".join(zones)
+ return etree.fromstring(
+ f"{body}"
+ )
+
+
+ZONES = "/configuration/security/zones"
+
+
+def test_merged_zone_content_ignored():
+ src = _zones("APPS")
+ tgt = _zones("APPSlocal")
+ det = DriftDetector(paths=[ZONES], prune=[], merged_zones=frozenset({"APPS"}))
+ assert det.diff(src, tgt).in_sync is True
+
+
+def test_merged_zone_missing_on_target_is_drift():
+ src = _zones("APPS")
+ tgt = _zones()
+ det = DriftDetector(paths=[ZONES], prune=[], merged_zones=frozenset({"APPS"}))
+ assert det.diff(src, tgt).differing_paths == [ZONES]
+
+
+def test_non_merged_zone_content_still_compared():
+ src = _zones("UNTRUSTa")
+ tgt = _zones("UNTRUSTb")
+ det = DriftDetector(paths=[ZONES], prune=[], merged_zones=frozenset({"APPS"}))
+ assert det.diff(src, tgt).differing_paths == [ZONES]
+
+
+def test_slave_only_zone_is_not_drift():
+ src = _zones("UNTRUST")
+ tgt = _zones(
+ "UNTRUST"
+ "MGMT"
+ )
+ det = DriftDetector(paths=[ZONES], prune=[])
+ assert det.diff(src, tgt).in_sync is True
+
+
+def test_slave_only_zone_ignored_but_shared_zone_drift_counts():
+ src = _zones(
+ "APPS"
+ "UNTRUSTa"
+ )
+ tgt = _zones(
+ "APPSlocal"
+ "UNTRUSTb"
+ "MGMT"
+ )
+ det = DriftDetector(paths=[ZONES], prune=[], merged_zones=frozenset({"APPS"}))
+ assert det.diff(src, tgt).differing_paths == [ZONES]
diff --git a/tests/unit/test_inventory.py b/tests/unit/test_inventory.py
index e9a768c..af0d2f9 100644
--- a/tests/unit/test_inventory.py
+++ b/tests/unit/test_inventory.py
@@ -54,3 +54,34 @@ def test_unknown_category_raises():
def test_missing_file_raises():
with pytest.raises(InventoryError, match="not found"):
load_inventory(FIXTURES / "does_not_exist.yaml", known_categories=KNOWN_CATEGORIES)
+
+
+def test_zone_map_parsed():
+ inv = load_inventory(FIXTURES / "zone_map.yaml", known_categories=KNOWN_CATEGORIES)
+ assert inv.targets[0].zone_map == {"APP1": "APPS", "APP2": "APPS", "APP3": "APPS"}
+
+
+def test_zone_map_defaults_empty():
+ inv = load_inventory(FIXTURES / "zone_map.yaml", known_categories=KNOWN_CATEGORIES)
+ assert inv.targets[1].zone_map == {}
+
+
+def test_zone_map_must_be_mapping():
+ with pytest.raises(InventoryError, match="zone_map must be a mapping"):
+ load_inventory(FIXTURES / "zone_map_bad.yaml", known_categories=KNOWN_CATEGORIES)
+
+
+@pytest.mark.parametrize("bad_map", [{"APP1": ""}, {"": "APPS"}, {"APP1": 5}, {"APP1": None}])
+def test_zone_map_entries_must_be_non_empty_strings(tmp_path, bad_map):
+ import yaml
+
+ doc = {
+ "source": {"host": "m", "auth": {"provider": "env"}},
+ "targets": [
+ {"host": "t", "auth": {"provider": "env"}, "include": ["policies"], "zone_map": bad_map}
+ ],
+ }
+ inv_file = tmp_path / "inv.yaml"
+ inv_file.write_text(yaml.safe_dump(doc))
+ with pytest.raises(InventoryError, match="zone_map entries must be non-empty strings"):
+ load_inventory(inv_file, known_categories=KNOWN_CATEGORIES)
diff --git a/tests/unit/test_zone_orchestrator.py b/tests/unit/test_zone_orchestrator.py
new file mode 100644
index 0000000..dd2ad30
--- /dev/null
+++ b/tests/unit/test_zone_orchestrator.py
@@ -0,0 +1,117 @@
+import asyncio
+from pathlib import Path
+from typing import ClassVar, Literal
+
+import pytest
+from lxml import etree
+
+from srxsync import orchestrator as orchestrator_module
+from srxsync.categories import CategoryModel
+from srxsync.diff import DiffBuilder
+from srxsync.inventory import Auth, Device, Inventory, Target
+from srxsync.orchestrator import Orchestrator, RunConfig
+from srxsync.secrets.base import Secret
+from srxsync.transport.base import Transport
+
+FX = Path(__file__).parent.parent / "fixtures" / "configs"
+APPS_MAP = {"APP1": "APPS", "APP2": "APPS", "APP3": "APPS"}
+SLAVE = (
+ ""
+ "UNTRUSTge-0/0/0.0"
+ ""
+)
+
+
+class FakeTransport(Transport):
+ configs: ClassVar[dict[str, etree._Element]] = {}
+ loaded: ClassVar[dict[str, etree._Element]] = {}
+
+ def connect(
+ self,
+ host: str,
+ username: str,
+ password: str | None = None,
+ ssh_key: str | None = None,
+ port: int = 22,
+ ) -> None:
+ self._host = host
+
+ def fetch(self, paths: list[str]) -> etree._Element:
+ return DiffBuilder(paths=paths, prune=[]).build(self.configs[self._host])
+
+ def load(self, xml: etree._Element, mode: Literal["replace", "merge"]) -> None:
+ self.loaded[self._host] = xml
+
+ def commit_confirmed(self, minutes: int) -> None:
+ pass
+
+ def confirm(self) -> None:
+ pass
+
+ def rollback(self) -> None:
+ pass
+
+ def close(self) -> None:
+ pass
+
+
+@pytest.fixture(autouse=True)
+def _fake_world(monkeypatch):
+ FakeTransport.configs = {
+ "master": etree.parse(str(FX / "zones_master.xml")).getroot(),
+ "slave": etree.fromstring(SLAVE),
+ }
+ FakeTransport.loaded = {}
+ monkeypatch.setattr(orchestrator_module, "get_secret", lambda host, auth: Secret(username="u"))
+
+
+def _orchestrator(zone_map: dict[str, str], include: list[str]) -> Orchestrator:
+ auth = Auth(provider="env")
+ inventory = Inventory(
+ source=Device(host="master", auth=auth),
+ targets=[Target(host="slave", auth=auth, include=include, zone_map=zone_map)],
+ )
+ return Orchestrator(inventory, CategoryModel.default(), transport_factory=FakeTransport)
+
+
+def _run_config(mode: Literal["replace", "merge"] = "replace") -> RunConfig:
+ return RunConfig(mode=mode, commit_confirmed_minutes=1, max_parallel=1, on_error="continue")
+
+
+def test_push_translates_and_warns():
+ summary = asyncio.run(_orchestrator(APPS_MAP, ["policies", "zones"]).push(_run_config()))
+ result = summary.results[0]
+ assert result.ok, result.error
+ payload = FakeTransport.loaded["slave"]
+ contexts = {
+ (ctx.findtext("from-zone-name"), ctx.findtext("to-zone-name"))
+ for ctx in payload.xpath("security/policies/policy")
+ }
+ assert contexts == {("APPS", "UNTRUST"), ("APPS", "APPS")}
+ assert payload.find("security/zones").get("replace") is None
+ assert any("creating zone APPS with no interfaces" in w for w in result.warnings)
+
+
+def test_push_translation_error_fails_only_that_target():
+ master = FakeTransport.configs["master"]
+ master.xpath("security/policies/policy[from-zone-name='APP3']/policy/name")[0].text = "app1-web"
+ summary = asyncio.run(_orchestrator(APPS_MAP, ["policies"]).push(_run_config()))
+ assert summary.results[0].ok is False
+ assert "duplicate policy app1-web" in (summary.results[0].error or "")
+ assert "slave" not in FakeTransport.loaded
+
+
+def test_push_without_zone_map_payload_unchanged():
+ asyncio.run(_orchestrator({}, ["policies"]).push(_run_config("merge")))
+ expected = DiffBuilder(paths=["/configuration/security/policies"], prune=[]).build(
+ FakeTransport.configs["master"]
+ )
+ assert etree.tostring(FakeTransport.loaded["slave"]) == etree.tostring(expected)
+
+
+def test_check_reports_missing_merged_zone_and_warns_unknown_key():
+ zone_map = {**APPS_MAP, "NOPE": "APPS"}
+ summary = asyncio.run(_orchestrator(zone_map, ["zones"]).check(max_parallel=1))
+ line = summary.reports[0]
+ assert "/configuration/security/zones" in line.differing_paths
+ assert "slave: zone_map key NOPE is not a zone on the master" in line.warnings
diff --git a/tests/unit/test_zonemap.py b/tests/unit/test_zonemap.py
new file mode 100644
index 0000000..0e910bd
--- /dev/null
+++ b/tests/unit/test_zonemap.py
@@ -0,0 +1,164 @@
+from pathlib import Path
+
+import pytest
+from lxml import etree
+
+from srxsync.zonemap import ZoneTranslationError, ZoneTranslator
+
+FIXTURE = Path(__file__).parent.parent / "fixtures" / "configs" / "zones_master.xml"
+APPS_MAP = {"APP1": "APPS", "APP2": "APPS", "APP3": "APPS"}
+
+
+def _load() -> etree._Element:
+ return etree.parse(str(FIXTURE)).getroot()
+
+
+def _texts(config: etree._Element, xpath: str) -> list[str]:
+ return [node.text for node in config.xpath(xpath)]
+
+
+def test_empty_map_is_identity():
+ source = _load()
+ translated = ZoneTranslator({}).apply(source)
+ assert etree.tostring(translated) == etree.tostring(source)
+
+
+def test_apply_does_not_mutate_input():
+ source = _load()
+ before = etree.tostring(source)
+ ZoneTranslator(APPS_MAP).apply(source)
+ assert etree.tostring(source) == before
+
+
+def test_translate_unmapped_is_identity():
+ translator = ZoneTranslator(APPS_MAP)
+ assert translator.translate("APP1") == "APPS"
+ assert translator.translate("UNTRUST") == "UNTRUST"
+
+
+def test_single_pass_no_chaining():
+ translator = ZoneTranslator({"A": "B", "B": "C"})
+ assert translator.translate("A") == "B"
+ assert translator.translate("B") == "C"
+
+
+def test_one_to_one_rename_everywhere():
+ translated = ZoneTranslator({"UNTRUST": "OUTSIDE"}).apply(_load())
+ assert "UNTRUST" not in etree.tostring(translated).decode()
+ assert _texts(translated, "security/nat/destination/rule-set/from/zone") == ["OUTSIDE"]
+ assert "OUTSIDE" in _texts(translated, "security/zones/security-zone/name")
+
+
+def test_global_policy_zones_renamed_and_deduped():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ match = "security/policies/global/policy/match"
+ assert _texts(translated, f"{match}/from-zone") == ["APPS"]
+ assert _texts(translated, f"{match}/to-zone") == ["UNTRUST"]
+
+
+def test_nat_zone_lists_renamed_and_deduped():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ assert _texts(translated, "security/nat/source/rule-set/from/zone") == ["APPS"]
+ assert _texts(translated, "security/nat/source/rule-set/to/zone") == ["UNTRUST"]
+
+
+def test_address_book_attach_renamed_and_deduped():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ assert _texts(translated, "security/address-book/attach/zone/name") == ["APPS"]
+
+
+def _context_rules(config: etree._Element, from_zone: str, to_zone: str) -> list[str]:
+ contexts = [
+ ctx
+ for ctx in config.xpath("security/policies/policy")
+ if ctx.findtext("from-zone-name") == from_zone and ctx.findtext("to-zone-name") == to_zone
+ ]
+ assert len(contexts) == 1, f"expected one {from_zone}->{to_zone} context, got {len(contexts)}"
+ return [rule.findtext("name") for rule in contexts[0].findall("policy")]
+
+
+def test_collapsed_contexts_concatenated_in_master_order():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ assert _context_rules(translated, "APPS", "UNTRUST") == [
+ "app1-web",
+ "app2-web",
+ "app2-dns",
+ "app3-ssh",
+ ]
+
+
+def test_contexts_between_merged_zones_become_intra_zone():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ assert _context_rules(translated, "APPS", "APPS") == ["app1-to-app2"]
+
+
+def test_duplicate_rule_name_in_merged_context_raises():
+ source = _load()
+ app3_rule = source.xpath("security/policies/policy[from-zone-name='APP3']/policy/name")[0]
+ app3_rule.text = "app1-web"
+ with pytest.raises(ZoneTranslationError, match=r"app1-web.*APP1->UNTRUST.*APP3->UNTRUST"):
+ ZoneTranslator(APPS_MAP).apply(source)
+
+
+def test_merged_zones():
+ source = _load()
+ assert ZoneTranslator(APPS_MAP).merged_zones(source) == frozenset({"APPS"})
+ assert ZoneTranslator({"UNTRUST": "OUTSIDE"}).merged_zones(source) == frozenset()
+
+
+def test_merged_zones_counts_unmapped_master_zone_with_same_name():
+ source = _load()
+ assert ZoneTranslator({"APP1": "UNTRUST"}).merged_zones(source) == frozenset({"UNTRUST"})
+
+
+def test_n_to_one_zone_becomes_single_empty_shell():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ apps = translated.xpath("security/zones/security-zone[name='APPS']")
+ assert len(apps) == 1
+ assert [child.tag for child in apps[0]] == ["name"]
+
+
+def test_one_to_one_zone_keeps_settings():
+ translated = ZoneTranslator(APPS_MAP).apply(_load())
+ untrust = translated.xpath("security/zones/security-zone[name='UNTRUST']")[0]
+ assert untrust.findtext("screen") == "untrust-screen"
+
+
+def test_unknown_keys():
+ source = _load()
+ translator = ZoneTranslator({"APP1": "APPS", "NOPE": "APPS"})
+ assert translator.unknown_keys(source) == ["NOPE"]
+
+
+def test_unknown_keys_empty_when_zones_not_fetched():
+ config = etree.fromstring("")
+ assert ZoneTranslator({"NOPE": "APPS"}).unknown_keys(config) == []
+
+
+def test_merged_zones_ignores_map_keys_absent_on_master():
+ translator = ZoneTranslator({"APP1": "APPS", "TYPO": "APPS"})
+ assert translator.merged_zones(_load()) == frozenset()
+ translated = translator.apply(_load())
+ apps = [
+ zone
+ for zone in translated.xpath("security/zones/security-zone")
+ if zone.findtext("name") == "APPS"
+ ]
+ assert len(apps) == 1
+ assert apps[0].find("host-inbound-traffic") is not None
+
+
+def test_merged_zones_empty_without_master_zones():
+ config = etree.fromstring("")
+ assert ZoneTranslator(APPS_MAP).merged_zones(config) == frozenset()
+
+
+def test_apply_maps_into_existing_zone_yields_single_empty_shell():
+ translated = ZoneTranslator({"APP1": "UNTRUST"}).apply(_load())
+ untrust = [
+ zone
+ for zone in translated.xpath("security/zones/security-zone")
+ if zone.findtext("name") == "UNTRUST"
+ ]
+ assert len(untrust) == 1
+ assert [child.tag for child in untrust[0]] == ["name"]
diff --git a/tests/unit/test_zonewarn.py b/tests/unit/test_zonewarn.py
new file mode 100644
index 0000000..ea49827
--- /dev/null
+++ b/tests/unit/test_zonewarn.py
@@ -0,0 +1,72 @@
+from lxml import etree
+
+from srxsync.zonewarn import zone_warnings
+
+BIND_HINT = (
+ "bind an interface on the slave "
+ "(set security zones security-zone {zone} interfaces )"
+)
+
+
+def _xml(text: str) -> etree._Element:
+ return etree.fromstring(text)
+
+
+TARGET = _xml(
+ ""
+ "UNTRUST"
+ "ge-0/0/0.0"
+ "EMPTY"
+ ""
+)
+
+
+def test_creating_zone_warns():
+ payload = _xml(
+ ""
+ "APPS"
+ ""
+ )
+ assert zone_warnings(payload, TARGET, host="srx-a") == [
+ f"srx-a: creating zone APPS with no interfaces — {BIND_HINT.format(zone='APPS')}"
+ ]
+
+
+def test_existing_zone_without_interfaces_warns():
+ payload = _xml(
+ ""
+ "EMPTYUNTRUST"
+ ""
+ )
+ assert zone_warnings(payload, TARGET, host="srx-a") == [
+ f"srx-a: zone EMPTY exists with no interfaces — {BIND_HINT.format(zone='EMPTY')}"
+ ]
+
+
+def test_zone_with_interfaces_is_silent():
+ payload = _xml(
+ "rs"
+ "UNTRUST"
+ )
+ assert zone_warnings(payload, TARGET, host="srx-a") == []
+
+
+def test_referenced_but_missing_zone_warns():
+ payload = _xml(
+ "g"
+ "APPSUNTRUST"
+ ""
+ )
+ assert zone_warnings(payload, TARGET, host="srx-a") == [
+ 'srx-a: zone APPS is referenced but missing on the slave — add "zones" to include '
+ "or create it"
+ ]
+
+
+def test_special_zones_ignored():
+ payload = _xml(
+ ""
+ "junos-hostUNTRUST"
+ ""
+ )
+ assert zone_warnings(payload, TARGET, host="srx-a") == []