diff --git a/.github/workflows/dhcp-check.yaml b/.github/workflows/dhcp-check.yaml new file mode 100644 index 0000000000..2026c6b899 --- /dev/null +++ b/.github/workflows/dhcp-check.yaml @@ -0,0 +1,74 @@ +--- +name: "DHCP Pool Validation" +permissions: + contents: "read" + +"on": + pull_request: + paths: + - "hieradata/node/**/*.yaml" + - "hieradata/node/**/*.yml" + - "utils/dhcp-check.py" + push: + branches: + - "production" + paths: + - "hieradata/node/**/*.yaml" + - "hieradata/node/**/*.yml" + - "utils/dhcp-check.py" + +jobs: + validate-dhcp-pools: + runs-on: "ubuntu-latest" + name: "Validate DHCP Pool Configurations" + + steps: + - name: "Checkout Repository" + uses: "actions/checkout@v4" + + - name: "Set up Python" + uses: "actions/setup-python@v4" + with: + python-version: "3.11" + + - name: "Install Python Dependencies" + run: | + python -m pip install --upgrade pip + if [ -f utils/requirements-dhcp.txt ]; then + pip install -r utils/requirements-dhcp.txt + else + pip install PyYAML + fi + + - name: "Run DHCP Pool Validation" + run: | + python utils/dhcp-check.py + env: + PYTHONPATH: "${{ github.workspace }}" + + - name: "Check for DHCP Pool Changes" + if: "github.event_name == 'pull_request'" + run: | + echo "## DHCP Pool Validation Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Get list of changed DHCP-related files + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD | grep -E 'hieradata/node/.*\.ya?ml$' | head -10) + + if [ -z "$CHANGED_FILES" ]; then + echo "āœ… No DHCP configuration files were modified in this PR." >> $GITHUB_STEP_SUMMARY + else + echo "šŸ“ **Modified DHCP Configuration Files:**" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CHANGED_FILES" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "All files have been validated and passed DHCP pool checks āœ…" >> $GITHUB_STEP_SUMMARY + fi + + - name: "Validation Success" + run: | + echo "šŸŽ‰ All DHCP pool configurations are valid!" + echo "āœ… Network configurations meet ISC DHCPD requirements" + echo "āœ… No subnet/mask conflicts detected" + echo "āœ… Gateway and range validations passed" diff --git a/utils/README-dhcp-check.md b/utils/README-dhcp-check.md new file mode 100644 index 0000000000..042938c09f --- /dev/null +++ b/utils/README-dhcp-check.md @@ -0,0 +1,181 @@ +# DHCP Pool Checker + +A validation tool for DHCP pools defined in Hiera YAML files (Foreman/Puppet configuration management). This tool prevents invalid subnet/mask configurations from causing failures in ISC DHCPD by validating network configurations prior to deployment. + +## Overview + +The `dhcp-check.py` script validates DHCP pool configurations in Puppet/Foreman Hiera YAML files to ensure: + +- Valid network and mask combinations +- Correct gateway configurations (when present) +- Valid IP ranges within subnet boundaries +- No conflicts with network or broadcast addresses + +## Features + +- **Network Validation**: Ensures each `dhcp::pools` entry contains valid network and mask combinations +- **Gateway Validation**: Validates that gateway IP addresses are within the subnet and do not conflict with network or broadcast addresses +- **Range Validation**: Verifies that all IP ranges are within subnet boundaries and properly ordered +- **Configurable Warning System**: Provides configurable handling of missing gateway configurations +- **YAML Anchor Resolution**: Automatically resolves YAML files with duplicate anchor issues +- **CI/CD Integration**: Designed for automated validation within GitHub Actions workflows + +## Installation & Usage + +### Prerequisites + +```bash +pip install pyyaml +``` + +### Basic Usage + +```bash +# Validate all DHCP pools in the default directory (hieradata/node) +python utils/dhcp-check.py + +# Specify custom directory +python utils/dhcp-check.py --dir hieradata/node + +# Require gateways (treat missing gateway as an error) +python utils/dhcp-check.py --require-gateway + +# Suppress gateway warnings +python utils/dhcp-check.py --no-warn-missing-gateway + +# Strict mode (treat all warnings as errors) +python utils/dhcp-check.py --strict +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--dir PATH` | Directory containing node YAML files (default: `hieradata/node`) | +| `--require-gateway` | Treat missing gateway as an error (fail validation) | +| `--no-warn-missing-gateway` | Suppress warnings when the gateway is missing | +| `--strict` | Treat warnings as errors (fail on any warnings) | + +## Exit Codes + +- **0**: No errors found (warnings do not affect exit code unless `--strict` is specified) +- **1**: Validation errors found (invalid subnet, invalid range, invalid gateway) +- **2**: Usage or environment errors (missing directory, invalid arguments) + +## Example Output + +### Successful Validation + +![Successful validation](image-ok.png) + +```bash +ā„¹ļø hieradata/node/example1.yaml: no dhcp::pools, skipping. +āœ”ļø hieradata/node/dhcp-server.yaml: 3 pool(s) validated OK. +āš ļø hieradata/node/dhcp-server.yaml [guest_network]: gateway is missing (warning only) + +āœ… All DHCP pools are valid. +``` + +### Validation Errors + +![Validation errors](image-wrong.png) + +```bash +āŒ Errors: + - hieradata/node/bad-server.yaml [main_pool]: network 192.168.1.5 is not the subnet base (should be 192.168.1.0/24) + - hieradata/node/bad-server.yaml [guest_pool]: range 192.168.2.1-192.168.2.300 not inside 192.168.2.0/24 + - hieradata/node/bad-server.yaml [admin_pool]: gateway 10.0.0.255 is network/broadcast address +``` + +## YAML Configuration Format + +The tool validates `dhcp::pools` sections in your Hiera YAML files: + +```yaml +dhcp::pools: + main_network: + network: "192.168.1.0" + mask: "255.255.255.0" + gateway: "192.168.1.1" + range: + - "192.168.1.10 192.168.1.100" + - "192.168.1.150 192.168.1.200" + + guest_network: + network: "10.0.0.0" + mask: "255.255.255.0" + # gateway is optional + range: + - "10.0.0.50 10.0.0.150" +``` + +## Validation Rules + +### Network & Mask + +- Network address must be a valid IPv4 address +- Mask must be a valid subnet mask in dotted decimal notation +- Network must be the subnet base address (not a host address) + +### Gateway (Optional) + +- If present, must be a valid IPv4 address +- Must be within the subnet range +- Cannot be a network or broadcast address +- If missing, generates a warning (unless `--no-warn-missing-gateway` is specified) + +### Ranges + +- Must be valid IPv4 addresses +- Start address must be ≤ end address +- Both addresses must be within the subnet +- Cannot include network or broadcast addresses + +## Troubleshooting + +### YAML Anchor Issues + +The tool automatically handles YAML files with duplicate anchor issues by: + +1. Detecting duplicate anchor errors +1. Stripping anchor definitions and references +1. Re-parsing the cleaned YAML for validation + +### Common Errors + +- **"network X is not the subnet base"**: Use the actual network address (e.g., 192.168.1.0, not 192.168.1.5) +- **"range X-Y not inside subnet"**: Ensure all IP ranges fall within the network/mask boundaries +- **"gateway is network/broadcast address"**: Select a gateway IP address that is not the network or broadcast address (e.g., not .0 or .255 for /24 networks) + +## Integration + +### GitHub Actions + +See `.github/workflows/dhcp-check.yaml` for automated validation on pull requests. + +### Pre-commit Hooks + +Add to your `.pre-commit-config.yaml`: + +```yaml +repos: + - repo: local + hooks: + - id: dhcp-check + name: DHCP Pool Validation + entry: python utils/dhcp-check.py + language: system + pass_filenames: false +``` + +## Contributing + +When modifying DHCP configurations, follow these steps: + +1. Run `python utils/dhcp-check.py` locally before committing +1. Address any validation errors or warnings +1. The GitHub workflow will automatically validate changes on PR + +## License + +This tool is part of the LSST control system and follows the same licensing terms. diff --git a/utils/dhcp-check.py b/utils/dhcp-check.py new file mode 100644 index 0000000000..b856acf130 --- /dev/null +++ b/utils/dhcp-check.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +DHCP Pool Checker +----------------- + +Validates DHCP pools defined in Hiera YAML files (Foreman/Puppet style). +Prevents bad subnet/mask math from taking down ISC DHCPD. + +Checks: +- Each `dhcp::pools` entry must have a valid network + mask combination. +- Optional `gateway` (some networks legitimately have no default GW): + * If present, it must be inside the subnet and not be network/broadcast. + * If absent, we WARN by default (configurable). +- All `range` entries must be inside the subnet, ordered (start <= end), + and must not touch network/broadcast addresses. + +Exit code: +- 0 when no errors (warnings never cause non-zero exit unless `--strict`). +- 1 when any ERROR is found (invalid subnet, bad range, bad gateway). +- 2 for usage or environment errors (e.g., folder missing). + +CLI flags: +- --require-gateway -> Treat missing gateway as ERROR (fail). +- --no-warn-missing-gateway -> Do not warn when gateway is missing. +- --strict -> Treat WARNINGS as ERRORS (fail on warnings). +- --dir PATH -> Root dir for nodes YAML (default: hieradata/node). + +Usage: + python utils/dhcp-check.py + python utils/dhcp-check.py --require-gateway + python utils/dhcp-check.py --no-warn-missing-gateway + python utils/dhcp-check.py --strict +""" + +import argparse +import ipaddress +import sys +from pathlib import Path + +import yaml + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Validate DHCP pools in Hiera YAML files.") + p.add_argument( + "--dir", + default="hieradata/node", + help="Directory containing node YAML files (default: hieradata/nodes)", + ) + p.add_argument( + "--require-gateway", + action="store_true", + help="Fail when a pool has no gateway (default: warn only).", + ) + p.add_argument( + "--no-warn-missing-gateway", + action="store_true", + help="Do not warn when a pool has no gateway.", + ) + p.add_argument( + "--strict", + action="store_true", + help="Treat warnings as errors (non-zero exit if any warnings).", + ) + return p.parse_args() + + +def prefixlen_from_mask(mask_str: str) -> int: + """Convert dotted mask (e.g., 255.255.255.224) to prefix length (/27).""" + return ipaddress.IPv4Network(f"0.0.0.0/{mask_str}").prefixlen + + +def validate_pool(file: str, name: str, pool: dict, require_gw: bool, warn_missing_gw: bool): + """ + Validate one DHCP pool dict. + + Returns: + errors: list[str] -> hard failures + warns: list[str] -> soft warnings + """ + errors, warns = [], [] + + # Network + mask must be valid; also verify the provided network equals the subnet base. + try: + mask = prefixlen_from_mask(pool["mask"]) + subnet = ipaddress.IPv4Network(f"{pool['network']}/{mask}", strict=True) + except Exception as e: + errors.append(f"{file} [{name}]: invalid network/mask → {e}") + return errors, warns + + if str(subnet.network_address) != pool["network"]: + errors.append( + f"{file} [{name}]: network {pool['network']} is not the subnet base " + f"(should be {subnet.network_address}/{mask})" + ) + + # Gateway is OPTIONAL: + gw_str = pool.get("gateway") + if gw_str: + try: + gw = ipaddress.ip_address(gw_str) + except Exception as e: + errors.append(f"{file} [{name}]: invalid gateway '{gw_str}' → {e}") + gw = None + if gw: + if gw not in subnet: + errors.append(f"{file} [{name}]: gateway {gw} not in {subnet}") + elif gw == subnet.network_address or gw == subnet.broadcast_address: + errors.append(f"{file} [{name}]: gateway {gw} is network/broadcast address") + else: + if require_gw: + errors.append(f"{file} [{name}]: gateway is missing (required by policy)") + elif not warn_missing_gw: + pass # silent + else: + warns.append(f"{file} [{name}]: gateway is missing (warning only)") + + # Validate each range. + for r in pool.get("range", []): + try: + start_str, end_str = r.split() + start = ipaddress.ip_address(start_str) + end = ipaddress.ip_address(end_str) + except Exception as e: + errors.append(f"{file} [{name}]: bad range '{r}' → {e}") + continue + + if start not in subnet or end not in subnet: + errors.append(f"{file} [{name}]: range {start}-{end} not inside {subnet}") + if start > end: + errors.append(f"{file} [{name}]: range start {start} > end {end}") + if start in (subnet.network_address, subnet.broadcast_address) or \ + end in (subnet.network_address, subnet.broadcast_address): + errors.append(f"{file} [{name}]: range {start}-{end} touches network/broadcast") + + return errors, warns + + +def check_file(path: Path, require_gw: bool, warn_missing_gw: bool): + """ + Check one YAML file. Returns (errors, warnings). + """ + try: + data = yaml.safe_load(path.read_text()) + except yaml.composer.ComposerError as e: + if "found duplicate anchor" in str(e): + # Try loading without anchors/aliases by using a simple text replacement + content = path.read_text() + # Remove all anchor definitions and references + import re + content = re.sub(r'&\w+\s*', '', content) + content = re.sub(r'\*\w+', '""', content) + try: + data = yaml.safe_load(content) + except Exception as fallback_e: + return [f"{path}: YAML parse error (even after anchor cleanup) → {fallback_e}"], [] + else: + return [f"{path}: YAML parse error → {e}"], [] + except Exception as e: + return [f"{path}: YAML parse error → {e}"], [] + + if not data or "dhcp::pools" not in data: + return [], [] + + file_errors, file_warns = [], [] + for name, pool in data["dhcp::pools"].items(): + errs, warns = validate_pool(str(path), name, pool, require_gw, warn_missing_gw) + file_errors.extend(errs) + file_warns.extend(warns) + + if not file_errors: + print(f"āœ”ļø {path}: {len(data['dhcp::pools'])} pool(s) validated OK.") + if file_warns: + for w in file_warns: + print(f"āš ļø {w}") + + return file_errors, file_warns + + +def main(): + args = parse_args() + root = Path(args.dir) + + if not root.exists(): + print(f"ERROR: {root} not found") + sys.exit(2) + + files = sorted(root.glob("*.y*ml")) + if not files: + print(f"No YAML files found under {root}") + sys.exit(0) + + all_errors, all_warns = [], [] + for f in files: + errs, warns = check_file(f, args.require_gateway, not args.no_warn_missing_gateway) + all_errors.extend(errs) + all_warns.extend(warns) + + if all_errors: + print("\nāŒ Errors:") + for e in all_errors: + print(" -", e) + sys.exit(1) + + if all_warns: + print("\nāš ļø Warnings:") + for w in all_warns: + print(" -", w) + if args.strict: + sys.exit(1) + + print("\nāœ… All DHCP pools look sane.") + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/image-ok.png b/utils/image-ok.png new file mode 100644 index 0000000000..ad33711b20 Binary files /dev/null and b/utils/image-ok.png differ diff --git a/utils/image-wrong.png b/utils/image-wrong.png new file mode 100644 index 0000000000..842651196e Binary files /dev/null and b/utils/image-wrong.png differ diff --git a/utils/requirements-dhcp.txt b/utils/requirements-dhcp.txt new file mode 100644 index 0000000000..8392d5414b --- /dev/null +++ b/utils/requirements-dhcp.txt @@ -0,0 +1 @@ +PyYAML==6.0.2