Skip to content

Commit d43e6b6

Browse files
authored
Attribute preservation, RADb mirroring, and Jira diff output (#2)
* Output registry changes as a Jira-friendly diff block Replaces per-route print statements with a Summary collector that emits a {code:diff} block at the end of each run. Created routes render green (+), deleted routes render red (-), and updated routes appear as neutral context lines. Dry-run mode is indicated in the header and uses the same prefixes to show what would change. Client dry-run return values are now 'dry-run-create', 'dry-run-update', and 'dry-run-delete' so callers can distinguish the intended operation. * Preserve unmanaged attributes on update and mirror RIPE/ARIN changes to RADb - Replace exists-check with full fetch on update so existing unmanaged attributes (remarks, admin-c, tech-c, etc.) are preserved in PUT/update payloads - Add delete_route() to RIPE, ARIN, and RADb clients - Mirror every RIPE and ARIN route change (create/update/delete) to RADb automatically when a radb section is present in the config - Skip RADb-only routes that were already mirrored to avoid double-syncing - Document delete:true flag, automatic mirroring, and attribute preservation in README * Added Mozilla’s Code of Conduct Public repo in one of Mozilla’s GH Orgs should include their Code of Conduct file. * Fix ruff formatting
1 parent 68891c7 commit d43e6b6

10 files changed

Lines changed: 647 additions & 127 deletions

File tree

CODE_OF_CONDUCT.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Community Participation Guidelines
2+
3+
This repository is governed by Mozilla's code of conduct and etiquette guidelines.
4+
For more details, please read the
5+
[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/).
6+
7+
## How to Report
8+
For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page.
9+
10+
<!--
11+
## Project Specific Etiquette
12+
13+
In some cases, there will be additional project etiquette i.e.: (https://bugzilla.mozilla.org/page.cgi?id=etiquette.html).
14+
Please update for your project.
15+
-->

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ radb:
8181
8282
`roas` is optional. If omitted, only route objects are synced. ROA sync only manages prefixes explicitly listed — other ROAs in the account are left untouched.
8383

84+
### Deleting route objects
85+
86+
Set `delete: true` on any route entry to remove it from the registry instead of syncing it:
87+
88+
```yaml
89+
routes:
90+
- prefix: "192.0.2.0/24"
91+
origin: "AS12345"
92+
delete: true
93+
```
94+
95+
### Automatic RADb mirroring
96+
97+
When a `radb` section is present in the config, every RIPE and ARIN route change (create, update, or delete) is automatically mirrored to RADb. You do not need to list the same prefix in the `radb.routes` section — duplicates are skipped automatically.
98+
99+
Routes listed exclusively under `radb.routes` (with no corresponding RIPE/ARIN entry) are still synced to RADb directly.
100+
101+
### Attribute preservation
102+
103+
On update, only fields managed by this tool (`route`/`route6`, `origin`, `mnt-by`, `source`, `changed`, and optionally `descr`) are modified. All other attributes already present in the registry object (e.g. `remarks`, `admin-c`, `tech-c`) are preserved.
104+
84105
## Credentials
85106

86107
Secrets are fetched from 1Password via the `op` CLI. The `credentials` block in the config file specifies the 1Password reference for each secret:

src/rir_updater/arin/client.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,42 +132,63 @@ def list_roas(self) -> list[ROA]:
132132
)
133133
return result
134134

135-
def _route_exists(self, route: RouteObject) -> bool:
135+
def _get_existing_route(self, route: RouteObject) -> str | None:
136136
resp = self._http.get(self._route_url(route), params=self._params())
137-
return resp.status_code == 200
137+
if resp.status_code == 404:
138+
return None
139+
_raise_for_status(resp, f"fetch arin route {route.prefix}")
140+
return resp.text
141+
142+
def _merge_route_body(self, existing_xml: str, route: RouteObject) -> str:
143+
"""Build a PUT body by updating managed fields while preserving the rest."""
144+
root = ET.fromstring(existing_xml)
145+
managed_tags = {
146+
"orgHandle": self._org_handle,
147+
"originAS": route.origin.upper(),
148+
"prefix": route.prefix,
149+
"source": "ARIN",
150+
}
151+
for child in root:
152+
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
153+
if tag in managed_tags:
154+
child.text = managed_tags[tag]
155+
elif tag == "description":
156+
for item in list(child):
157+
child.remove(item)
158+
for i, text in enumerate((route.description or "").splitlines()):
159+
line_el = SubElement(child, f"{{{CORE_NS}}}line")
160+
line_el.set("number", str(i))
161+
line_el.text = text
162+
return ET.tostring(root, encoding="unicode")
138163

139164
def delete_route(self, route: RouteObject) -> str:
140-
"""Delete a route object. Returns 'deleted', 'not-found', or 'dry-run'."""
141-
obj_type = "route6" if ":" in route.prefix else "route"
142-
asn = route.origin.upper()
165+
"""Delete a route object. Returns 'deleted', 'not-found', or 'dry-run-delete'.""" # noqa: E501
143166
if self._dry_run:
144-
print(f"[dry-run] would delete arin {obj_type} {route.prefix} {asn}")
145-
return "dry-run"
167+
return "dry-run-delete"
146168
url = self._route_url(route)
147169
resp = self._http.delete(url, params=self._params())
148170
if resp.status_code == 404:
149171
return "not-found"
172+
asn = route.origin.upper()
150173
_raise_for_status(resp, f"delete arin route {route.prefix} {asn}")
151174
return "deleted"
152175

153176
def sync_route(self, route: RouteObject) -> str:
154-
"""Sync a route object. Returns 'created', 'updated', or 'dry-run'."""
155-
exists = self._route_exists(route)
156-
obj_type = "route6" if ":" in route.prefix else "route"
157-
asn = route.origin.upper()
177+
"""Sync a route object. Returns 'created', 'updated', or a dry-run variant."""
178+
existing = self._get_existing_route(route)
158179

159180
if self._dry_run:
160-
action = "update" if exists else "create"
161-
print(f"[dry-run] would {action} arin {obj_type} {route.prefix} {asn}")
162-
return "dry-run"
181+
return "dry-run-update" if existing is not None else "dry-run-create"
163182

164-
body = self._route_body(route)
165183
url = self._route_url(route)
166-
if exists:
184+
asn = route.origin.upper()
185+
if existing is not None:
186+
body = self._merge_route_body(existing, route)
167187
resp = self._http.put(url, params=self._params(), content=body)
168188
_raise_for_status(resp, f"update arin route {route.prefix} {asn}")
169189
return "updated"
170190
else:
191+
body = self._route_body(route)
171192
# Unlike RIPE, ARIN POST uses the key URL (not the collection URL).
172193
resp = self._http.post(url, params=self._params(), content=body)
173194
_raise_for_status(resp, f"create arin route {route.prefix} {asn}")
@@ -268,10 +289,6 @@ def sync_roas(self, roas: list[ROA]) -> dict[str, int]:
268289
to_delete_handles = [current_managed[k] for k in to_delete_keys]
269290

270291
if self._dry_run:
271-
for prefix, asn, max_len in to_add:
272-
print(f"[dry-run] would add arin ROA {prefix} {asn} max={max_len}")
273-
for prefix, asn, max_len in to_delete_keys:
274-
print(f"[dry-run] would delete arin ROA {prefix} {asn} max={max_len}")
275292
return {"added": len(to_add), "deleted": len(to_delete_keys)}
276293

277294
if not to_add and not to_delete_handles:

src/rir_updater/main.py

Lines changed: 115 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from rir_updater.exceptions import ApiError, CredentialError, RirUpdaterError
1717
from rir_updater.radb.client import RadbClient
1818
from rir_updater.ripe.client import RipeClient
19+
from rir_updater.summary import Summary
1920

2021

2122
def main():
@@ -91,76 +92,125 @@ def should_run(name: str) -> bool:
9192
_setup_arin_ote(config.arin, creds, args.commit)
9293
return
9394

94-
if config.ripe and should_run("ripe"):
95-
creds = config.ripe.credentials
96-
use_test_env = not args.production
97-
# The test DB may have a separate account (RIPE test accounts are distinct
98-
# from production). Fall back to production credentials if not configured.
99-
if use_test_env and creds.test_db_username and creds.test_db_password:
100-
db_auth = get_ripe_db_auth(creds.test_db_username, creds.test_db_password)
101-
else:
102-
db_auth = get_ripe_db_auth(creds.db_username, creds.db_password)
103-
with RipeClient(
104-
db_auth=db_auth,
105-
rpki_key=get_ripe_rpki_key(creds.rpki_api_key),
106-
maintainer=config.ripe.maintainer,
107-
dry_run=not args.commit,
108-
use_test_env=use_test_env,
109-
) as client:
110-
if args.setup_test:
111-
client.setup_test_env(config.ripe.routes, config.ripe.sso_emails)
112-
return
113-
114-
for route in config.ripe.routes:
115-
result = client.sync_route(route)
116-
print(f"{result}: ripe route {route.prefix} {route.origin}")
117-
118-
if config.ripe.roas:
119-
counts = client.sync_roas(config.ripe.roas)
120-
added, deleted = counts["added"], counts["deleted"]
121-
print(f"RIPE ROAs: {added} added, {deleted} deleted")
122-
123-
if config.arin and should_run("arin"):
124-
creds = config.arin.credentials
125-
use_test_env = not args.production
126-
if use_test_env and creds.test_api_key:
127-
arin_api_key = get_arin_api_key(creds.test_api_key)
128-
else:
129-
arin_api_key = get_arin_api_key(creds.api_key)
130-
with ArinClient(
131-
org_handle=config.arin.org_handle,
132-
api_key=arin_api_key,
133-
dry_run=not args.commit,
134-
use_test_env=use_test_env,
135-
) as client:
136-
for route in config.arin.routes:
137-
if route.delete:
138-
result = client.delete_route(route)
139-
else:
140-
result = client.sync_route(route)
141-
print(f"{result}: arin route {route.prefix} {route.origin}")
142-
143-
if config.arin.roas:
144-
counts = client.sync_roas(config.arin.roas)
145-
added, deleted = counts["added"], counts["deleted"]
146-
print(f"ARIN ROAs: {added} added, {deleted} deleted")
147-
148-
if config.radb and should_run("radb"):
149-
creds = config.radb.credentials
150-
portal_username, portal_password = get_radb_portal_auth(
151-
creds.portal_username, creds.portal_password
95+
summary = Summary(dry_run=not args.commit)
96+
mirrored_prefixes: set[str] = set()
97+
98+
# Build RadbClient upfront — used for both explicit RADb routes and
99+
# automatic mirroring of every RIPE/ARIN route change.
100+
radb_client = None
101+
if config.radb:
102+
radb_creds = config.radb.credentials
103+
radb_portal_u, radb_portal_p = get_radb_portal_auth(
104+
radb_creds.portal_username, radb_creds.portal_password
152105
)
153-
with RadbClient(
106+
radb_client = RadbClient(
154107
maintainer=config.radb.maintainer,
155-
portal_username=portal_username,
156-
portal_password=portal_password,
157-
mntner_password=get_radb_mntner_password(creds.mntner_password),
108+
portal_username=radb_portal_u,
109+
portal_password=radb_portal_p,
110+
mntner_password=get_radb_mntner_password(radb_creds.mntner_password),
158111
contact_email=config.radb.contact_email,
159112
dry_run=not args.commit,
160-
) as client:
113+
)
114+
115+
try:
116+
if config.ripe and should_run("ripe"):
117+
label = "RIPE (production)" if args.production else "RIPE (test)"
118+
creds = config.ripe.credentials
119+
use_test_env = not args.production
120+
# The test DB may have a separate account (RIPE test accounts are
121+
# distinct from production). Fall back to production creds if not set.
122+
if use_test_env and creds.test_db_username and creds.test_db_password:
123+
db_auth = get_ripe_db_auth(
124+
creds.test_db_username, creds.test_db_password
125+
)
126+
else:
127+
db_auth = get_ripe_db_auth(creds.db_username, creds.db_password)
128+
with RipeClient(
129+
db_auth=db_auth,
130+
rpki_key=get_ripe_rpki_key(creds.rpki_api_key),
131+
maintainer=config.ripe.maintainer,
132+
dry_run=not args.commit,
133+
use_test_env=use_test_env,
134+
) as client:
135+
if args.setup_test:
136+
client.setup_test_env(config.ripe.routes, config.ripe.sso_emails)
137+
return
138+
139+
summary.start_registry(label)
140+
for route in config.ripe.routes:
141+
if route.delete:
142+
result = client.delete_route(route)
143+
else:
144+
result = client.sync_route(route)
145+
summary.record_route(label, result, route.prefix, route.origin)
146+
if radb_client:
147+
radb_result = (
148+
radb_client.delete_route(route)
149+
if route.delete
150+
else radb_client.sync_route(route)
151+
)
152+
summary.record_route(
153+
"RADb", radb_result, route.prefix, route.origin
154+
)
155+
mirrored_prefixes.add(route.prefix)
156+
157+
if config.ripe.roas:
158+
counts = client.sync_roas(config.ripe.roas)
159+
summary.record_roas(label, counts["added"], counts["deleted"])
160+
161+
if config.arin and should_run("arin"):
162+
label = "ARIN (production)" if args.production else "ARIN (OTE)"
163+
creds = config.arin.credentials
164+
use_test_env = not args.production
165+
if use_test_env and creds.test_api_key:
166+
arin_api_key = get_arin_api_key(creds.test_api_key)
167+
else:
168+
arin_api_key = get_arin_api_key(creds.api_key)
169+
with ArinClient(
170+
org_handle=config.arin.org_handle,
171+
api_key=arin_api_key,
172+
dry_run=not args.commit,
173+
use_test_env=use_test_env,
174+
) as client:
175+
summary.start_registry(label)
176+
for route in config.arin.routes:
177+
if route.delete:
178+
result = client.delete_route(route)
179+
else:
180+
result = client.sync_route(route)
181+
summary.record_route(label, result, route.prefix, route.origin)
182+
if radb_client:
183+
radb_result = (
184+
radb_client.delete_route(route)
185+
if route.delete
186+
else radb_client.sync_route(route)
187+
)
188+
summary.record_route(
189+
"RADb", radb_result, route.prefix, route.origin
190+
)
191+
mirrored_prefixes.add(route.prefix)
192+
193+
if config.arin.roas:
194+
counts = client.sync_roas(config.arin.roas)
195+
summary.record_roas(label, counts["added"], counts["deleted"])
196+
197+
if radb_client and should_run("radb"):
198+
summary.start_registry("RADb")
161199
for route in config.radb.routes:
162-
result = client.sync_route(route)
163-
print(f"{result}: radb route {route.prefix} {route.origin}")
200+
if route.prefix in mirrored_prefixes:
201+
continue # already synced via mirroring
202+
result = (
203+
radb_client.delete_route(route)
204+
if route.delete
205+
else radb_client.sync_route(route)
206+
)
207+
summary.record_route("RADb", result, route.prefix, route.origin)
208+
209+
finally:
210+
if radb_client:
211+
radb_client.close()
212+
213+
summary.print_jira()
164214

165215

166216
def _setup_arin_ote(arin_config, creds, commit: bool) -> None:

0 commit comments

Comments
 (0)