diff --git a/.github/workflows/vyos-build.yml b/.github/workflows/vyos-build.yml index 8a972d6..7fc74af 100644 --- a/.github/workflows/vyos-build.yml +++ b/.github/workflows/vyos-build.yml @@ -1,4 +1,4 @@ -name: Build VyOS Image +name: VyOS Integration Tests on: push: @@ -9,14 +9,9 @@ on: paths: - 'infrastructure/network/vyos/**' workflow_dispatch: - inputs: - upload: - description: 'Upload image to e2 storage' - type: boolean - default: true concurrency: - group: vyos-build-${{ github.ref }} + group: vyos-test-${{ github.ref }} cancel-in-progress: false jobs: @@ -25,250 +20,89 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Validate flavor template - run: | - # Check that the template file exists and contains required placeholders - TEMPLATE="infrastructure/network/vyos/vyos-build/build-flavors/gateway.toml" - - if [[ ! -f "${TEMPLATE}" ]]; then - echo "ERROR: Template file not found: ${TEMPLATE}" - exit 1 - fi - - if ! grep -q '%%SSH_KEY_TYPE%%' "${TEMPLATE}"; then - echo "ERROR: Template missing %%SSH_KEY_TYPE%% placeholder" - exit 1 - fi - - if ! grep -q '%%SSH_PUBLIC_KEY%%' "${TEMPLATE}"; then - echo "ERROR: Template missing %%SSH_PUBLIC_KEY%% placeholder" - exit 1 - fi - - echo "Template validation passed" - - name: Check scripts are executable run: | - for script in infrastructure/network/vyos/vyos-build/scripts/*.sh; do - if [[ ! -x "${script}" ]]; then + for script in infrastructure/network/vyos/scripts/*.sh infrastructure/network/vyos/tests/*.sh; do + if [[ -f "${script}" && ! -x "${script}" ]]; then echo "ERROR: Script not executable: ${script}" exit 1 fi echo "OK: ${script}" done - build: - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - runs-on: warp-ubuntu-latest-x64-8x - needs: validate - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.23' - cache-dependency-path: tools/labctl/go.sum - - - name: Build labctl + - name: Validate topology file run: | - cd tools/labctl - go build -o ../../labctl . + python3 -c "import yaml; yaml.safe_load(open('infrastructure/network/vyos/tests/topology.clab.yml'))" + echo "Topology file is valid YAML" - - name: Install SOPS - run: | - curl -LO https://github.com/getsops/sops/releases/download/v3.9.2/sops-v3.9.2.linux.amd64 - chmod +x sops-v3.9.2.linux.amd64 - sudo mv sops-v3.9.2.linux.amd64 /usr/local/bin/sops - - - name: Write SOPS age key - run: | - echo "${{ secrets.SOPS_AGE_KEY }}" > /tmp/age-key.txt - chmod 600 /tmp/age-key.txt - - - name: Extract SSH public key - env: - SOPS_AGE_KEY_FILE: /tmp/age-key.txt - run: | - sops --decrypt \ - --extract '["ssh_public_key"]' images/packer-ssh.sops.yaml > /tmp/ssh_key.pub - echo "SSH key extracted" - - - name: Clone vyos-build - run: | - git clone -b current --single-branch --depth 1 \ - https://github.com/vyos/vyos-build.git /tmp/vyos-build - - - name: Generate build flavor - run: | - ./infrastructure/network/vyos/vyos-build/scripts/generate-flavor.sh \ - "$(cat /tmp/ssh_key.pub)" \ - /tmp/vyos-build/data/build-flavors/gateway.toml - - - name: Build VyOS image - run: | - # Generate version string - VERSION="lab-$(date +%Y%m%d%H%M%S)" - - docker run --rm --privileged \ - -v /tmp/vyos-build:/vyos \ - -v /dev:/dev \ - -e VYOS_BUILD_BY="ci@lab.gilman.io" \ - -w /vyos \ - vyos/vyos-build:current \ - bash -c "sudo ./build-vyos-image --architecture amd64 --build-by ci@lab.gilman.io --build-type release --version ${VERSION} gateway" - - # Find and move the output image (suppress permission denied errors) - echo "Looking for .raw image in build output..." - ls -la /tmp/vyos-build/ || true - - # The raw image is created in the vyos-build root directory - RAW_FILE=$(find /tmp/vyos-build -maxdepth 1 -name "*.raw" -type f 2>/dev/null | head -1) - - if [[ -z "${RAW_FILE}" ]]; then - echo "No .raw file in root, checking build directory..." - RAW_FILE=$(find /tmp/vyos-build/build -name "*.raw" -type f 2>/dev/null | head -1) - fi - - if [[ -z "${RAW_FILE}" || ! -f "${RAW_FILE}" ]]; then - echo "ERROR: Build failed - no raw image found" - echo "Contents of /tmp/vyos-build:" - ls -la /tmp/vyos-build/ || true - echo "Contents of /tmp/vyos-build/build:" - ls -la /tmp/vyos-build/build/ 2>/dev/null || true - exit 1 - fi - - echo "Found raw image: ${RAW_FILE}" - cp "${RAW_FILE}" /tmp/vyos-gateway.raw - echo "Build complete: /tmp/vyos-gateway.raw" - ls -lah /tmp/vyos-gateway.raw - - - name: Upload to e2 - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.upload) - run: | - ./labctl images upload \ - --credentials images/e2.sops.yaml \ - --sops-age-key-file /tmp/age-key.txt \ - --source /tmp/vyos-gateway.raw \ - --destination vyos/vyos-gateway.raw - - - name: Upload build artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: vyos-gateway-image - path: /tmp/vyos-gateway.raw - retention-days: 7 - if-no-files-found: warn - - # Build container image for integration testing build-container: - if: github.event_name == 'pull_request' - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-latest needs: validate steps: - uses: actions/checkout@v4 - - name: Cache SOPS - uses: actions/cache@v4 - with: - path: ~/.cache/sops - key: sops-v3.9.2 - - - name: Install SOPS - run: | - if [[ -x "${HOME}/.cache/sops/sops" ]]; then - sudo cp "${HOME}/.cache/sops/sops" /usr/local/bin/sops - exit 0 - fi - mkdir -p "${HOME}/.cache/sops" - curl -Lo "${HOME}/.cache/sops/sops" \ - https://github.com/getsops/sops/releases/download/v3.9.2/sops-v3.9.2.linux.amd64 - chmod +x "${HOME}/.cache/sops/sops" - sudo cp "${HOME}/.cache/sops/sops" /usr/local/bin/sops - - - name: Write SOPS age key - run: | - echo "${{ secrets.SOPS_AGE_KEY }}" > /tmp/age-key.txt - chmod 600 /tmp/age-key.txt - - - name: Extract SSH public key - env: - SOPS_AGE_KEY_FILE: /tmp/age-key.txt - run: | - sops --decrypt \ - --extract '["ssh_public_key"]' images/packer-ssh.sops.yaml > /tmp/ssh_key.pub - echo "SSH key extracted" - - - name: Clone vyos-build + - name: Compute cache key + id: cache-key run: | - git clone -b current --single-branch --depth 1 \ - https://github.com/vyos/vyos-build.git /tmp/vyos-build + # Cache key based on ISO checksum and build scripts + ISO_CHECKSUM=$(grep -A5 'name: vyos-stream' images/images.yaml | grep 'checksum:' | awk '{print $2}' | cut -d: -f2) + SCRIPTS_HASH=$(cat infrastructure/network/vyos/scripts/iso-to-container.sh infrastructure/network/vyos/Dockerfile.containerlab | sha256sum | cut -d' ' -f1) + echo "key=vyos-container-${ISO_CHECKSUM:0:16}-${SCRIPTS_HASH:0:16}" >> $GITHUB_OUTPUT - - name: Generate build flavor - run: | - ./infrastructure/network/vyos/vyos-build/scripts/generate-flavor.sh \ - "$(cat /tmp/ssh_key.pub)" \ - /tmp/vyos-build/data/build-flavors/gateway.toml + - name: Restore cached container image + id: cache-container + uses: actions/cache@v4 + with: + path: /tmp/vyos-gateway-container.tar + key: ${{ steps.cache-key.outputs.key }} - - name: Build VyOS ISO + - name: Install dependencies + if: steps.cache-container.outputs.cache-hit != 'true' run: | - # Generate version string - VERSION="test-$(date +%Y%m%d%H%M%S)" - - # Build ISO (produces squashfs we need for container) - docker run --rm --privileged \ - -v /tmp/vyos-build:/vyos \ - -e VYOS_BUILD_BY="ci@lab.gilman.io" \ - -w /vyos \ - vyos/vyos-build:current \ - bash -c "sudo ./build-vyos-image --architecture amd64 --build-by ci@lab.gilman.io --build-type release --version ${VERSION} gateway" + sudo apt-get update + sudo apt-get install -y p7zip-full squashfs-tools-ng - echo "Build complete, checking for squashfs..." - find /tmp/vyos-build -name "*.squashfs" -type f 2>/dev/null || true - - - name: Install squashfs tools - run: sudo apt-get update && sudo apt-get install -y squashfs-tools-ng - - - name: Build container image + - name: Download VyOS ISO + if: steps.cache-container.outputs.cache-hit != 'true' run: | - cd /tmp/vyos-build + # Extract URL and checksum from images.yaml + URL=$(grep -A5 'name: vyos-stream' images/images.yaml | grep 'url:' | awk '{print $2}') + EXPECTED=$(grep -A5 'name: vyos-stream' images/images.yaml | grep 'checksum:' | awk '{print $2}' | cut -d: -f2) - # Find the squashfs filesystem - SQUASHFS=$(find . -name "filesystem.squashfs" -type f 2>/dev/null | head -1) + echo "Downloading VyOS ISO from: ${URL}" + curl -L -o /tmp/vyos.iso "${URL}" - if [[ -z "${SQUASHFS}" ]]; then - # Try alternative location - SQUASHFS=$(find . -name "*.squashfs" -type f 2>/dev/null | head -1) - fi + echo "Verifying checksum..." + ACTUAL=$(sha256sum /tmp/vyos.iso | awk '{print $1}') - if [[ -z "${SQUASHFS}" || ! -f "${SQUASHFS}" ]]; then - echo "ERROR: squashfs not found" - find . -type f -name "*.squashfs" 2>/dev/null || true - ls -la build/ 2>/dev/null || true + if [[ "${EXPECTED}" != "${ACTUAL}" ]]; then + echo "ERROR: Checksum mismatch" + echo "Expected: ${EXPECTED}" + echo "Actual: ${ACTUAL}" exit 1 fi + echo "Checksum verified" - echo "Found squashfs: ${SQUASHFS}" - - # Extract squashfs to tarball - sqfs2tar "${SQUASHFS}" > /tmp/rootfs.tar - echo "Extracted rootfs.tar: $(ls -lah /tmp/rootfs.tar)" - - # Build container image - cd $GITHUB_WORKSPACE - cp /tmp/rootfs.tar . - docker build -t vyos-gateway:test -f infrastructure/network/vyos/Dockerfile.containerlab . - rm rootfs.tar - - echo "Container image built successfully" - docker images vyos-gateway:test + - name: Build container image from ISO + if: steps.cache-container.outputs.cache-hit != 'true' + run: | + infrastructure/network/vyos/scripts/iso-to-container.sh /tmp/vyos.iso vyos-gateway:test - name: Save container image + if: steps.cache-container.outputs.cache-hit != 'true' run: | docker save vyos-gateway:test -o /tmp/vyos-gateway-container.tar ls -lah /tmp/vyos-gateway-container.tar + - name: Report cache status + run: | + if [[ "${{ steps.cache-container.outputs.cache-hit }}" == "true" ]]; then + echo "✓ Container image restored from cache" + else + echo "✓ Container image built and cached for future runs" + fi + ls -lah /tmp/vyos-gateway-container.tar + - name: Upload container image artifact uses: actions/upload-artifact@v4 with: @@ -276,10 +110,10 @@ jobs: path: /tmp/vyos-gateway-container.tar retention-days: 1 - # Run integration tests using Containerlab integration-test: needs: build-container runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -296,14 +130,7 @@ jobs: - name: Install Containerlab run: | - if [[ -x "${HOME}/.cache/containerlab/containerlab" ]]; then - sudo cp "${HOME}/.cache/containerlab/containerlab" /usr/local/bin/containerlab - else - mkdir -p "${HOME}/.cache/containerlab" - bash -c "$(curl -sL https://get.containerlab.dev)" - BIN_PATH="$(command -v containerlab)" - sudo cp "${BIN_PATH}" "${HOME}/.cache/containerlab/containerlab" - fi + bash -c "$(curl -sL https://get.containerlab.dev)" containerlab version - name: Set up Python @@ -320,68 +147,126 @@ jobs: - name: Generate test config.boot run: | ssh-keygen -t ed25519 -f /tmp/vyos-test-key -N "" -C "vyos-ci" - chmod +x infrastructure/network/vyos/tests/render-config-boot.sh infrastructure/network/vyos/tests/render-config-boot.sh "$(cat /tmp/vyos-test-key.pub)" - name: Deploy Containerlab topology run: | cd infrastructure/network/vyos/tests sudo containerlab deploy -t topology.clab.yml --reconfigure - timeout-minutes: 10 - - - name: Initialize VyOS config - run: | - CONTAINER="clab-vyos-gateway-test-gateway" - sudo docker exec "${CONTAINER}" sh -c "modprobe br_netfilter || true" - sudo docker exec "${CONTAINER}" sh -c "timeout 60 python3 /usr/libexec/vyos/vyos-boot-config-loader.py /opt/vyatta/etc/config/config.boot || true" + timeout-minutes: 5 - - name: Wait for VyOS boot + - name: Wait for VyOS to be ready run: | - echo "Waiting for VyOS to boot..." CONTAINER="clab-vyos-gateway-test-gateway" - # Wait for container to be running + echo "Waiting for container to be running..." for i in {1..30}; do if docker ps --filter "name=${CONTAINER}" --filter "status=running" | grep -q "${CONTAINER}"; then echo "Container is running" break fi echo "Waiting for container... ($i/30)" - sleep 3 + sleep 2 done - # Wait for systemd to be ready - for i in {1..60}; do - if docker exec "${CONTAINER}" systemctl is-system-running --quiet 2>/dev/null; then - echo "VyOS systemd is running" + echo "Loading kernel modules on host..." + sudo modprobe 8021q || true + + echo "Loading kernel modules in container..." + sudo docker exec "${CONTAINER}" modprobe br_netfilter || true + sudo docker exec "${CONTAINER}" modprobe 8021q || true + + echo "Checking if 8021q is loaded..." + lsmod | grep 8021q || echo "WARNING: 8021q not loaded on host" + + echo "Waiting for VyOS config to be applied..." + # VyOS loads config automatically via vyos-router.service + # The "systemd running" state happens before config is applied + # Config migration takes ~100 seconds in container environments + # The message appears in docker logs (container stdout), not dmesg + for i in {1..90}; do + # Check for config migration completion in container logs + if docker logs "${CONTAINER}" 2>&1 | grep -q "migrate.*configure"; then + echo "VyOS config migration detected" + # Wait for services to start + sleep 15 break fi - echo "Waiting for systemd... ($i/60)" - sleep 5 + echo "Waiting for VyOS config... ($i/90)" + sleep 2 done - # Additional settle time for all services - echo "Waiting for services to stabilize..." - sleep 30 + echo "Verifying configuration loaded..." + docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands | head -10 + + # Check if kea-dhcp4 process is running + echo "=== DHCP server process check ===" + docker exec "${CONTAINER}" pgrep -a kea-dhcp4 || echo "WARNING: kea-dhcp4 process not found" + + # Check DHCP server status + echo "=== DHCP server status ===" + docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show dhcp server leases 2>&1 || true + timeout-minutes: 5 + + - name: Verify VyOS interfaces + run: | + CONTAINER="clab-vyos-gateway-test-gateway" + + echo "=== VyOS Version ===" + docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show version - # Check VyOS status - docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show version || true + echo "" + echo "=== Configured Interfaces ===" + docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show interfaces + + echo "" + echo "=== DHCP Server Status ===" + docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show dhcp server leases || echo "No leases yet" + timeout-minutes: 2 - name: Run integration tests + env: + VYOS_SSH_KEY: /tmp/vyos-test-key run: | cd infrastructure/network/vyos/tests pytest -v --tb=short -x + timeout-minutes: 5 - name: Collect logs on failure if: failure() run: | - CONTAINER="clab-vyos-gateway-test-gateway" - echo "=== Container logs ===" - docker logs "${CONTAINER}" 2>&1 | tail -100 || true + GATEWAY="clab-vyos-gateway-test-gateway" + + echo "=== Running containers ===" + docker ps --format "table {{.Names}}\t{{.Status}}" | grep vyos-gateway-test || true + + echo "" + echo "=== Gateway container logs ===" + docker logs "${GATEWAY}" 2>&1 | tail -100 || true + + echo "" echo "=== VyOS configuration ===" - docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration 2>&1 || true - echo "=== Interfaces ===" - docker exec "${CONTAINER}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show interfaces 2>&1 || true + docker exec "${GATEWAY}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration 2>&1 || true + + echo "" + echo "=== VyOS interfaces ===" + docker exec "${GATEWAY}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show interfaces 2>&1 || true + + echo "" + echo "=== VyOS routing table ===" + docker exec "${GATEWAY}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show ip route 2>&1 || true + + echo "" + echo "=== VyOS NAT rules ===" + docker exec "${GATEWAY}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show nat source rules 2>&1 || true + + echo "" + echo "=== VyOS firewall ===" + docker exec "${GATEWAY}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show firewall 2>&1 || true + + echo "" + echo "=== mgmt-client connectivity test ===" + docker exec clab-vyos-gateway-test-mgmt-client ping -c 1 10.10.10.1 2>&1 || true - name: Cleanup if: always() diff --git a/images/images.yaml b/images/images.yaml index 474d15b..71bcc6f 100644 --- a/images/images.yaml +++ b/images/images.yaml @@ -4,10 +4,8 @@ metadata: name: lab-images spec: images: - - name: vyos-iso + - name: vyos-stream source: - # VyOS rolling nightly build - # Note: Update checksum after downloading and verifying the ISO - url: https://github.com/vyos/vyos-nightly-build/releases/download/2025.12.20-0020-rolling/vyos-2025.12.20-0020-rolling-generic-amd64.iso - checksum: sha256:7f9eb1d6d9aacbd8fb684bb384cf2251d987097993fe7dbead8653ffbde31d04 - destination: vyos/vyos-2025.12.20-0020-rolling-generic-amd64.iso + url: https://community-downloads.vyos.dev/stream/2025.11/vyos-2025.11-generic-amd64.iso + checksum: sha256:f60a2d7dd3bdf2e370a45c04ed4fc3b195691694ca3b8546adf4c5983e70d96e + destination: vyos/vyos-2025.11-generic-amd64.iso diff --git a/infrastructure/network/vyos/justfile b/infrastructure/network/vyos/justfile index 5846e69..04b2c20 100644 --- a/infrastructure/network/vyos/justfile +++ b/infrastructure/network/vyos/justfile @@ -1,36 +1,36 @@ set shell := ["bash", "-euo", "pipefail", "-c"] -SQUASHFS := "build/live/filesystem.squashfs" -ROOTFS := "rootfs.tar" IMAGE := "vyos-gateway:test" TOPO := "tests/topology.clab.yml" KEY := "tests/.vyos-test-key" +# Generate SSH key for testing key: test -f "{{KEY}}" || ssh-keygen -t ed25519 -f "{{KEY}}" -N "" -C "vyos-ci" +# Render test config.boot from gateway.toml template config: key tests/render-config-boot.sh "$(cat {{KEY}}.pub)" -rootfs: - test -f "{{SQUASHFS}}" - sqfs2tar "{{SQUASHFS}}" > "{{ROOTFS}}" - -image: rootfs - docker build -t "{{IMAGE}}" -f Dockerfile.containerlab . +# Build container image from VyOS ISO +container ISO: + scripts/iso-to-container.sh "{{ISO}}" "{{IMAGE}}" +# Deploy the Containerlab topology deploy: config sudo containerlab deploy -t "{{TOPO}}" +# Destroy the Containerlab topology destroy: sudo containerlab destroy -t "{{TOPO}}" --cleanup +# Run pytest tests pytest: pytest -v tests -test: - just deploy - just pytest +# Full test cycle (requires container image to be built first) +test: deploy pytest +# Clean up test artifacts clean: - rm -f "{{ROOTFS}}" + rm -f tests/config.boot tests/.vyos-test-key tests/.vyos-test-key.pub diff --git a/infrastructure/network/vyos/scripts/iso-to-container.sh b/infrastructure/network/vyos/scripts/iso-to-container.sh new file mode 100755 index 0000000..d68315d --- /dev/null +++ b/infrastructure/network/vyos/scripts/iso-to-container.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# +# iso-to-container.sh - Convert a VyOS ISO to a Docker container image +# +# Usage: iso-to-container.sh [image-name:tag] +# +# This script extracts the squashfs filesystem from a VyOS ISO and builds +# a Docker container image suitable for use with Containerlab. +# +# Requirements: +# - 7z (p7zip-full package) +# - sqfs2tar (squashfs-tools-ng package) +# - docker +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCKERFILE="${SCRIPT_DIR}/../Dockerfile.containerlab" + +usage() { + echo "Usage: $0 [image-name:tag]" + echo "" + echo "Converts a VyOS ISO to a Docker container image." + echo "" + echo "Arguments:" + echo " iso-path Path to VyOS ISO file" + echo " image-tag Docker image name:tag (default: vyos-gateway:test)" + echo "" + echo "Requirements:" + echo " - 7z (p7zip-full package)" + echo " - sqfs2tar (squashfs-tools-ng package)" + echo " - docker" + exit 1 +} + +if [[ $# -lt 1 ]]; then + usage +fi + +ISO_PATH="$1" +IMAGE_TAG="${2:-vyos-gateway:test}" +WORK_DIR="${TMPDIR:-/tmp}/vyos-container-$$" + +if [[ ! -f "${ISO_PATH}" ]]; then + echo "ERROR: ISO file not found: ${ISO_PATH}" + exit 1 +fi + +if [[ ! -f "${DOCKERFILE}" ]]; then + echo "ERROR: Dockerfile not found: ${DOCKERFILE}" + exit 1 +fi + +for cmd in 7z sqfs2tar docker; do + if ! command -v "${cmd}" &>/dev/null; then + echo "ERROR: Required command not found: ${cmd}" + exit 1 + fi +done + +cleanup() { + rm -rf "${WORK_DIR}" +} +trap cleanup EXIT + +mkdir -p "${WORK_DIR}" + +echo "Extracting squashfs from ISO..." +7z x -o"${WORK_DIR}" "${ISO_PATH}" "live/filesystem.squashfs" -y >/dev/null + +SQUASHFS="${WORK_DIR}/live/filesystem.squashfs" +if [[ ! -f "${SQUASHFS}" ]]; then + echo "ERROR: filesystem.squashfs not found in ISO" + echo "Contents of ${WORK_DIR}:" + find "${WORK_DIR}" -type f + exit 1 +fi + +echo "Converting squashfs to rootfs.tar..." +ROOTFS_TAR="${WORK_DIR}/rootfs.tar" +sqfs2tar "${SQUASHFS}" > "${ROOTFS_TAR}" +echo "rootfs.tar size: $(ls -lh "${ROOTFS_TAR}" | awk '{print $5}')" + +echo "Building container image: ${IMAGE_TAG}..." +BUILD_CONTEXT="${WORK_DIR}/build" +mkdir -p "${BUILD_CONTEXT}" +cp "${ROOTFS_TAR}" "${BUILD_CONTEXT}/rootfs.tar" +cp "${DOCKERFILE}" "${BUILD_CONTEXT}/Dockerfile" + +docker build -t "${IMAGE_TAG}" -f "${BUILD_CONTEXT}/Dockerfile" "${BUILD_CONTEXT}" + +echo "Container image built successfully: ${IMAGE_TAG}" +docker images "${IMAGE_TAG%%:*}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" diff --git a/infrastructure/network/vyos/vyos-build/build-flavors/gateway.toml b/infrastructure/network/vyos/templates/gateway.toml similarity index 95% rename from infrastructure/network/vyos/vyos-build/build-flavors/gateway.toml rename to infrastructure/network/vyos/templates/gateway.toml index f4dbdd3..ce69276 100644 --- a/infrastructure/network/vyos/vyos-build/build-flavors/gateway.toml +++ b/infrastructure/network/vyos/templates/gateway.toml @@ -60,10 +60,8 @@ firewall { rule 10 { action accept description "Allow established/related" - state { - established - related - } + state established + state related } rule 20 { action drop @@ -73,19 +71,15 @@ firewall { network-group HOME_NETWORK } } - state { - new - } + state new } } name LOCAL { default-action drop rule 10 { action accept - state { - established - related - } + state established + state related } rule 20 { action accept @@ -163,10 +157,8 @@ firewall { rule 10 { action accept description "Allow established/related" - state { - established - related - } + state established + state related } rule 20 { action accept @@ -291,6 +283,7 @@ service { } dhcp-server { shared-network-name LAB_MGMT { + interface eth5.10 subnet 10.10.10.0/24 { lease 86400 option { diff --git a/infrastructure/network/vyos/tests/conftest.py b/infrastructure/network/vyos/tests/conftest.py index 4831b58..d65de16 100644 --- a/infrastructure/network/vyos/tests/conftest.py +++ b/infrastructure/network/vyos/tests/conftest.py @@ -1,15 +1,16 @@ """ -Pytest fixtures for VyOS Gateway integration tests. +Pytest fixtures for VyOS Gateway functional tests. -This module provides fixtures for connecting to the VyOS gateway container -running in Containerlab. +This module provides fixtures for testing the VyOS gateway running in Containerlab. +Tests validate actual network behavior, not just configuration strings. """ import os +import socket import subprocess import time from dataclasses import dataclass -from typing import Callable, Iterable +from typing import Callable import pytest from scrapli import Scrapli @@ -17,8 +18,6 @@ def wait_for_vyos_ready(host: str, timeout: int = 240, interval: int = 5) -> bool: """Wait for VyOS to be ready for SSH connections.""" - import socket - start_time = time.time() while time.time() - start_time < timeout: try: @@ -36,41 +35,92 @@ def wait_for_vyos_ready(host: str, timeout: int = 240, interval: int = 5) -> boo return False +def normalize_output(output: str) -> str: + """Normalize VyOS CLI output for stable assertions.""" + warning_lines = { + "WARNING: terminal is not fully functional", + "Press RETURN to continue", + } + filtered = [line for line in output.splitlines() if line.strip() not in warning_lines] + return "\n".join(filtered).replace("'", "") + + @dataclass(frozen=True) class TestTopology: """Expected values for the Containerlab test topology.""" + # WAN interface wan_iface: str = "eth4" - wan_ip: str = "192.168.0.2/24" + wan_ip: str = "192.168.0.2" + wan_cidr: str = "192.168.0.2/24" wan_gateway: str = "192.168.0.1" + wan_client_ip: str = "192.168.0.100" + + # Trunk interface trunk_iface: str = "eth5" + + # VLAN networks (gateway IPs) mgmt_vif: str = "10" - mgmt_ip: str = "10.10.10.1/24" + mgmt_gateway: str = "10.10.10.1" + mgmt_client_ip: str = "10.10.10.100" + prov_vif: str = "20" - prov_ip: str = "10.10.20.1/24" + prov_gateway: str = "10.10.20.1" + prov_client_ip: str = "10.10.20.100" + platform_vif: str = "30" - platform_ip: str = "10.10.30.1/24" + platform_gateway: str = "10.10.30.1" + platform_client_ip: str = "10.10.30.100" + cluster_vif: str = "40" - cluster_ip: str = "10.10.40.1/24" + cluster_gateway: str = "10.10.40.1" + cluster_client_ip: str = "10.10.40.100" + service_vif: str = "50" - service_ip: str = "10.10.50.1/24" + service_gateway: str = "10.10.50.1" + service_client_ip: str = "10.10.50.100" + storage_vif: str = "60" - storage_ip: str = "10.10.60.1/24" + storage_gateway: str = "10.10.60.1" + storage_client_ip: str = "10.10.60.100" + + # Network ranges home_cidr: str = "192.168.0.0/24" lab_cidr: str = "10.10.0.0/16" + + # DHCP configuration dhcp_subnet: str = "10.10.10.0/24" dhcp_range_start: str = "10.10.10.200" dhcp_range_stop: str = "10.10.10.250" + + # DNS configuration dns_listen_addresses: tuple[str, ...] = ("10.10.10.1", "10.10.30.1") + + # BGP configuration bgp_neighbors: tuple[str, ...] = ("10.10.30.10", "10.10.30.11", "10.10.30.12") bgp_remote_as: str = "64513" bgp_local_as: str = "64512" bgp_router_id: str = "10.10.50.1" bgp_service_network: str = "10.10.50.0/24" + + # System configuration domain_name: str = "lab.gilman.io" hostname: str = "gateway" name_servers: tuple[str, ...] = ("1.1.1.1", "8.8.8.8") - time_zone: str = "America/Los_Angeles" + + # Container name prefix + container_prefix: str = "clab-vyos-gateway-test" + + +# Mapping of client names to their gateway IPs for connectivity tests +VLAN_CLIENTS = { + "mgmt-client": "10.10.10.1", + "prov-client": "10.10.20.1", + "platform-client": "10.10.30.1", + "cluster-client": "10.10.40.1", + "service-client": "10.10.50.1", + "storage-client": "10.10.60.1", +} @pytest.fixture(scope="session") @@ -125,7 +175,6 @@ def vyos( This fixture uses session scope so the connection is reused across all tests. """ - # Wait for VyOS to be ready if not wait_for_vyos_ready(vyos_host): pytest.fail(f"VyOS at {vyos_host} not ready after timeout") @@ -150,7 +199,7 @@ def vyos( @pytest.fixture(scope="session") def vyos_show(vyos: Scrapli) -> Callable[[str], str]: - """Return a helper to run show commands and return output.""" + """Return a helper to run VyOS show commands and return normalized output.""" def _show(command: str) -> str: result = vyos.send_command(command) @@ -162,44 +211,99 @@ def _show(command: str) -> str: @pytest.fixture(scope="session") -def config_commands(vyos_container: str) -> str: - """Return the rendered config as VyOS set-style commands.""" - result = subprocess.run( - [ - "docker", - "exec", - vyos_container, - "vyos-config-to-commands", - "/opt/vyatta/etc/config/config.boot", - ], - check=False, - capture_output=True, - text=True, - ) - if result.returncode != 0: - stderr = result.stderr.strip() or result.stdout.strip() - pytest.fail(f"Failed to render config commands via docker exec: {stderr}") - return normalize_output(result.stdout) +def exec_on_client(test_topology: TestTopology) -> Callable[..., subprocess.CompletedProcess]: + """ + Execute a command on a test client container. + + Usage: + result = exec_on_client("mgmt-client", ["ping", "-c", "1", "10.10.10.1"]) + assert result.returncode == 0 + """ + + def _exec( + client: str, + cmd: list[str], + timeout: int = 30, + ) -> subprocess.CompletedProcess: + container = f"{test_topology.container_prefix}-{client}" + return subprocess.run( + ["docker", "exec", container, *cmd], + capture_output=True, + text=True, + timeout=timeout, + ) + + return _exec @pytest.fixture(scope="session") -def assert_contains() -> Callable[[str, Iterable[str], str], None]: - """Return an assertion helper for checking output content.""" +def ping(exec_on_client: Callable) -> Callable[[str, str, int], bool]: + """ + Ping helper that returns True if ping succeeds. - def _assert(output: str, items: Iterable[str], context: str = "") -> None: - missing = [item for item in items if item not in output] - if missing: - prefix = f"{context}: " if context else "" - raise AssertionError(f"{prefix}missing {', '.join(missing)}") + Usage: + assert ping("mgmt-client", "10.10.10.1") + """ - return _assert + def _ping(from_client: str, target: str, count: int = 3) -> bool: + result = exec_on_client(from_client, ["ping", "-c", str(count), "-W", "2", target]) + return result.returncode == 0 + return _ping -def normalize_output(output: str) -> str: - """Normalize VyOS CLI output for stable assertions.""" - warning_lines = { - "WARNING: terminal is not fully functional", - "Press RETURN to continue", - } - filtered = [line for line in output.splitlines() if line.strip() not in warning_lines] - return "\n".join(filtered).replace("'", "") + +@pytest.fixture(scope="session") +def tcp_connect(exec_on_client: Callable) -> Callable[[str, str, int, int], bool]: + """ + Test TCP connectivity using netcat. + + Returns True if connection succeeds, False otherwise. + + Usage: + assert tcp_connect("mgmt-client", "10.10.10.1", 22) # SSH should work + assert not tcp_connect("wan-client", "10.10.10.100", 22) # Should be blocked + """ + + def _connect(from_client: str, target: str, port: int, timeout: int = 3) -> bool: + result = exec_on_client( + from_client, + ["nc", "-z", "-w", str(timeout), target, str(port)], + ) + return result.returncode == 0 + + return _connect + + +@pytest.fixture(scope="session") +def dns_resolve(exec_on_client: Callable) -> Callable[[str, str, str], str | None]: + """ + Resolve a DNS name using dig. + + Returns the resolved IP or None if resolution fails. + + Usage: + ip = dns_resolve("mgmt-client", "cloudflare.com", "10.10.10.1") + assert ip is not None + """ + + def _resolve(from_client: str, hostname: str, dns_server: str) -> str | None: + result = exec_on_client( + from_client, + ["dig", "+short", f"@{dns_server}", hostname], + ) + if result.returncode != 0: + return None + # dig returns IPs one per line, take the first valid one + for line in result.stdout.strip().split("\n"): + line = line.strip() + if line and not line.startswith(";"): + return line + return None + + return _resolve + + +@pytest.fixture(scope="session") +def vlan_clients() -> dict[str, str]: + """Return mapping of VLAN client names to their gateway IPs.""" + return VLAN_CLIENTS.copy() diff --git a/infrastructure/network/vyos/tests/render-config-boot.sh b/infrastructure/network/vyos/tests/render-config-boot.sh index 3891660..1f06b87 100755 --- a/infrastructure/network/vyos/tests/render-config-boot.sh +++ b/infrastructure/network/vyos/tests/render-config-boot.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="${SCRIPT_DIR}/.." -TEMPLATE_FILE="${REPO_ROOT}/vyos-build/build-flavors/gateway.toml" +TEMPLATE_FILE="${REPO_ROOT}/templates/gateway.toml" OUTPUT_FILE="${SCRIPT_DIR}/config.boot" usage() { diff --git a/infrastructure/network/vyos/tests/test_bgp.py b/infrastructure/network/vyos/tests/test_bgp.py deleted file mode 100644 index 761c704..0000000 --- a/infrastructure/network/vyos/tests/test_bgp.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -BGP configuration tests for the VyOS gateway. -""" - - -def test_bgp_local_as(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set protocols bgp system-as {test_topology.bgp_local_as}"], - context="BGP local AS", - ) - - -def test_bgp_router_id(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set protocols bgp parameters router-id {test_topology.bgp_router_id}"], - context="BGP router ID", - ) - - -def test_bgp_neighbors_configured(config_commands, test_topology, assert_contains): - expected = [ - f"set protocols bgp neighbor {neighbor} remote-as {test_topology.bgp_remote_as}" - for neighbor in test_topology.bgp_neighbors - ] - assert_contains(config_commands, expected, context="BGP neighbors") - - -def test_bgp_neighbors_shutdown(config_commands, test_topology, assert_contains): - expected = [ - f"set protocols bgp neighbor {neighbor} shutdown" - for neighbor in test_topology.bgp_neighbors - ] - assert_contains(config_commands, expected, context="BGP neighbor shutdown") - - -def test_bgp_network_advertisement(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [ - "set protocols bgp address-family ipv4-unicast", - f"set protocols bgp address-family ipv4-unicast network {test_topology.bgp_service_network}", - ], - context="BGP network advertisement", - ) diff --git a/infrastructure/network/vyos/tests/test_connectivity.py b/infrastructure/network/vyos/tests/test_connectivity.py new file mode 100644 index 0000000..6ea6fbe --- /dev/null +++ b/infrastructure/network/vyos/tests/test_connectivity.py @@ -0,0 +1,72 @@ +""" +Connectivity tests for the VyOS gateway. + +These tests verify that basic routing and reachability work correctly +across all network segments. +""" + +import pytest + + +class TestGatewayReachability: + """Test that each VLAN client can reach its gateway.""" + + @pytest.mark.parametrize( + "client,gateway", + [ + ("mgmt-client", "10.10.10.1"), + ("prov-client", "10.10.20.1"), + ("platform-client", "10.10.30.1"), + ("cluster-client", "10.10.40.1"), + ("service-client", "10.10.50.1"), + ("storage-client", "10.10.60.1"), + ], + ) + def test_vlan_client_reaches_gateway(self, ping, client, gateway): + """Each VLAN client can ping its gateway IP.""" + assert ping(client, gateway), f"{client} cannot reach gateway {gateway}" + + def test_wan_client_reaches_gateway(self, ping, test_topology): + """WAN client can ping the gateway's WAN interface.""" + assert ping("wan-client", test_topology.wan_ip), ( + f"wan-client cannot reach gateway WAN IP {test_topology.wan_ip}" + ) + + +class TestInterVlanRouting: + """Test routing between different VLANs through the gateway.""" + + def test_mgmt_to_platform(self, ping, test_topology): + """Management client can reach platform client via gateway routing.""" + assert ping("mgmt-client", test_topology.platform_client_ip), ( + "mgmt-client cannot reach platform-client (inter-VLAN routing failure)" + ) + + def test_platform_to_mgmt(self, ping, test_topology): + """Platform client can reach management client via gateway routing.""" + assert ping("platform-client", test_topology.mgmt_client_ip), ( + "platform-client cannot reach mgmt-client (inter-VLAN routing failure)" + ) + + def test_cluster_to_storage(self, ping, test_topology): + """Cluster client can reach storage client via gateway routing.""" + assert ping("cluster-client", test_topology.storage_client_ip), ( + "cluster-client cannot reach storage-client (inter-VLAN routing failure)" + ) + + +class TestWanConnectivity: + """Test connectivity between lab networks and WAN.""" + + def test_lab_client_reaches_wan_client(self, ping, test_topology): + """Lab client can reach WAN client (via NAT).""" + assert ping("mgmt-client", test_topology.wan_client_ip), ( + "mgmt-client cannot reach wan-client (NAT or routing failure)" + ) + + def test_all_vlan_clients_reach_wan(self, ping, vlan_clients, test_topology): + """All VLAN clients can reach the WAN network.""" + for client in vlan_clients: + assert ping(client, test_topology.wan_client_ip), ( + f"{client} cannot reach wan-client" + ) diff --git a/infrastructure/network/vyos/tests/test_firewall.py b/infrastructure/network/vyos/tests/test_firewall.py index 7acb6d6..a261dd0 100644 --- a/infrastructure/network/vyos/tests/test_firewall.py +++ b/infrastructure/network/vyos/tests/test_firewall.py @@ -1,95 +1,105 @@ """ -Firewall configuration tests for the VyOS gateway. -""" - - -def test_firewall_groups_exist(config_commands, assert_contains): - assert_contains( - config_commands, - [ - "set firewall group network-group HOME_NETWORK", - "set firewall group network-group LAB_NETWORKS", - "set firewall group network-group RFC1918", - ], - context="firewall groups", - ) - - -def test_home_network_group_content(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set firewall group network-group HOME_NETWORK network {test_topology.home_cidr}"], - context="HOME_NETWORK group", - ) - - -def test_lab_networks_group_content(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set firewall group network-group LAB_NETWORKS network {test_topology.lab_cidr}"], - context="LAB_NETWORKS group", - ) - - -def test_rfc1918_group_content(config_commands, assert_contains): - assert_contains( - config_commands, - [ - "set firewall group network-group RFC1918 network 10.0.0.0/8", - "set firewall group network-group RFC1918 network 172.16.0.0/12", - "set firewall group network-group RFC1918 network 192.168.0.0/16", - ], - context="RFC1918 group", - ) - - -def test_firewall_interface_binding(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [ - f"set firewall interface {test_topology.wan_iface} in name WAN_TO_LAB", - f"set firewall interface {test_topology.wan_iface} local name LOCAL", - f"set firewall interface {test_topology.wan_iface} out name LAB_TO_WAN", - ], - context="firewall interface binding", - ) - - -def test_wan_to_lab_rules(config_commands, assert_contains): - assert_contains( - config_commands, - [ - "set firewall ipv4 name WAN_TO_LAB default-action drop", - "set firewall ipv4 name WAN_TO_LAB rule 10 state established", - "set firewall ipv4 name WAN_TO_LAB rule 10 state related", - "set firewall ipv4 name WAN_TO_LAB rule 20 source group network-group HOME_NETWORK", - ], - context="WAN_TO_LAB rules", - ) - - -def test_lab_to_wan_rules(config_commands, assert_contains): - assert_contains( - config_commands, - [ - "set firewall ipv4 name LAB_TO_WAN default-action accept", - "set firewall ipv4 name LAB_TO_WAN rule 10 state established", - "set firewall ipv4 name LAB_TO_WAN rule 10 state related", - "set firewall ipv4 name LAB_TO_WAN rule 20 destination group network-group HOME_NETWORK", - ], - context="LAB_TO_WAN rules", - ) +Firewall functional tests for the VyOS gateway. +These tests verify that firewall rules actually block and allow +traffic as expected, not just that they are configured. +""" -def test_local_firewall_rules(config_commands, assert_contains): - assert_contains( - config_commands, +import pytest + + +class TestWanToLabFirewall: + """Test firewall rules for traffic from WAN to lab networks.""" + + def test_home_network_can_ping_lab(self, ping, test_topology): + """ + WAN client (in HOME_NETWORK) can ping lab clients. + + The WAN_TO_LAB firewall allows traffic from HOME_NETWORK (192.168.0.0/24). + """ + assert ping("wan-client", test_topology.mgmt_client_ip), ( + "wan-client (HOME_NETWORK) should be able to ping mgmt-client" + ) + + def test_home_network_can_reach_lab_services(self, tcp_connect, test_topology): + """ + WAN client (in HOME_NETWORK) can reach lab services. + + Since wan-client is in HOME_NETWORK, it should be allowed through. + """ + # This tests that HOME_NETWORK rule actually works + # We can't easily test "non-home" traffic without another WAN client + assert tcp_connect( + "wan-client", test_topology.mgmt_gateway, 22 + ), "wan-client should reach gateway SSH (HOME_NETWORK allowed)" + + +class TestLabToWanFirewall: + """Test firewall rules for traffic from lab to WAN.""" + + def test_lab_can_reach_wan(self, ping, test_topology): + """Lab clients can reach the WAN network.""" + assert ping("mgmt-client", test_topology.wan_client_ip), ( + "Lab client should be able to reach WAN" + ) + + def test_lab_can_reach_wan_gateway(self, ping, test_topology): + """Lab clients can reach the WAN-side gateway IP.""" + assert ping("platform-client", test_topology.wan_ip), ( + "Lab client should be able to reach gateway WAN IP" + ) + + +class TestLocalFirewall: + """Test firewall rules for traffic destined to the gateway itself.""" + + def test_lab_can_ssh_to_gateway(self, tcp_connect, test_topology): + """Lab clients can SSH to the gateway.""" + assert tcp_connect("mgmt-client", test_topology.mgmt_gateway, 22), ( + "Lab client should be able to SSH to gateway" + ) + + def test_wan_can_ssh_to_gateway(self, tcp_connect, test_topology): + """WAN client (HOME_NETWORK) can SSH to gateway.""" + assert tcp_connect("wan-client", test_topology.wan_ip, 22), ( + "WAN client (HOME_NETWORK) should be able to SSH to gateway" + ) + + def test_lab_can_reach_gateway_dns(self, tcp_connect, test_topology): + """Lab clients can reach gateway DNS service.""" + # DNS uses UDP primarily, but we can test TCP DNS as well + # For simplicity, we'll verify DNS works via the dns_resolve fixture + # in test_services.py. Here we just verify port 53 TCP is reachable. + assert tcp_connect("mgmt-client", test_topology.mgmt_gateway, 53), ( + "Lab client should be able to reach gateway DNS (TCP)" + ) + + @pytest.mark.parametrize( + "client,gateway", [ - "set firewall ipv4 name LOCAL default-action drop", - "set firewall ipv4 name LOCAL rule 30 destination port 22", - "set firewall ipv4 name LOCAL rule 40 destination port 53", - "set firewall ipv4 name LOCAL rule 50 destination port 67", - "set firewall ipv4 name LOCAL rule 60 destination port 179", + ("mgmt-client", "10.10.10.1"), + ("platform-client", "10.10.30.1"), + ("cluster-client", "10.10.40.1"), ], - context="LOCAL rules", ) + def test_lab_can_ping_gateway(self, ping, client, gateway): + """Lab clients can ping the gateway (ICMP allowed in LOCAL ruleset).""" + assert ping(client, gateway), f"{client} should be able to ping gateway {gateway}" + + +class TestFirewallIsolation: + """Test that firewall properly isolates networks when expected.""" + + def test_established_connections_work(self, ping, test_topology): + """ + Verify stateful firewall allows return traffic. + + When a lab client initiates a connection to WAN, the return + traffic should be allowed through (established/related rule). + """ + # This is implicitly tested by test_lab_can_reach_wan, but let's + # make it explicit: if the lab client can ping WAN and get responses, + # then established/related traffic is working. + assert ping("mgmt-client", test_topology.wan_client_ip), ( + "Stateful firewall should allow return traffic" + ) diff --git a/infrastructure/network/vyos/tests/test_interfaces.py b/infrastructure/network/vyos/tests/test_interfaces.py deleted file mode 100644 index fb13df9..0000000 --- a/infrastructure/network/vyos/tests/test_interfaces.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Interface configuration tests for the VyOS gateway. -""" - - -def test_interface_addresses(config_commands, test_topology, assert_contains): - expected = [ - f"set interfaces ethernet {test_topology.wan_iface} address {test_topology.wan_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.mgmt_vif} address {test_topology.mgmt_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.prov_vif} address {test_topology.prov_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.platform_vif} address {test_topology.platform_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.cluster_vif} address {test_topology.cluster_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.service_vif} address {test_topology.service_ip}", - f"set interfaces ethernet {test_topology.trunk_iface} vif {test_topology.storage_vif} address {test_topology.storage_ip}", - ] - assert_contains(config_commands, expected, context="interface addresses") diff --git a/infrastructure/network/vyos/tests/test_nat.py b/infrastructure/network/vyos/tests/test_nat.py index a6c15e2..79c582e 100644 --- a/infrastructure/network/vyos/tests/test_nat.py +++ b/infrastructure/network/vyos/tests/test_nat.py @@ -1,30 +1,93 @@ """ -NAT configuration tests for the VyOS gateway. +NAT functional tests for the VyOS gateway. + +These tests verify that NAT masquerading actually translates +source addresses correctly. """ +import subprocess +import time + + +class TestSourceNat: + """Test source NAT (masquerade) functionality.""" + + def test_nat_masquerade_translates_source( + self, exec_on_client, test_topology + ): + """ + Traffic from lab networks appears with gateway's WAN IP on WAN side. + + This test captures traffic on wan-client while mgmt-client pings it, + verifying the source IP is the gateway's WAN IP (masqueraded). + """ + wan_client = f"{test_topology.container_prefix}-wan-client" + mgmt_client = f"{test_topology.container_prefix}-mgmt-client" + + # Start tcpdump on wan-client in background, capturing ICMP + tcpdump_proc = subprocess.Popen( + [ + "docker", "exec", wan_client, + "tcpdump", "-i", "eth1", "-c", "3", "-n", "icmp", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Give tcpdump a moment to start + time.sleep(1) + + # Send pings from mgmt-client to wan-client + subprocess.run( + ["docker", "exec", mgmt_client, "ping", "-c", "3", "-W", "2", + test_topology.wan_client_ip], + capture_output=True, + timeout=10, + ) -def test_source_nat_rule_exists(config_commands, assert_contains): - assert_contains( - config_commands, - ["set nat source rule 100"], - context="NAT rule", - ) + # Wait for tcpdump to finish and get output + try: + stdout, stderr = tcpdump_proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + tcpdump_proc.kill() + stdout, stderr = tcpdump_proc.communicate() + # Verify the captured packets show gateway WAN IP as source + # The original source (10.10.10.100) should NOT appear + assert test_topology.wan_ip in stdout, ( + f"Expected NAT'd source IP {test_topology.wan_ip} in capture, " + f"got: {stdout}" + ) + assert test_topology.mgmt_client_ip not in stdout, ( + f"Original source IP {test_topology.mgmt_client_ip} should not " + f"appear in WAN-side capture (NAT should hide it)" + ) -def test_masquerade_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [ - "set nat source rule 100 translation address masquerade", - f"set nat source rule 100 source address {test_topology.lab_cidr}", - ], - context="NAT masquerade", - ) + def test_nat_allows_bidirectional_traffic(self, ping, test_topology): + """ + NAT connection tracking allows return traffic. + When a lab client initiates to WAN, responses come back correctly. + """ + # If ping succeeds, it means: + # 1. Outbound packet was NAT'd (source changed to gateway WAN IP) + # 2. Return packet was correctly de-NAT'd back to original source + assert ping("mgmt-client", test_topology.wan_client_ip), ( + "NAT connection tracking should allow bidirectional traffic" + ) -def test_nat_outbound_interface(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set nat source rule 100 outbound-interface name {test_topology.wan_iface}"], - context="NAT outbound interface", - ) + def test_multiple_vlans_share_nat(self, ping, test_topology): + """All VLAN clients can use NAT to reach WAN.""" + clients = [ + "mgmt-client", + "prov-client", + "platform-client", + "cluster-client", + "service-client", + "storage-client", + ] + for client in clients: + assert ping(client, test_topology.wan_client_ip), ( + f"{client} should be able to reach WAN via NAT" + ) diff --git a/infrastructure/network/vyos/tests/test_operational.py b/infrastructure/network/vyos/tests/test_operational.py new file mode 100644 index 0000000..3d111af --- /dev/null +++ b/infrastructure/network/vyos/tests/test_operational.py @@ -0,0 +1,117 @@ +""" +Operational state tests for the VyOS gateway. + +These tests verify that VyOS operational state matches expectations +by running show commands and checking the output. +""" + +import pytest + + +class TestInterfaceState: + """Test interface operational state.""" + + def test_wan_interface_up(self, vyos_show, test_topology): + """WAN interface is operationally up.""" + output = vyos_show(f"show interfaces ethernet {test_topology.wan_iface}") + assert "up" in output.lower(), f"WAN interface {test_topology.wan_iface} is not up" + assert test_topology.wan_ip in output, ( + f"WAN interface missing IP {test_topology.wan_ip}" + ) + + def test_trunk_interface_up(self, vyos_show, test_topology): + """Trunk interface is operationally up.""" + output = vyos_show(f"show interfaces ethernet {test_topology.trunk_iface}") + assert "up" in output.lower(), ( + f"Trunk interface {test_topology.trunk_iface} is not up" + ) + + @pytest.mark.parametrize( + "vif,gateway_ip", + [ + ("10", "10.10.10.1"), + ("20", "10.10.20.1"), + ("30", "10.10.30.1"), + ("40", "10.10.40.1"), + ("50", "10.10.50.1"), + ("60", "10.10.60.1"), + ], + ) + def test_vlan_interface_up(self, vyos_show, test_topology, vif, gateway_ip): + """Each VLAN sub-interface is up with correct IP.""" + output = vyos_show( + f"show interfaces ethernet {test_topology.trunk_iface} vif {vif}" + ) + assert "up" in output.lower(), f"VLAN {vif} interface is not up" + assert gateway_ip in output, f"VLAN {vif} missing IP {gateway_ip}" + + +class TestRoutingState: + """Test routing table state.""" + + def test_default_route_present(self, vyos_show, test_topology): + """Default route exists via WAN gateway.""" + output = vyos_show("show ip route 0.0.0.0/0") + assert test_topology.wan_gateway in output, ( + f"Default route via {test_topology.wan_gateway} not found" + ) + + def test_connected_routes_present(self, vyos_show): + """Connected routes exist for all VLAN networks.""" + output = vyos_show("show ip route connected") + expected_networks = [ + "10.10.10.0/24", + "10.10.20.0/24", + "10.10.30.0/24", + "10.10.40.0/24", + "10.10.50.0/24", + "10.10.60.0/24", + ] + for network in expected_networks: + assert network in output, f"Connected route for {network} not found" + + +class TestBgpState: + """Test BGP operational state.""" + + def test_bgp_configured(self, vyos_show, test_topology): + """BGP is configured with correct AS number.""" + output = vyos_show("show bgp summary") + assert test_topology.bgp_local_as in output, ( + f"BGP AS {test_topology.bgp_local_as} not found in summary" + ) + + def test_bgp_router_id(self, vyos_show, test_topology): + """BGP router ID is configured correctly.""" + output = vyos_show("show bgp summary") + assert test_topology.bgp_router_id in output, ( + f"BGP router ID {test_topology.bgp_router_id} not found" + ) + + +class TestNatState: + """Test NAT operational state.""" + + def test_source_nat_rule_active(self, vyos_show): + """Source NAT rule 100 is active.""" + output = vyos_show("show nat source rules") + assert "100" in output, "NAT source rule 100 not found" + assert "masquerade" in output.lower(), "NAT masquerade not configured" + + +class TestFirewallState: + """Test firewall operational state.""" + + def test_firewall_rulesets_loaded(self, vyos_show): + """Firewall rulesets are loaded.""" + output = vyos_show("show firewall") + expected_rulesets = ["WAN_TO_LAB", "LAB_TO_WAN", "LOCAL"] + for ruleset in expected_rulesets: + assert ruleset in output, f"Firewall ruleset {ruleset} not found" + + def test_firewall_groups_exist(self, vyos_show): + """Firewall network groups are defined.""" + output = vyos_show("show firewall group") + expected_groups = ["HOME_NETWORK", "LAB_NETWORKS", "RFC1918"] + for group in expected_groups: + assert group in output, f"Firewall group {group} not found" diff --git a/infrastructure/network/vyos/tests/test_routing.py b/infrastructure/network/vyos/tests/test_routing.py deleted file mode 100644 index 56efc01..0000000 --- a/infrastructure/network/vyos/tests/test_routing.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Routing configuration tests for the VyOS gateway. -""" - - -def test_default_route_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [ - "set protocols static route 0.0.0.0/0", - f"set protocols static route 0.0.0.0/0 next-hop {test_topology.wan_gateway}", - ], - context="default route", - ) diff --git a/infrastructure/network/vyos/tests/test_services.py b/infrastructure/network/vyos/tests/test_services.py index 09bcb66..aff9e94 100644 --- a/infrastructure/network/vyos/tests/test_services.py +++ b/infrastructure/network/vyos/tests/test_services.py @@ -1,43 +1,76 @@ """ -Service configuration tests for the VyOS gateway. +Service functional tests for the VyOS gateway. + +These tests verify that gateway services (DNS, SSH) actually work, +not just that they are configured. + +Note: DHCP tests are not included because DHCP broadcast delivery through +VLAN subinterfaces doesn't work in containerlab's veth/bridge setup. This is +a fundamental Linux networking limitation - the bridge's frame processing +intercepts packets before the 8021q module can deliver them to subinterfaces. """ +import pytest -def test_dhcp_server_configured(config_commands, assert_contains): - assert_contains( - config_commands, - ["set service dhcp-server", "set service dhcp-server shared-network-name LAB_MGMT"], - context="DHCP server config", - ) +class TestDnsService: + """Test DNS forwarding service.""" -def test_dhcp_range_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [test_topology.dhcp_range_start, test_topology.dhcp_range_stop], - context="DHCP range", - ) + def test_dns_resolves_external_domain(self, dns_resolve, test_topology): + """ + DNS forwarding resolves external domains. + The gateway should forward DNS queries to upstream resolvers + and return valid responses. + """ + # Use a well-known domain that should always resolve + result = dns_resolve("mgmt-client", "one.one.one.one", test_topology.mgmt_gateway) + assert result is not None, ( + "DNS resolution via gateway failed for one.one.one.one" + ) + # Cloudflare's one.one.one.one resolves to 1.1.1.1 or 1.0.0.1 + assert result.startswith("1."), ( + f"Unexpected DNS result for one.one.one.one: {result}" + ) -def test_dns_forwarding_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - ["set service dns forwarding", f"set service dns forwarding allow-from {test_topology.lab_cidr}"], - context="DNS forwarding", + @pytest.mark.parametrize( + "client,dns_server", + [ + ("mgmt-client", "10.10.10.1"), + ("platform-client", "10.10.30.1"), + ], ) + def test_dns_available_on_multiple_interfaces( + self, dns_resolve, client, dns_server + ): + """DNS service is available on configured listen addresses.""" + result = dns_resolve(client, "cloudflare.com", dns_server) + assert result is not None, ( + f"DNS resolution failed from {client} via {dns_server}" + ) -def test_dns_listen_addresses(config_commands, test_topology, assert_contains): - expected = [ - f"set service dns forwarding listen-address {address}" - for address in test_topology.dns_listen_addresses - ] - assert_contains(config_commands, expected, context="DNS listen addresses") +class TestSshService: + """Test SSH service accessibility.""" + def test_ssh_port_open_from_lab(self, tcp_connect, test_topology): + """SSH port is accessible from lab networks.""" + assert tcp_connect("mgmt-client", test_topology.mgmt_gateway, 22), ( + "SSH port not accessible from mgmt-client" + ) -def test_ssh_service_enabled(config_commands, assert_contains): - assert_contains( - config_commands, - ["set service ssh", "set service ssh port 22"], - context="SSH service", - ) + def test_ssh_port_open_from_wan(self, tcp_connect, test_topology): + """SSH port is accessible from WAN (HOME_NETWORK).""" + assert tcp_connect("wan-client", test_topology.wan_ip, 22), ( + "SSH port not accessible from wan-client" + ) + + def test_ssh_connection_works(self, vyos_show): + """ + SSH connection actually works. + + This implicitly tests SSH by using the vyos_show fixture + which connects via SSH to run commands. + """ + output = vyos_show("show version") + assert "VyOS" in output, "SSH connection or VyOS not working properly" diff --git a/infrastructure/network/vyos/tests/test_system.py b/infrastructure/network/vyos/tests/test_system.py deleted file mode 100644 index 6aab44e..0000000 --- a/infrastructure/network/vyos/tests/test_system.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -System configuration tests for the VyOS gateway. -""" - - -def test_hostname_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set system host-name {test_topology.hostname}"], - context="hostname", - ) - - -def test_domain_name_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set system domain-name {test_topology.domain_name}"], - context="domain name", - ) - - -def test_name_servers_configured(config_commands, test_topology, assert_contains): - expected = [ - f"set system name-server {server}" for server in test_topology.name_servers - ] - assert_contains(config_commands, expected, context="name servers") - - -def test_timezone_configured(config_commands, test_topology, assert_contains): - assert_contains( - config_commands, - [f"set system time-zone {test_topology.time_zone}"], - context="time zone", - ) diff --git a/infrastructure/network/vyos/tests/topology.clab.yml b/infrastructure/network/vyos/tests/topology.clab.yml index 9bd2ec6..aeb206b 100644 --- a/infrastructure/network/vyos/tests/topology.clab.yml +++ b/infrastructure/network/vyos/tests/topology.clab.yml @@ -22,7 +22,7 @@ topology: # Run with systemd and extra capabilities for VyOS functionality cmd: /sbin/init binds: - - config.boot:/opt/vyatta/etc/config/config.boot:ro,Z + - config.boot:/opt/vyatta/etc/config/config.boot:rw,Z - /lib/modules:/lib/modules:ro cap-add: - NET_ADMIN @@ -35,13 +35,14 @@ topology: image: alpine:latest exec: - sh -c "ip link add br0 type bridge && ip link set br0 up" - - sh -c "for iface in eth1 eth2 eth3 eth4 eth5 eth6 eth7; do ip link set $iface up && ip link set $iface master br0; done" + - sh -c "for iface in eth1 eth2 eth3 eth4 eth5 eth6 eth7 eth8; do ip link set $iface up && ip link set $iface master br0; done" # WAN-side client (simulates home network / upstream) wan-client: kind: linux image: alpine:latest exec: + - apk add --no-cache tcpdump netcat-openbsd - sh -c "ip addr add 192.168.0.100/24 dev eth1 && ip link set eth1 up" - ip route replace default via 192.168.0.2 @@ -50,6 +51,7 @@ topology: kind: linux image: alpine:latest exec: + - apk add --no-cache bind-tools netcat-openbsd - sh -c "ip link set eth1 up && ip link add link eth1 name eth1.10 type vlan id 10 && ip addr add 10.10.10.100/24 dev eth1.10 && ip link set eth1.10 up" - ip route replace default via 10.10.10.1 @@ -66,6 +68,7 @@ topology: kind: linux image: alpine:latest exec: + - apk add --no-cache bind-tools - sh -c "ip link set eth1 up && ip link add link eth1 name eth1.30 type vlan id 30 && ip addr add 10.10.30.100/24 dev eth1.30 && ip link set eth1.30 up" - ip route replace default via 10.10.30.1 diff --git a/infrastructure/network/vyos/vyos-build/README.md b/infrastructure/network/vyos/vyos-build/README.md deleted file mode 100644 index 36478b0..0000000 --- a/infrastructure/network/vyos/vyos-build/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# VyOS Gateway Image Build - -This directory contains the configuration and scripts for building custom VyOS gateway images using the official `vyos-build` toolchain. - -## Overview - -This approach: - -1. Uses the official `vyos/vyos-build` Docker container -2. Bakes the gateway configuration directly into the image via build flavors -3. Produces a raw disk image suitable for Tinkerbell/NAS deployment -4. Injects SSH credentials from SOPS secrets at build time - -## Directory Structure - -``` -vyos-build/ -├── build-flavors/ -│ └── gateway.toml # Build flavor template with config.boot -├── scripts/ -│ └── generate-flavor.sh # Injects SSH credentials into flavor -└── README.md -``` - -## Build Process - -The GitHub Actions workflow (`.github/workflows/vyos-build.yml`) handles the full build: - -1. Decrypts SSH public key from `images/packer-ssh.sops.yaml` -2. Generates the final flavor TOML with credentials injected -3. Clones `vyos-build` repository -4. Runs the build in the `vyos/vyos-build:current` container -5. Uploads the resulting image to iDrive e2 - -### Local Build (for testing) - -```bash -# 1. Clone vyos-build -git clone -b current --single-branch https://github.com/vyos/vyos-build.git /tmp/vyos-build - -# 2. Generate flavor with SSH key -./scripts/generate-flavor.sh "ssh-ed25519 AAAA..." /tmp/vyos-build/data/build-flavors/gateway.toml - -# 3. Run build in container -docker run --rm -it --privileged \ - -v /tmp/vyos-build:/vyos \ - -v /dev:/dev \ - vyos/vyos-build:current bash - -# Inside container: -cd /vyos -sudo ./build-vyos-image --architecture amd64 --build-by "local@test" gateway - -# Output: /vyos/build/vyos-*.raw -``` - -## Configuration - -The `gateway.toml` flavor file contains: - -- **`image_format = "raw"`**: Output format for Tinkerbell deployment -- **`disk_size = 8`**: 8GB disk image -- **`default_config`**: Full VyOS configuration embedded in the image - -The configuration matches `infrastructure/network/vyos/configs/gateway.conf` with SSH credentials added via placeholders: -- `%%SSH_KEY_TYPE%%` - SSH key type (e.g., `ssh-ed25519`) -- `%%SSH_PUBLIC_KEY%%` - SSH public key body - -## Relationship to Other Files - -| File | Purpose | -|------|---------| -| `configs/gateway.conf` | Source of truth for VyOS config (Ansible applies updates) | -| `vyos-build/build-flavors/gateway.toml` | Build-time config with SSH credentials | diff --git a/infrastructure/network/vyos/vyos-build/scripts/generate-flavor.sh b/infrastructure/network/vyos/vyos-build/scripts/generate-flavor.sh deleted file mode 100755 index 398429c..0000000 --- a/infrastructure/network/vyos/vyos-build/scripts/generate-flavor.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -# Generate VyOS build flavor with SSH credentials from SOPS secrets -# -# Usage: -# ./generate-flavor.sh -# -# Arguments: -# ssh_public_key - Full SSH public key (e.g., "ssh-ed25519 AAAAC3Nz... comment") -# output_file - Path to write the generated flavor TOML -# -# Example: -# ./generate-flavor.sh "$(sops -d --extract '["ssh_public_key"]' images/packer-ssh.sops.yaml)" gateway-final.toml - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TEMPLATE_FILE="${SCRIPT_DIR}/../build-flavors/gateway.toml" - -usage() { - echo "Usage: $0 " - echo "" - echo "Arguments:" - echo " ssh_public_key - Full SSH public key string" - echo " output_file - Path for generated flavor TOML" - exit 1 -} - -if [[ $# -ne 2 ]]; then - usage -fi - -SSH_PUBLIC_KEY="$1" -OUTPUT_FILE="$2" - -# Validate inputs -if [[ -z "${SSH_PUBLIC_KEY}" ]]; then - echo "ERROR: SSH public key is required" - exit 1 -fi - -if [[ ! -f "${TEMPLATE_FILE}" ]]; then - echo "ERROR: Template file not found: ${TEMPLATE_FILE}" - exit 1 -fi - -# Parse SSH public key: "type key comment" -> extract type and key -SSH_KEY_TYPE=$(echo "${SSH_PUBLIC_KEY}" | awk '{print $1}') -SSH_KEY_BODY=$(echo "${SSH_PUBLIC_KEY}" | awk '{print $2}') - -if [[ -z "${SSH_KEY_TYPE}" ]] || [[ -z "${SSH_KEY_BODY}" ]]; then - echo "ERROR: Could not parse SSH public key" - echo "Expected format: 'type key [comment]'" - echo "Got: '${SSH_PUBLIC_KEY}'" - exit 1 -fi - -echo "=== Generating VyOS Build Flavor ===" -echo "SSH Key Type: ${SSH_KEY_TYPE}" -echo "SSH Key Length: ${#SSH_KEY_BODY} characters" -echo "Output: ${OUTPUT_FILE}" - -# Generate the final flavor by replacing placeholders -sed \ - -e "s|%%SSH_KEY_TYPE%%|${SSH_KEY_TYPE}|g" \ - -e "s|%%SSH_PUBLIC_KEY%%|${SSH_KEY_BODY}|g" \ - "${TEMPLATE_FILE}" > "${OUTPUT_FILE}" - -echo "=== Flavor generated successfully ==="