diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6dab20..a413793f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `host find` now shows IP address(es) for each host. - `host add -contact` is now repeatable, with one contact per flag, so options can be placed before or after the hostname. +### Fixed + +- `naptr_remove` removing all records regardless of the provided options. Now requires all options to match for a record to be removed. + ## [1.9.0](https://github.com/unioslo/mreg-cli/releases/tag/1.9.0) - 2026-04-14 ### Changed diff --git a/ci/testsuite b/ci/testsuite index 570a36ba..50b6186c 100644 --- a/ci/testsuite +++ b/ci/testsuite @@ -315,6 +315,11 @@ host mx_remove baz 10 mail.example.org host naptr_add -name baz -preference 16384 -order 3 -flag u -service "SIP" -regex "[abc]+" -replacement "wonk" host naptr_show baz host naptr_remove -name baz -preference 16384 -order 3 -flag u -service "sip" -regex "[abc]+" -replacement "wonk" +## Add 2 nearly identical records with different services +host naptr_add -name baz -preference 101 -order 4 -flag s -service http+I2R -regex "" -replacement . +host naptr_add -name baz -preference 101 -order 4 -flag s -service ftp+I2R -regex "" -replacement . +host naptr_remove -name baz -preference 101 -order 4 -replacement . # fails, too many matches +host naptr_remove -name baz -preference 101 -order 4 -replacement . -force # PTR host ptr_add 10.0.0.20 baz.example.org host ptr_show 10.0.0.20 diff --git a/ci/testsuite-result.json b/ci/testsuite-result.json index 8a626759..75a8acec 100644 --- a/ci/testsuite-result.json +++ b/ci/testsuite-result.json @@ -27487,6 +27487,349 @@ ], "time": null }, + { + "command": "host naptr_add -name baz -preference 101 -order 4 -flag s -service http+I2R -regex \"\" -replacement .", + "command_filter": null, + "command_filter_negate": false, + "command_issued": "host naptr_add -name baz -preference 101 -order 4 -flag s -service http+I2R -regex \"\" -replacement .", + "ok": [ + "Added NAPTR record to baz.example.org." + ], + "warning": [], + "error": [], + "output": [], + "api_requests": [ + { + "method": "GET", + "url": "/api/v1/hosts/baz.example.org", + "data": {}, + "status": 200, + "response": { + "ipaddresses": [ + { + "macaddress": "11:22:33:aa:bb:cc", + "created_at": "2026-04-24T13:14:13.137107+02:00", + "updated_at": "2026-04-24T13:14:23.189953+02:00", + "ipaddress": "10.0.0.10", + "host": 20 + }, + { + "macaddress": "11:22:33:44:55:67", + "created_at": "2026-04-24T13:14:24.505828+02:00", + "updated_at": "2026-04-24T13:14:27.229134+02:00", + "ipaddress": "2001:db8::14", + "host": 20 + } + ], + "cnames": [], + "mxs": [], + "txts": [ + { + "created_at": "2026-04-24T13:14:22.395738+02:00", + "updated_at": "2026-04-24T13:14:22.395797+02:00", + "txt": "v=spf1 -all", + "host": 20 + } + ], + "ptr_overrides": [], + "srvs": [], + "naptrs": [], + "sshfps": [], + "hostgroups": [], + "roles": [], + "hinfo": null, + "loc": null, + "bacnetid": null, + "communities": [], + "contacts": [], + "created_at": "2026-04-24T13:14:22.378394+02:00", + "updated_at": "2026-04-24T13:14:22.378456+02:00", + "name": "baz.example.org", + "ttl": null, + "comment": "", + "zone": 1, + "contact": "" + } + }, + { + "method": "GET", + "url": "/api/v1/naptrs/?preference=101&order=4&flag=s&service=http%2BI2R®ex=&replacement=.&host=20", + "data": {}, + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + } + }, + { + "method": "POST", + "url": "/api/v1/naptrs/", + "data": { + "preference": 101, + "order": 4, + "flag": "s", + "service": "http+I2R", + "regex": "", + "replacement": ".", + "host": 20 + }, + "status": 201, + "response": { + "created_at": "2026-04-24T13:14:31.810704+02:00", + "updated_at": "2026-04-24T13:14:31.810798+02:00", + "preference": 101, + "order": 4, + "flag": "s", + "service": "http+I2R", + "regex": "", + "replacement": ".", + "host": 20 + } + } + ], + "time": null + }, + { + "command": "host naptr_add -name baz -preference 101 -order 4 -flag s -service ftp+I2R -regex \"\" -replacement .", + "command_filter": null, + "command_filter_negate": false, + "command_issued": "host naptr_add -name baz -preference 101 -order 4 -flag s -service ftp+I2R -regex \"\" -replacement .", + "ok": [ + "Added NAPTR record to baz.example.org." + ], + "warning": [], + "error": [], + "output": [], + "api_requests": [ + { + "method": "GET", + "url": "/api/v1/hosts/baz.example.org", + "data": {}, + "status": 200, + "response": { + "ipaddresses": [ + { + "macaddress": "11:22:33:aa:bb:cc", + "created_at": "2026-04-24T13:14:13.137107+02:00", + "updated_at": "2026-04-24T13:14:23.189953+02:00", + "ipaddress": "10.0.0.10", + "host": 20 + }, + { + "macaddress": "11:22:33:44:55:67", + "created_at": "2026-04-24T13:14:24.505828+02:00", + "updated_at": "2026-04-24T13:14:27.229134+02:00", + "ipaddress": "2001:db8::14", + "host": 20 + } + ], + "cnames": [], + "mxs": [], + "txts": [ + { + "created_at": "2026-04-24T13:14:22.395738+02:00", + "updated_at": "2026-04-24T13:14:22.395797+02:00", + "txt": "v=spf1 -all", + "host": 20 + } + ], + "ptr_overrides": [], + "srvs": [], + "naptrs": [ + { + "created_at": "2026-04-24T13:14:31.810704+02:00", + "updated_at": "2026-04-24T13:14:31.810798+02:00", + "preference": 101, + "order": 4, + "flag": "s", + "service": "http+i2r", + "regex": "", + "replacement": ".", + "host": 20 + } + ], + "sshfps": [], + "hostgroups": [], + "roles": [], + "hinfo": null, + "loc": null, + "bacnetid": null, + "communities": [], + "contacts": [], + "created_at": "2026-04-24T13:14:22.378394+02:00", + "updated_at": "2026-04-24T13:14:22.378456+02:00", + "name": "baz.example.org", + "ttl": null, + "comment": "", + "zone": 1, + "contact": "" + } + }, + { + "method": "GET", + "url": "/api/v1/naptrs/?preference=101&order=4&flag=s&service=ftp%2BI2R®ex=&replacement=.&host=20", + "data": {}, + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + } + }, + { + "method": "POST", + "url": "/api/v1/naptrs/", + "data": { + "preference": 101, + "order": 4, + "flag": "s", + "service": "ftp+I2R", + "regex": "", + "replacement": ".", + "host": 20 + }, + "status": 201, + "response": { + "created_at": "2026-04-24T13:14:32.141394+02:00", + "updated_at": "2026-04-24T13:14:32.141451+02:00", + "preference": 101, + "order": 4, + "flag": "s", + "service": "ftp+I2R", + "regex": "", + "replacement": ".", + "host": 20 + } + } + ], + "time": null + }, + { + "command": "host naptr_remove -name baz -preference 101 -order 4 -replacement .", + "command_filter": null, + "command_filter_negate": false, + "command_issued": "host naptr_remove -name baz -preference 101 -order 4 -replacement . # fails, too many matches", + "ok": [], + "warning": [ + "Use --force to delete all matching records." + ], + "error": [], + "output": [ + "Found multiple matching NAPTR records:", + "NAPTRs: Preference Order Flag Service Regex Replacement ", + " 101 4 s ftp+i2r \"\" . ", + " 101 4 s http+i2r \"\" . " + ], + "api_requests": [ + { + "method": "GET", + "url": "/api/v1/hosts/baz.example.org", + "data": {}, + "status": 200, + "response": { + "ipaddresses": [ + { + "macaddress": "11:22:33:aa:bb:cc", + "created_at": "2026-04-24T13:14:13.137107+02:00", + "updated_at": "2026-04-24T13:14:23.189953+02:00", + "ipaddress": "10.0.0.10", + "host": 20 + }, + { + "macaddress": "11:22:33:44:55:67", + "created_at": "2026-04-24T13:14:24.505828+02:00", + "updated_at": "2026-04-24T13:14:27.229134+02:00", + "ipaddress": "2001:db8::14", + "host": 20 + } + ], + "cnames": [], + "mxs": [], + "txts": [ + { + "created_at": "2026-04-24T13:14:22.395738+02:00", + "updated_at": "2026-04-24T13:14:22.395797+02:00", + "txt": "v=spf1 -all", + "host": 20 + } + ], + "ptr_overrides": [], + "srvs": [], + "naptrs": [ + { + "created_at": "2026-04-24T13:14:32.141394+02:00", + "updated_at": "2026-04-24T13:14:32.141451+02:00", + "preference": 101, + "order": 4, + "flag": "s", + "service": "ftp+i2r", + "regex": "", + "replacement": ".", + "host": 20 + }, + { + "created_at": "2026-04-24T13:14:31.810704+02:00", + "updated_at": "2026-04-24T13:14:31.810798+02:00", + "preference": 101, + "order": 4, + "flag": "s", + "service": "http+i2r", + "regex": "", + "replacement": ".", + "host": 20 + } + ], + "sshfps": [], + "hostgroups": [], + "roles": [], + "hinfo": null, + "loc": null, + "bacnetid": null, + "communities": [], + "contacts": [], + "created_at": "2026-04-24T13:14:22.378394+02:00", + "updated_at": "2026-04-24T13:14:22.378456+02:00", + "name": "baz.example.org", + "ttl": null, + "comment": "", + "zone": 1, + "contact": "" + } + } + ], + "time": null + }, + { + "command": "host naptr_remove -name baz -preference 101 -order 4 -replacement . -force", + "command_filter": null, + "command_filter_negate": false, + "command_issued": "host naptr_remove -name baz -preference 101 -order 4 -replacement . -force", + "ok": [ + "Deleted NAPTR record from baz.example.org.", + "Deleted NAPTR record from baz.example.org." + ], + "warning": [], + "error": [], + "output": [], + "api_requests": [ + { + "method": "DELETE", + "url": "/api/v1/naptrs/3", + "data": {}, + "status": 204 + }, + { + "method": "DELETE", + "url": "/api/v1/naptrs/2", + "data": {}, + "status": 204 + } + ], + "time": null + }, { "command": "host ptr_add 10.0.0.20 baz.example.org", "command_filter": null, diff --git a/mreg_cli/commands/host_submodules/rr.py b/mreg_cli/commands/host_submodules/rr.py index 70ff4e22..23060906 100644 --- a/mreg_cli/commands/host_submodules/rr.py +++ b/mreg_cli/commands/host_submodules/rr.py @@ -65,6 +65,7 @@ ForceMissing, InputFailure, PatchError, + handle_exception, ) from mreg_cli.output.host import ( output_hinfo, @@ -365,6 +366,32 @@ def naptr_add(args: argparse.Namespace) -> None: OutputManager().add_ok(f"Added NAPTR record to {host.name}.") +def filter_naptrs( + naptrs: list[NAPTR], + preference: int, + order: int, + flag: str | None, + service: str | None, + regex: str | None, + replacement: str, +) -> list[NAPTR]: + """Filter NAPTRs, matching on all required fields and any optional fields that are provided.""" + return [ + naptr + for naptr in naptrs + if ( + naptr.preference == preference + and naptr.order == order + and naptr.replacement == replacement + # These 3 fields can be blank, and we need to + # know if we should filter on them or not based on user input + and (flag is None or naptr.flag == flag) + and (service is None or naptr.service == service) + and (regex is None or naptr.regex == regex) + ) + ] + + @command_registry.register_command( prog="naptr_remove", description="Remove matching NAPTR records from a host.", @@ -390,15 +417,15 @@ def naptr_add(args: argparse.Namespace) -> None: required=True, metavar="ORDER", ), - Flag("-flag", description="NAPTR flag.", required=True, metavar="FLAG"), - Flag("-service", description="NAPTR service.", required=True, metavar="SERVICE"), - Flag("-regex", description="NAPTR regexp.", required=True, metavar="REGEXP"), Flag( "-replacement", description="NAPTR replacement.", required=True, metavar="REPLACEMENT", ), + Flag("-flag", description="NAPTR flag.", default=None, metavar="FLAG"), + Flag("-service", description="NAPTR service.", default=None, metavar="SERVICE"), + Flag("-regex", description="NAPTR regexp.", default=None, metavar="REGEXP"), Flag("-force", action="store_true", description="Force deletion for multiple records."), ], ) @@ -408,16 +435,15 @@ def naptr_remove(args: argparse.Namespace) -> None: :param args: argparse.Namespace (name, preference, order, flag, service, regex, replacement) """ host = Host.get_by_any_means_or_raise(args.name) - naptrs = host.naptrs - - to_delete: list[NAPTR] = [] - - for naptr in naptrs: - for attribute in ("preference", "order", "flag", "service", "regex", "replacement"): - if getattr(args, attribute) and getattr(naptr, attribute) != getattr(args, attribute): - break - - to_delete.append(naptr) + to_delete = filter_naptrs( + host.naptrs, + preference=args.preference, + order=args.order, + flag=args.flag, + service=args.service, + regex=args.regex, + replacement=args.replacement, + ) if not to_delete: raise EntityNotFound(f"No matching NAPTR record found for {host}") @@ -429,11 +455,13 @@ def naptr_remove(args: argparse.Namespace) -> None: # This should ideally be done in a transaction, but the API doesn't support it. # Right now we may end up in a situation where some records are deleted and some are not. + # Best-effort lets us delete as many as possible at the very least. for naptr in to_delete: - if naptr.delete(): + try: + naptr.delete() OutputManager().add_ok(f"Deleted NAPTR record from {host.name}.") - else: - raise DeleteError(f"Failed to remove NAPTR for {host}") + except Exception as e: + handle_exception(e) @command_registry.register_command( diff --git a/tests/commands/test_host.py b/tests/commands/test_host.py index 8ab1f44a..3a4510da 100644 --- a/tests/commands/test_host.py +++ b/tests/commands/test_host.py @@ -83,6 +83,9 @@ def test_override_from_string() -> None: preference=1, order=1, replacement="naptr.example.com", + flag="U", + service="SIP+D2U", + regex="", ), "naptr.example.com", ), diff --git a/tests/commands/test_rr.py b/tests/commands/test_rr.py new file mode 100644 index 00000000..8286ff0e --- /dev/null +++ b/tests/commands/test_rr.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import datetime + +import pytest +from mreg_api.models import NAPTR + +from mreg_cli.commands.host_submodules.rr import filter_naptrs + +_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0) +_updated_at = datetime.datetime(2025, 1, 1, 0, 0, 0) + +# Overlap matrix (field -> ids sharing same value): +# host: 123=[1,2,3] 456=[4,5] 789=[6] 999=[7,8,9,10] +# preference: 10=[1,2,3] 20=[4,5] 30=[6] 40=[7,8,9,10] +# order: 20=[1,2,5] 10=[4,6] 30=[3] 15=[7,8,9,10] +# flag: "U"=[1,2,4,7,9,10] "S"=[3,5,8] ""=[6] +# service: "SIP+D2U"=[1,2,5] "E2U+SIP"=[3,4] ""=[6] "X"=[7,8,10] "Y"=[9] +# regex: ""=[1,3,4,6] "!^.*$!..."=[2,5] "r1"=[7,8,9] "r2"=[10] +# replacement: "multi.example.com"=[7,8,9,10] +_naptr1 = NAPTR( + id=1, + host=123, + preference=10, + order=20, + flag="U", + service="SIP+D2U", + regex="", + replacement="naptr1.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr2 = NAPTR( + id=2, + host=123, + preference=10, + order=20, + flag="U", + service="SIP+D2U", + regex="!^.*$!sip:info@example.com!", + replacement="naptr2.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr3 = NAPTR( + id=3, + host=123, + preference=10, + order=30, + flag="S", + service="E2U+SIP", + regex="", + replacement="naptr3.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr4 = NAPTR( + id=4, + host=456, + preference=20, + order=10, + flag="U", + service="E2U+SIP", + regex="", + replacement="naptr4.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr5 = NAPTR( + id=5, + host=456, + preference=20, + order=20, + flag="S", + service="SIP+D2U", + regex="!^.*$!sip:info@example.com!", + replacement="naptr5.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr6 = NAPTR( + id=6, + host=789, + preference=30, + order=10, + flag="", + service="", + regex="", + replacement="naptr6.example.com", + created_at=_created_at, + updated_at=_updated_at, +) + +# NAPTRs 7-10 share preference/order/replacement to exercise multi-match via optional fields. +# flag varies: 7,9,10="U" 8="S" +# service varies: 7,8,10="X" 9="Y" +# regex varies: 7,8,9="r1" 10="r2" +_naptr7 = NAPTR( + id=7, + host=999, + preference=40, + order=15, + flag="U", + service="X", + regex="r1", + replacement="multi.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr8 = NAPTR( + id=8, + host=999, + preference=40, + order=15, + flag="S", + service="X", + regex="r1", + replacement="multi.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr9 = NAPTR( + id=9, + host=999, + preference=40, + order=15, + flag="U", + service="Y", + regex="r1", + replacement="multi.example.com", + created_at=_created_at, + updated_at=_updated_at, +) +_naptr10 = NAPTR( + id=10, + host=999, + preference=40, + order=15, + flag="U", + service="X", + regex="r2", + replacement="multi.example.com", + created_at=_created_at, + updated_at=_updated_at, +) + +naptrs = [ + _naptr1, + _naptr2, + _naptr3, + _naptr4, + _naptr5, + _naptr6, + _naptr7, + _naptr8, + _naptr9, + _naptr10, +] + + +@pytest.mark.parametrize( + "preference,order,flag,service,regex,replacement,expected", + [ + (10, 20, "U", "SIP+D2U", "", "naptr1.example.com", [_naptr1]), + (10, 20, "U", "SIP+D2U", "!^.*$!sip:info@example.com!", "naptr2.example.com", [_naptr2]), + (10, 30, "S", "E2U+SIP", "", "naptr3.example.com", [_naptr3]), + (20, 10, "U", "E2U+SIP", "", "naptr4.example.com", [_naptr4]), + (20, 20, "S", "SIP+D2U", "!^.*$!sip:info@example.com!", "naptr5.example.com", [_naptr5]), + (30, 10, "", "", "", "naptr6.example.com", [_naptr6]), + (40, 15, "U", "X", "r1", "multi.example.com", [_naptr7]), + (10, 20, "U", "SIP+D2U", "", "naptr-nonexistent.example.com", []), + (99, 20, "U", "SIP+D2U", "", "naptr1.example.com", []), + ], +) +def test_filter_naptrs_single( + preference: int, + order: int, + flag: str | None, + service: str | None, + regex: str | None, + replacement: str, + expected: list[NAPTR], +) -> None: + assert filter_naptrs(naptrs, preference, order, flag, service, regex, replacement) == expected + + +@pytest.mark.parametrize( + "preference,order,flag,service,regex,replacement,expected", + [ + (40, 15, None, "X", "r1", "multi.example.com", [_naptr7, _naptr8]), + (40, 15, "U", None, "r1", "multi.example.com", [_naptr7, _naptr9]), + (40, 15, "U", "X", None, "multi.example.com", [_naptr7, _naptr10]), + (40, 15, None, None, None, "multi.example.com", [_naptr7, _naptr8, _naptr9, _naptr10]), + ], +) +def test_filter_naptrs_multi( + preference: int, + order: int, + flag: str | None, + service: str | None, + regex: str | None, + replacement: str, + expected: list[NAPTR], +) -> None: + assert filter_naptrs(naptrs, preference, order, flag, service, regex, replacement) == expected