mirror of
https://github.com/isc-projects/bind9.git
synced 2026-06-10 08:49:58 -04:00
Create zone setup helpers in isctest.zone
System tests that set up zones — especially DNSSEC tests — require a
chain of common operations: rendering zone files from templates,
generating keys, signing, and propagating DS records to parent zones.
Implement these as methods on isctest.zone.Zone so individual tests
don't need to repeat the logic in shell or ad-hoc Python.
isctest.zone.Zone is a plain class that holds the zone's data and
accumulated state (delegations, keys) alongside the methods that operate
on it. It is intentionally separate from isctest.template.Zone, which
remains a dumb data container for jinja2 template rendering.
Key design points:
- zone.Zone.name is the text form without trailing dot ("." for root);
zone.Zone.dname holds the dns.name.Name for DNS-level operations;
zone.Zone.basename is the filesystem-safe name ("root" for ".").
- filepath_unsigned / filepath_signed are both always available.
filepath returns the appropriate one based on zone.Zone.signed.
- The zones/ subdirectory is the default (subdir="zones"); old-style
tests that place zone files directly in the ns workdir can pass
subdir=None.
- Signing is opt-in via signed=True; configure() auto-detects whether to
generate keys and sign based on this flag, so the same method handles
both signed and unsigned zones.
- delegations and keys are mutable list attributes; callers append to
them before calling configure() rather than threading them through
every call.
Also:
- Add isctest.template.zones() as a bridge from a list of zone.Zone to a
{name: template.Zone} dict suitable for use as the ``zones`` template
variable. template.zones() resolves filepath to the actual zone file
so templates don't need to know whether a zone is signed.
Assisted-by: Claude:claude-opus-4-8
This commit is contained in:
parent
af661f06d0
commit
7853dbac43
4 changed files with 196 additions and 1 deletions
|
|
@ -318,10 +318,12 @@ def logger(request, system_test_name):
|
|||
def expected_artifacts(request):
|
||||
common_artifacts = [
|
||||
".libs/*", # possible build artifacts, see GL #5055
|
||||
"ns*/keys",
|
||||
"ns*/named*.conf",
|
||||
"ns*/named.memstats",
|
||||
"ns*/named.run",
|
||||
"ns*/named.run.prev",
|
||||
"ns*/zones",
|
||||
"core.[0-9]*-backtrace.txt",
|
||||
"core.[0-9]*.gz",
|
||||
"pytest.log.txt",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from . import ( # pylint: disable=redefined-builtin
|
|||
template,
|
||||
transfer,
|
||||
vars,
|
||||
zone,
|
||||
)
|
||||
|
||||
# isctest.mark module is intentionally NOT imported, because it relies on
|
||||
|
|
@ -38,4 +39,5 @@ __all__ = [
|
|||
"template",
|
||||
"transfer",
|
||||
"vars",
|
||||
"zone",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from re import compile as Re
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import re
|
||||
|
||||
|
|
@ -23,6 +23,9 @@ import jinja2
|
|||
from .log import debug
|
||||
from .vars import ALL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .zone import Zone as _SetupZone
|
||||
|
||||
NS_DIR_RE = Re(r"^(a?ns([0-9]+))/")
|
||||
|
||||
|
||||
|
|
@ -163,3 +166,17 @@ class TrustAnchor:
|
|||
domain: str
|
||||
type: str
|
||||
contents: str
|
||||
|
||||
|
||||
def zones(zone_list: "list[_SetupZone]") -> dict[str, Zone]:
|
||||
"""
|
||||
Convert a list of zone.Zone instances to a {name: Zone} dict for templates.
|
||||
|
||||
The returned dict maps zone names to plain template Zone instances, suitable
|
||||
for use as the ``zones`` variable in jinja2 templates. The ``filepath`` of
|
||||
each template zone is set to the actual zone file (signed or unsigned).
|
||||
"""
|
||||
return {
|
||||
z.name: Zone(name=z.name, ns=z.ns, type=z.type, filepath=z.filepath)
|
||||
for z in zone_list
|
||||
}
|
||||
|
|
|
|||
174
bin/tests/system/isctest/zone.py
Normal file
174
bin/tests/system/isctest/zone.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
#
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
#
|
||||
# See the COPYRIGHT file distributed with this work for additional
|
||||
# information regarding copyright ownership.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import shutil
|
||||
|
||||
import dns.name
|
||||
|
||||
from .kasp import Key
|
||||
from .log import debug
|
||||
from .run import EnvCmd
|
||||
from .template import NS1, Nameserver, TemplateEngine, TrustAnchor
|
||||
from .vars.algorithms import Algorithm
|
||||
|
||||
KEYDIR = "keys"
|
||||
|
||||
|
||||
class Zone:
|
||||
"""
|
||||
Zone providing zone file setup and signing operations.
|
||||
|
||||
This is the operational counterpart to isctest.template.Zone, which is a
|
||||
plain data container for template rendering. Use isctest.template.zones()
|
||||
to convert a list of Zone instances into template data.
|
||||
|
||||
The normal entrypoint is configure(), which runs the full setup once.
|
||||
The individual steps (copy_dssets, add_keys, render, sign) are public so
|
||||
that tests needing finer-grained control can drive them directly, but they
|
||||
are order-dependent and write to the same on-disk locations as configure().
|
||||
Pick one or the other for a given Zone: calling a step method and
|
||||
configure() on the same Zone leaves the zone directory in an inconsistent
|
||||
state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | dns.name.Name,
|
||||
ns: Nameserver,
|
||||
signed: bool = False,
|
||||
subdir: str | None = "zones",
|
||||
filepath_unsigned: Path | str | None = None,
|
||||
filepath_signed: Path | str | None = None,
|
||||
zone_type: str = "primary",
|
||||
) -> None:
|
||||
self.dname: dns.name.Name = (
|
||||
dns.name.from_text(name) if isinstance(name, str) else name
|
||||
)
|
||||
raw = self.dname.to_text()
|
||||
self.name: str = raw if raw == "." else raw.rstrip(".")
|
||||
self.basename: str = "root" if self.dname == dns.name.root else self.name
|
||||
self.ns = ns
|
||||
self.signed = signed
|
||||
self.subdir = subdir
|
||||
self.type = zone_type
|
||||
self._configured = False
|
||||
|
||||
prefix = f"{subdir}/" if subdir else ""
|
||||
self.filepath_unsigned: Path = Path(
|
||||
filepath_unsigned or f"{prefix}{self.basename}.db"
|
||||
)
|
||||
self.filepath_signed: Path = Path(
|
||||
filepath_signed or f"{prefix}{self.basename}.db.signed"
|
||||
)
|
||||
|
||||
self.delegations: list[Zone] = []
|
||||
self.keys: list[Key] = []
|
||||
|
||||
@property
|
||||
def filepath(self) -> Path:
|
||||
"""Actual zone file — filepath_signed if signed, filepath_unsigned otherwise."""
|
||||
return self.filepath_signed if self.signed else self.filepath_unsigned
|
||||
|
||||
def make_key(self, params: str = "", alg: Algorithm | None = None) -> Key:
|
||||
"""Generate a DNSSEC key and return it without adding it to self.keys."""
|
||||
debug(f"{self.name}: generating key")
|
||||
keydir = Path(self.ns.name) / KEYDIR
|
||||
keydir.mkdir(exist_ok=True)
|
||||
if alg is None:
|
||||
alg = Algorithm.default()
|
||||
keygen = EnvCmd(
|
||||
"KEYGEN", f"-q -a {alg.number} -b {alg.bits} -K {KEYDIR} -L 3600"
|
||||
)
|
||||
key_name = keygen(f"{params} {self.name}", cwd=self.ns.name).out.strip()
|
||||
return Key(key_name, keydir=keydir)
|
||||
|
||||
def add_keys(self, ksk=True, zsk=True) -> None:
|
||||
"""Generate KSK and/or ZSK and append both to self.keys."""
|
||||
if ksk:
|
||||
self.keys.append(self.make_key("-f KSK"))
|
||||
if zsk:
|
||||
self.keys.append(self.make_key())
|
||||
|
||||
def copy_dssets(self) -> None:
|
||||
"""Copy dsset-* files from each delegation's ns dir into self.ns dir."""
|
||||
for zone in self.delegations:
|
||||
try:
|
||||
shutil.copy(f"{zone.ns.name}/dsset-{zone.name}.", self.ns.name)
|
||||
except FileNotFoundError:
|
||||
debug(f"{zone.name}: delegation is insecure (no dsset found)")
|
||||
else:
|
||||
debug(f"{zone.name}: delegation is secure")
|
||||
|
||||
def render(self, template: str | None = None) -> None:
|
||||
"""Render the unsigned zone file from a jinja2 template."""
|
||||
debug(f"{self.name}: rendering zone file")
|
||||
templates = TemplateEngine(".")
|
||||
output = Path(self.ns.name) / self.filepath_unsigned
|
||||
output.parent.mkdir(exist_ok=True)
|
||||
|
||||
if template is None:
|
||||
local = f"{output}.j2.manual"
|
||||
common = "_common/zones/template.db.j2.manual"
|
||||
template = local if Path(local).is_file() else common
|
||||
|
||||
data = {
|
||||
"zone": self,
|
||||
"delegations": self.delegations,
|
||||
}
|
||||
templates.render(str(output), data, template=template)
|
||||
|
||||
def sign(self, params: str = "") -> None:
|
||||
"""Sign the rendered zone file. Requires self.signed == True."""
|
||||
assert self.signed, f"{self.name}: zone is not configured for signing"
|
||||
debug(f"{self.name}: signing zone")
|
||||
signer = EnvCmd("SIGNER", f"-S -g -K {KEYDIR} {params}")
|
||||
signer(
|
||||
f"-P -x -O full -o {self.name}"
|
||||
f" -f {self.filepath_signed} {self.filepath_unsigned}",
|
||||
cwd=self.ns.name,
|
||||
)
|
||||
|
||||
def configure(self, template: str | None = None, sign_params: str = "") -> None:
|
||||
"""
|
||||
Perform full zone setup: copy DS sets, generate keys, render, sign.
|
||||
|
||||
This is the standard single-call entrypoint and may be called only once
|
||||
per Zone. Use the individual step methods directly only when a test
|
||||
needs finer-grained control, and do not mix them with configure() on the
|
||||
same Zone (see the class docstring).
|
||||
"""
|
||||
assert not self._configured, f"{self.name}: configure() already called"
|
||||
self._configured = True
|
||||
self.copy_dssets()
|
||||
if self.signed:
|
||||
self.add_keys()
|
||||
self.render(template)
|
||||
if self.signed:
|
||||
self.sign(sign_params)
|
||||
|
||||
def trust_anchor(
|
||||
self, type: str = "static-ds" # pylint: disable=redefined-builtin
|
||||
) -> TrustAnchor:
|
||||
assert self.keys, "no zone keys configured"
|
||||
return self.keys[0].into_ta(type)
|
||||
|
||||
|
||||
def configure_root(
|
||||
delegations: list[Zone],
|
||||
ns: Nameserver = NS1,
|
||||
signed: bool = True,
|
||||
) -> Zone:
|
||||
zone = Zone(".", ns, signed=signed)
|
||||
zone.delegations = delegations
|
||||
zone.configure(template="_common/zones/root.db.j2.manual")
|
||||
return zone
|
||||
Loading…
Reference in a new issue