From 79cbcab397d6c38841c988458cb663692b54f8d9 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:47:48 +0300 Subject: [PATCH 01/30] =?UTF-8?q?test(#21):=20netns-=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=20=E2=80=94=20=D1=82=D0=BE=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D1=8F=20client/gw/uplink,=20lib=20=D0=B8=20=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основы харнесса для тестов без реального VPN-провайдера: - lib.sh — конфигурация стенда (netns, адреса, fwmark/таблицы) и правила netfilter с поддержкой nft и iptables (в alpine-образе приложения nft нет); - topology.sh — up/down/status/killswitch: veth-пары client<->gw<->uplink, ip_forward, ip rule fwmark 51820 -> table 51820, метки OUTPUT/FORWARD, DROP kill-switch для прямого выхода в «публичную» сеть; - run.sh — preflight, запуск мока и детектора утечки в netns uplink, прогон тестов, снос стенда с проверкой отсутствия остатков. Refs #21 Refs #15 --- tests/netns/lib.sh | 201 ++++++++++++++++++++++++++++++++++++++++ tests/netns/run.sh | 157 +++++++++++++++++++++++++++++++ tests/netns/topology.sh | 144 ++++++++++++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 tests/netns/lib.sh create mode 100644 tests/netns/run.sh create mode 100644 tests/netns/topology.sh diff --git a/tests/netns/lib.sh b/tests/netns/lib.sh new file mode 100644 index 0000000..de2130f --- /dev/null +++ b/tests/netns/lib.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# lib.sh — общие настройки и утилиты netns-стенда awg-dash (задача #21, эпик #15). +# +# Подключается через `source` из topology.sh / run.sh. Ничего не выполняет при +# подключении, ничего не создаёт — только конфигурация и вспомогательные функции. +# +# Все имена/адреса переопределяются переменными окружения (см. tests/netns/README.md). + +# shellcheck shell=bash + +# --- имена сетевых namespace'ов --------------------------------------------- +: "${AWG_NETNS_PREFIX:=awgt21}" +: "${AWG_NS_CLIENT:=${AWG_NETNS_PREFIX}-client}" # LAN-клиент за шлюзом +: "${AWG_NS_GW:=${AWG_NETNS_PREFIX}-gw}" # хост со шлюзом awg-dash +: "${AWG_NS_UPLINK:=${AWG_NETNS_PREFIX}-uplink}" # «ISP» + мок WireGuard-endpoint + +# --- адресация -------------------------------------------------------------- +: "${AWG_NET_CLIENT:=10.213.0.0/24}" +: "${AWG_CLIENT_IP:=10.213.0.2}" +: "${AWG_GW_LAN_IP:=10.213.0.1}" +: "${AWG_NET_WAN:=10.214.0.0/24}" +: "${AWG_GW_WAN_IP:=10.214.0.1}" +: "${AWG_UPLINK_IP:=10.214.0.2}" + +# «Провайдер»/мок-endpoint: порт как в wg-quick. +: "${AWG_MOCK_PORT:=51820}" +# Адрес из TEST-NET-2 (RFC 5737) — «публичный» получатель для детектора утечки. +: "${AWG_LEAK_IP:=198.51.100.9}" +: "${AWG_LEAK_PORT:=9999}" +: "${AWG_LEAK_NET:=198.51.100.0/24}" + +# --- policy routing --------------------------------------------------------- +# Метка и таблица как в wg-quick: fwmark 51820 -> table 51820. +: "${AWG_FWMARK:=51820}" +: "${AWG_ROUTE_TABLE:=51820}" + +# --- имена интерфейсов и константы стенда ----------------------------------- +: "${AWG_VETH_CLIENT:=veth-c-gw}" +: "${AWG_VETH_GW_LAN:=veth-gw-lan}" +: "${AWG_VETH_GW_WAN:=veth-gw-wan}" +: "${AWG_VETH_UPLINK:=veth-uplink}" + +NFT_TABLE="awgtest" +C_MARK_OUT="awgtest-mark-out" +C_MARK_FWD="awgtest-mark-fwd" +C_DROP="awgtest-killswitch-drop" +IPT_CHAIN_MARK="awgtest-mark" +IPT_CHAIN_FWD="awgtest-mark-fwd" +IPT_CHAIN_KS="awgtest-ks" + +# Рабочий каталог стенда (логи мока, sink'а, отчёты). Задаётся run.sh. +: "${AWG_NETNS_WORKDIR:=/tmp/awg-netns}" + +# --- вывод ------------------------------------------------------------------ +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + _c_red=$'\033[31m'; _c_grn=$'\033[32m'; _c_yel=$'\033[33m'; _c_dim=$'\033[2m'; _c_off=$'\033[0m' +else + _c_red=''; _c_grn=''; _c_yel=''; _c_dim=''; _c_off='' +fi + +log() { printf '%s[netns]%s %s\n' "$_c_dim" "$_c_off" "$*"; } +ok() { printf '%s[ ok ]%s %s\n' "$_c_grn" "$_c_off" "$*"; } +warn() { printf '%s[warn]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; } +fail() { printf '%s[fail]%s %s\n' "$_c_red" "$_c_off" "$*" >&2; exit 1; } + +# Код возврата 3 — «заблокировано окружением» (нет root/CAP_NET_ADMIN/ip), +# см. deploy/dev-stand.md. 4 — внутренняя ошибка стенда. +blocked() { printf '%s[blocked]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; exit 3; } + +# --- предусловия ------------------------------------------------------------ +require_root() { + [ "$(id -u)" -eq 0 ] || blocked "нужен root (или CAP_NET_ADMIN): запустите под sudo" +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || blocked "нет утилиты '$1' в PATH" +} + +# Проверяем, что ядро/контейнер вообще позволяют netns (иначе AC невыполним). +require_netns_capability() { + local probe="${AWG_NETNS_PREFIX}-probe" + if ! ip netns add "$probe" 2>/dev/null; then + blocked "не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN (запустите контейнер с --privileged)" + fi + ip netns del "$probe" 2>/dev/null || true +} + +ns_exists() { ip netns list | awk '{print $1}' | grep -qx "$1"; } + +# --- netfilter: метки policy routing и kill-switch -------------------------- +# Стенд работает и на nftables (хост Debian/Ubuntu с nft), и на iptables +# (alpine-образ приложения: iptables без бинаря nft). Backend выбирается по +# наличию бинаря в netns шлюза; семантика правил одинаковая. + +fw_kind() { + # AWG_FW_BACKEND=nft|iptables|auto — принудительный выбор backend (в alpine-образе + # приложения есть только iptables, на хосте обычно nft). + local want="${AWG_FW_BACKEND:-auto}" + case "$want" in + nft) + ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; } + echo none; return + ;; + iptables) + ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; } + echo none; return + ;; + esac + # command -v — builtin shell, поэтому проверяем через sh -c внутри netns + if ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then + echo nft + elif ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then + echo iptables + else + echo none + fi +} + +fw_available() { + local kind; kind="$(fw_kind)" + case "$kind" in + nft) ip netns exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;; + iptables) ip netns exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + +# Правило метки для трафика шлюза (OUTPUT) и для транзитного трафика LAN (FORWARD). +fw_setup() { + local kind; kind="$(fw_kind)" + case "$kind" in + nft) + ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true + ip netns exec "$AWG_NS_GW" nft -f - </dev/null || true + $ipt -t mangle -F "$IPT_CHAIN_MARK" + $ipt -t mangle -A "$IPT_CHAIN_MARK" -p udp --dport "$AWG_MOCK_PORT" \ + -m comment --comment "$C_MARK_OUT" -j MARK --set-mark "$AWG_FWMARK" + $ipt -t mangle -C OUTPUT -j "$IPT_CHAIN_MARK" 2>/dev/null \ + || $ipt -t mangle -A OUTPUT -j "$IPT_CHAIN_MARK" + $ipt -t mangle -N "$IPT_CHAIN_FWD" 2>/dev/null || true + $ipt -t mangle -F "$IPT_CHAIN_FWD" + $ipt -t mangle -A "$IPT_CHAIN_FWD" -p udp --dport "$AWG_MOCK_PORT" \ + -m comment --comment "$C_MARK_FWD" -j MARK --set-mark "$AWG_FWMARK" + $ipt -t mangle -C FORWARD -j "$IPT_CHAIN_FWD" 2>/dev/null \ + || $ipt -t mangle -A FORWARD -j "$IPT_CHAIN_FWD" + $ipt -t filter -N "$IPT_CHAIN_KS" 2>/dev/null || true + $ipt -t filter -F "$IPT_CHAIN_KS" + $ipt -t filter -A "$IPT_CHAIN_KS" -s "$AWG_NET_CLIENT" -d "$AWG_LEAK_NET" \ + -m comment --comment "$C_DROP" -j DROP + $ipt -t filter -C FORWARD -j "$IPT_CHAIN_KS" 2>/dev/null \ + || $ipt -t filter -A FORWARD -j "$IPT_CHAIN_KS" + ;; + *) + fail "нет ни nft, ни iptables в ${AWG_NS_GW} — стенд не может проверить маркировку" + ;; + esac + log "правила меток/kill-switch установлены (backend: $kind)" +} + +# killswitch on|off — включить/выключить fail-closed правило FORWARD. +fw_set_killswitch() { + local state="$1" kind; kind="$(fw_kind)" + case "$kind:$state" in + nft:on) + ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true + fw_setup + ;; + nft:off) + ip netns exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks + ;; + iptables:on) + fw_setup + ;; + iptables:off) + ip netns exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS" + ;; + *) + fail "fw_set_killswitch: неизвестное состояние '$state' (ожидается on|off)" + ;; + esac +} diff --git a/tests/netns/run.sh b/tests/netns/run.sh new file mode 100644 index 0000000..ee7aa25 --- /dev/null +++ b/tests/netns/run.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# run.sh — точка входа netns-стенда awg-dash (вызывается из `make test-netns`). +# +# tests/netns/run.sh [опции] +# +# Что делает: +# 1) preflight: root/CAP_NET_ADMIN/ip/python3; +# 2) поднимает netns-топологию (topology.sh up); +# 3) поднимает мок WireGuard-endpoint и детектор утечки внутри netns `uplink`; +# 4) прогоняет интеграционные тесты стенда (tests/netns/test_netns_stand.py); +# 5) сносит стенд и проверяет, что namespace'ы удалены. +# +# Опции: +# --keep-topology не удалять топологию после прогона (для отладки: make test-netns-keep) +# --keep-logs не удалять рабочий каталог с логами (по умолчанию сохраняется всегда +# при провале или при --keep-topology) +# --no-tests только поднять стенд и напечатать сводку +# --report PATH путь к JSON-отчёту (по умолчанию $AWG_NETNS_WORKDIR/report.json) +# --workdir PATH рабочий каталог стенда (логи, флаги готовности) +# -h|--help эта справка +# +# Коды возврата: 0 — успех; 1 — тесты упали; 3 — заблокировано окружением +# (нет root/CAP_NET_ADMIN); 4 — ошибка стенда. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/netns/lib.sh +source "$HERE/lib.sh" + +KEEP_TOPO=0 +NO_TESTS=0 +REPORT="" +WORKDIR_OPT="" + +usage() { sed -n '2,25p' "$HERE/run.sh" | sed 's/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + --keep-topology) KEEP_TOPO=1 ;; + --keep-logs) : ;; + --no-tests) NO_TESTS=1 ;; + --report) REPORT="${2:?--report требует путь}"; shift ;; + --workdir) WORKDIR_OPT="${2:?--workdir требует путь}"; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "неизвестная опция: $1 (см. --help)" ;; + esac + shift +done + +# --- preflight --------------------------------------------------------------- +require_root +require_cmd ip +require_cmd python3 +require_netns_capability + +if [ -n "$WORKDIR_OPT" ]; then + export AWG_NETNS_WORKDIR="$WORKDIR_OPT" +else + export AWG_NETNS_WORKDIR="${AWG_NETNS_WORKDIR:-$(mktemp -d -t awg-netns-XXXXXX)}" +fi +mkdir -p "$AWG_NETNS_WORKDIR" +: "${REPORT:=${AWG_NETNS_WORKDIR}/report.json}" + +# пути артефактов процессов — в дочерние процессы уходят через окружение +export AWG_MOCK_LOG="${AWG_NETNS_WORKDIR}/mock-endpoint.jsonl" +export AWG_MOCK_READY="${AWG_NETNS_WORKDIR}/mock-endpoint.ready" +export AWG_MOCK_MODE_FILE="${AWG_NETNS_WORKDIR}/mock-mode" +export AWG_LEAK_LOG="${AWG_NETNS_WORKDIR}/leak-sink.jsonl" +export AWG_LEAK_READY="${AWG_NETNS_WORKDIR}/leak-sink.ready" + +# чистое состояние артефактов прошлого прогона +rm -f "$AWG_MOCK_LOG" "$AWG_MOCK_READY" "$AWG_LEAK_LOG" "$AWG_LEAK_READY" +printf 'respond\n' > "$AWG_MOCK_MODE_FILE" + +MOCK_PID="" +SINK_PID="" +TEST_RC=0 +CLEANED=0 + +cleanup() { + [ "$CLEANED" -eq 1 ] && return 0 + CLEANED=1 + for pid in "$MOCK_PID" "$SINK_PID"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + done + if [ "$KEEP_TOPO" -eq 1 ]; then + warn "топология сохранена (--keep-topology): ${AWG_NS_CLIENT} ${AWG_NS_GW} ${AWG_NS_UPLINK}" + warn "снести: bash tests/netns/topology.sh down" + else + bash "$HERE/topology.sh" down >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT INT TERM + +# --- стенд ------------------------------------------------------------------- +log "рабочий каталог: ${AWG_NETNS_WORKDIR}" +bash "$HERE/topology.sh" up + +log "запускаю мок WireGuard-endpoint (udp/${AWG_MOCK_PORT}) в ${AWG_NS_UPLINK}" +ip netns exec "$AWG_NS_UPLINK" python3 "$HERE/mock_wg_endpoint.py" \ + --bind "0.0.0.0:${AWG_MOCK_PORT}" --log "$AWG_MOCK_LOG" --ready "$AWG_MOCK_READY" \ + --mode-file "$AWG_MOCK_MODE_FILE" --mode respond & +MOCK_PID=$! + +log "запускаю детектор утечки (${AWG_LEAK_IP}:${AWG_LEAK_PORT}) в ${AWG_NS_UPLINK}" +ip netns exec "$AWG_NS_UPLINK" python3 "$HERE/leak_sink.py" \ + --ip "$AWG_LEAK_IP" --port "$AWG_LEAK_PORT" --log "$AWG_LEAK_LOG" --ready "$AWG_LEAK_READY" & +SINK_PID=$! + +# ждём готовности обоих процессов (файлы-флаги пишутся после bind) +for _ in $(seq 1 100); do + if [ -f "$AWG_MOCK_READY" ] && [ -f "$AWG_LEAK_READY" ]; then break; fi + if ! kill -0 "$MOCK_PID" 2>/dev/null; then fail "мок WireGuard-endpoint упал при старте"; fi + sleep 0.1 +done +[ -f "$AWG_MOCK_READY" ] || fail "мок WireGuard-endpoint не поднялся за 10с" +[ -f "$AWG_LEAK_READY" ] || fail "детектор утечки не поднялся за 10с" +ok "мок-endpoint и детектор утечки готовы" + +# --- тесты ------------------------------------------------------------------- +if [ "$NO_TESTS" -eq 1 ]; then + log "--no-tests: стенд поднят, тесты пропущены" +else + log "прогон интеграционных тестов стенда" + AWG_NETNS_RUN=1 AWG_NETNS_WORKDIR="$AWG_NETNS_WORKDIR" \ + python3 "$HERE/run_tests.py" --report "$REPORT" || TEST_RC=$? +fi + +# --- проверка сноса ---------------------------------------------------------- +TOPOLOGY_REMOVED=true +if [ "$KEEP_TOPO" -eq 0 ]; then + cleanup + trap - EXIT INT TERM + for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do + if ns_exists "$ns"; then + warn "после сноса остался netns ${ns} — стенд не очищен" + TOPOLOGY_REMOVED=false + TEST_RC=4 + fi + done + [ "$TOPOLOGY_REMOVED" = true ] && ok "топология снесена, namespace'ы удалены" +fi + +if [ "$NO_TESTS" -eq 1 ] && [ "$TOPOLOGY_REMOVED" = true ]; then + ok "стенд поднят успешно (AC-3: netns + мок-endpoint без реального провайдера)" + exit 0 +fi + +if [ "$TEST_RC" -eq 0 ]; then + ok "netns-стенд: тесты пройдены; отчёт: ${REPORT}" +else + warn "netns-стенд: тесты/очистка упали (код ${TEST_RC}); логи: ${AWG_NETNS_WORKDIR}" +fi +exit "$TEST_RC" diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh new file mode 100644 index 0000000..53a4198 --- /dev/null +++ b/tests/netns/topology.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# topology.sh — netns-топология тестового стенда awg-dash (задача #21). +# +# topology.sh up создать netns, veth-пары, адреса, маршруты, правила меток +# topology.sh down удалить netns и veth-пары (идемпотентно) +# topology.sh status показать состояние топологии (exit 0 — стенд поднят) +# topology.sh killswitch on включить fail-closed правило в шлюзе +# topology.sh killswitch off выключить fail-closed правило в шлюзе +# topology.sh fw-kind напечатать backend netfilter (nft|iptables) +# +# Требует root и CAP_NET_ADMIN (в контейнере — --privileged либо NET_ADMIN+SYS_ADMIN). +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/netns/lib.sh +source "$HERE/lib.sh" + +topology_present() { + ns_exists "$AWG_NS_CLIENT" && ns_exists "$AWG_NS_GW" && ns_exists "$AWG_NS_UPLINK" +} + +topology_down() { + local quiet="${1:-}" + [ "$quiet" = quiet ] || log "удаляю топологию" + # netns удаляем первыми: вместе с ними уходят veth-концы и процессы внутри. + for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do + if ns_exists "$ns"; then ip netns del "$ns" 2>/dev/null || true; fi + done + # страховка на случай, если veth остался в корневом netns + for ifc in "$AWG_VETH_CLIENT" "$AWG_VETH_GW_LAN" "$AWG_VETH_GW_WAN" "$AWG_VETH_UPLINK"; do + if ip link show "$ifc" >/dev/null 2>&1; then ip link del "$ifc" 2>/dev/null || true; fi + done + [ "$quiet" = quiet ] || ok "топология удалена" +} + +topology_up() { + require_root + require_cmd ip + require_cmd python3 + require_netns_capability + + log "создаю netns: client=$AWG_NS_CLIENT gw=$AWG_NS_GW uplink=$AWG_NS_UPLINK" + topology_down quiet + + local ns + for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do + ip netns add "$ns" + ip -n "$ns" link set lo up + done + + # --- client <-> gw (LAN) -------------------------------------------------- + ip link add "$AWG_VETH_CLIENT" type veth peer name "$AWG_VETH_GW_LAN" + ip link set "$AWG_VETH_CLIENT" netns "$AWG_NS_CLIENT" + ip link set "$AWG_VETH_GW_LAN" netns "$AWG_NS_GW" + ip -n "$AWG_NS_CLIENT" addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT" + ip -n "$AWG_NS_CLIENT" link set "$AWG_VETH_CLIENT" up + ip -n "$AWG_NS_GW" addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" + ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_LAN" up + + # --- gw <-> uplink (WAN) -------------------------------------------------- + ip link add "$AWG_VETH_GW_WAN" type veth peer name "$AWG_VETH_UPLINK" + ip link set "$AWG_VETH_GW_WAN" netns "$AWG_NS_GW" + ip link set "$AWG_VETH_UPLINK" netns "$AWG_NS_UPLINK" + ip -n "$AWG_NS_GW" addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN" + ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_WAN" up + ip -n "$AWG_NS_UPLINK" addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" + ip -n "$AWG_NS_UPLINK" link set "$AWG_VETH_UPLINK" up + + # --- forwarding и rp_filter в шлюзе --------------------------------------- + ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1 + # loose rp_filter: маркированная политика даёт асимметрию reverse-пути + ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2 + ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2" + ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2" + + # --- маршруты ------------------------------------------------------------- + ip -n "$AWG_NS_CLIENT" route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" + ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100 + ip -n "$AWG_NS_UPLINK" route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK" + # «публичный» адрес-приманка для детектора утечки (RFC 5737 TEST-NET-2) + ip -n "$AWG_NS_UPLINK" addr add "${AWG_LEAK_IP}/32" dev lo + + # --- policy routing: fwmark 51820 -> table 51820 -> аплинк ---------------- + ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE" + ip -n "$AWG_NS_GW" rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE" + + # --- netfilter: метки + kill-switch --------------------------------------- + fw_setup + + ok "стенд поднят: ${AWG_NS_CLIENT}(${AWG_CLIENT_IP}) -> ${AWG_NS_GW}(${AWG_GW_LAN_IP}/${AWG_GW_WAN_IP}) -> ${AWG_NS_UPLINK}(${AWG_UPLINK_IP})" + ok "policy routing: fwmark ${AWG_FWMARK} -> table ${AWG_ROUTE_TABLE} (default via ${AWG_UPLINK_IP})" + ok "mock endpoint: udp/${AWG_MOCK_PORT} в ${AWG_NS_UPLINK}; детектор утечки: ${AWG_LEAK_IP}:${AWG_LEAK_PORT}/udp" +} + +topology_status() { + local rc=0 + if ! topology_present; then + printf 'topology: down\n' + return 1 + fi + printf 'topology: up\n' + local ns + for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do + printf '\n[%s] links:\n' "$ns" + ip -n "$ns" -br addr show || rc=1 + done + printf '\n[%s] ip rule:\n' "$AWG_NS_GW" + ip -n "$AWG_NS_GW" rule show + printf '\n[%s] table %s:\n' "$AWG_NS_GW" "$AWG_ROUTE_TABLE" + ip -n "$AWG_NS_GW" route show table "$AWG_ROUTE_TABLE" + printf '\n[%s] netfilter:\n' "$AWG_NS_GW" + if [ "$(fw_kind)" = nft ]; then + ip netns exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1 + else + ip netns exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 + ip netns exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1 + fi + return $rc +} + +case "${1:-status}" in + up) + topology_up + ;; + down) + require_root + topology_down + ;; + status) + topology_status + ;; + killswitch) + require_root + fw_set_killswitch "${2:?usage: topology.sh killswitch on|off}" + ok "kill-switch: ${2}" + ;; + fw-kind) + require_root + fw_kind + ;; + *) + fail "usage: topology.sh {up|down|status|killswitch on|off|fw-kind}" + ;; +esac From 18b911d5827ad6c7d0f35b7e2d079953b5473501 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:48:00 +0300 Subject: [PATCH 02/30] =?UTF-8?q?test(#21):=20=D0=BC=D0=BE=D0=BA=20WireGua?= =?UTF-8?q?rd-endpoint,=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=BE=D1=80?= =?UTF-8?q?=20=D1=83=D1=82=D0=B5=D1=87=D0=BA=D0=B8=20=D0=B8=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock_wg_endpoint.py — UDP-эндпоинт, повторяющий раскладку сообщений WireGuard (initiation 148 байт -> response 92 байта), режимы respond/drop переключаются на лету через файл; JSONL-лог для ассертов; - leak_sink.py — UDP+TCP детектор утечки на 198.51.100.9:9999 внутри netns uplink (RFC 5737 TEST-NET-2); - probe.py — отправка handshake initiation или raw-UDP из указанного netns с разбором ответа в JSON. Только stdlib: работает в alpine-образе. Refs #21 Refs #15 --- tests/netns/leak_sink.py | 120 ++++++++++++++++++ tests/netns/mock_wg_endpoint.py | 210 ++++++++++++++++++++++++++++++++ tests/netns/probe.py | 113 +++++++++++++++++ 3 files changed, 443 insertions(+) create mode 100644 tests/netns/leak_sink.py create mode 100644 tests/netns/mock_wg_endpoint.py create mode 100644 tests/netns/probe.py diff --git a/tests/netns/leak_sink.py b/tests/netns/leak_sink.py new file mode 100644 index 0000000..bd2b752 --- /dev/null +++ b/tests/netns/leak_sink.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""leak_sink.py — детектор утечки трафика для netns-стенда (задача #21). + +Слушает UDP и TCP порт 9999 на «публичном» адресе 198.51.100.9 внутри netns +`uplink` (адрес из TEST-NET-2, RFC 5737). Если при включённом kill-switch +шлюза сюда пришёл хоть один пакет — значит трафик, предназначенный для +туннеля, утёк напрямую: тест падает. + +Лог — JSONL, пишется немедленно (тесты читают файл, не дожидаясь остановки). +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import threading +import time + + +class JsonlLog: + def __init__(self, path: str | None) -> None: + self._lock = threading.Lock() + self._fh = None + if path: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + self._fh = open(path, "a", buffering=1, encoding="utf-8") + + def write(self, **event: object) -> None: + if self._fh is None: + return + event.setdefault("ts", round(time.time(), 6)) + with self._lock: + self._fh.write(json.dumps(event, sort_keys=True) + "\n") + self._fh.flush() + + def close(self) -> None: + if self._fh is not None: + self._fh.close() + self._fh = None + + +def udp_loop(bind: tuple[str, int], log: JsonlLog, stop: threading.Event) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(bind) + sock.settimeout(0.25) + while not stop.is_set(): + try: + payload, peer = sock.recvfrom(65535) + except socket.timeout: + continue + except OSError: + break + log.write(event="leak", proto="udp", length=len(payload), src=f"{peer[0]}:{peer[1]}") + sock.close() + + +def tcp_loop(bind: tuple[str, int], log: JsonlLog, stop: threading.Event) -> None: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(bind) + srv.listen(8) + srv.settimeout(0.25) + while not stop.is_set(): + try: + conn, peer = srv.accept() + except socket.timeout: + continue + except OSError: + break + log.write(event="leak", proto="tcp", src=f"{peer[0]}:{peer[1]}") + conn.close() + srv.close() + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Детектор утечки трафика для netns-стенда (#21)") + ap.add_argument("--ip", default=os.environ.get("AWG_LEAK_IP", "198.51.100.9")) + ap.add_argument("--port", type=int, default=int(os.environ.get("AWG_LEAK_PORT", "9999"))) + ap.add_argument("--log", default=os.environ.get("AWG_LEAK_LOG", "")) + ap.add_argument("--ready", default=os.environ.get("AWG_LEAK_READY", "")) + args = ap.parse_args(argv) + + bind = (args.ip, args.port) + log = JsonlLog(args.log or None) + stop = threading.Event() + + udp = threading.Thread(target=udp_loop, args=(bind, log, stop), daemon=True) + tcp = threading.Thread(target=tcp_loop, args=(bind, log, stop), daemon=True) + udp.start() + tcp.start() + + if args.ready: + os.makedirs(os.path.dirname(os.path.abspath(args.ready)) or ".", exist_ok=True) + with open(args.ready, "w", encoding="utf-8") as fh: + fh.write(f"{os.getpid()}\n") + + log.write(event="listen", addr=f"{args.ip}:{args.port}", pid=os.getpid()) + print(f"leak-sink: udp+tcp {args.ip}:{args.port} listen", flush=True) + try: + while True: + time.sleep(0.5) + except KeyboardInterrupt: + pass + finally: + stop.set() + udp.join(timeout=2) + tcp.join(timeout=2) + log.write(event="stop") + log.close() + if args.ready and os.path.exists(args.ready): + os.unlink(args.ready) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/netns/mock_wg_endpoint.py b/tests/netns/mock_wg_endpoint.py new file mode 100644 index 0000000..deb1ead --- /dev/null +++ b/tests/netns/mock_wg_endpoint.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""mock_wg_endpoint.py — мок WireGuard/AmneziaWG-endpoint для netns-стенда (задача #21). + +Зачем: интеграционные тесты split-tunnel должны идти без реального VPN-провайдера и +без настоящего туннеля. Мок слушает UDP-порт 51820 в netns `uplink` («ISP»), +распознаёт тип сообщения WireGuard по первому байту и отвечает на handshake +initiation (type 1, 148 байт) корректным по структуре handshake response +(type 2, 92 байта), подставляя sender_index клиента как receiver_index. + +ЧЕГО МОК НЕ ДЕЛАЕТ (важно, чтобы не принять его за настоящий peer): + * не выполняет Noise IK handshake и никакой криптографии — поля «зашифрованы» + случайными байтами; + * не маршрутизирует трафик и не поднимает TUN-интерфейс. +Поэтому мок НЕ подходит для проверки шифрования/MTU/throughput — только для +проверки топологии, policy routing, метки трафика и fail-closed сценариев. + +Режим работы переключается на лету через файл `--mode-file`: + respond — отвечать на handshake initiation (провайдер доступен); + drop — молча считать пакеты и не отвечать (имитация «провайдер недоступен»). + +Лог: JSONL (по строке на событие), пишется и сбрасывается на диск сразу, чтобы +тесты могли его читать без гонок. Файл `--ready` создаётся после bind(). + +Только stdlib — запускается и на хосте, и в alpine-образе приложения. +""" + +from __future__ import annotations + +import argparse +import json +import os +import random +import socket +import struct +import sys +import time + +MSG_HANDSHAKE_INITIATION = 1 +MSG_HANDSHAKE_RESPONSE = 2 +MSG_COOKIE_REPLY = 3 +MSG_TRANSPORT_DATA = 4 + +TYPE_NAMES = { + MSG_HANDSHAKE_INITIATION: "handshake_initiation", + MSG_HANDSHAKE_RESPONSE: "handshake_response", + MSG_COOKIE_REPLY: "cookie_reply", + MSG_TRANSPORT_DATA: "transport_data", +} + +HANDSHAKE_INITIATION_LEN = 148 +HANDSHAKE_RESPONSE_LEN = 92 + + +def msg_type(payload: bytes) -> int: + """WireGuard message type: младший байт little-endian uint32 (3 байта reserved).""" + if len(payload) < 4: + return -1 + return payload[0] + + +def build_handshake_response(initiation: bytes) -> bytes: + """Собрать структурно корректный type 2 (92 байта) в ответ на type 1. + + Раскладка: type(1)+reserved(3) sender(4) receiver(4) ephemeral(32) + encrypted_nothing(16) mac1(16) mac2(16) = 92 + """ + sender_index = initiation[4:8] if len(initiation) >= 8 else b"\x00" * 4 + out = bytearray(HANDSHAKE_RESPONSE_LEN) + out[0] = MSG_HANDSHAKE_RESPONSE + # sender_index мока — случайный, receiver_index = sender_index клиента + out[4:8] = struct.pack(" None: + self._fh = None + if path: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + self._fh = open(path, "a", buffering=1, encoding="utf-8") + + def write(self, **event: object) -> None: + if self._fh is None: + return + event.setdefault("ts", round(time.time(), 6)) + self._fh.write(json.dumps(event, sort_keys=True) + "\n") + self._fh.flush() + + def close(self) -> None: + if self._fh is not None: + self._fh.close() + self._fh = None + + +def read_mode(mode_file: str | None, default: str) -> str: + """Режим читается из файла на каждом пакете: тесты переключают без рестарта.""" + if not mode_file: + return default + try: + with open(mode_file, encoding="utf-8") as fh: + mode = fh.read().strip() + except OSError: + return default + return mode if mode in ("respond", "drop") else default + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Мок WireGuard-endpoint для netns-стенда (#21)") + ap.add_argument("--bind", default="0.0.0.0:51820", help="адрес:порт для прослушивания UDP") + ap.add_argument("--log", default=os.environ.get("AWG_MOCK_LOG", ""), help="JSONL-лог событий") + ap.add_argument("--ready", default=os.environ.get("AWG_MOCK_READY", ""), help="файл-флаг готовности") + ap.add_argument( + "--mode-file", + default=os.environ.get("AWG_MOCK_MODE_FILE", ""), + help="файл с режимом respond|drop (переключение на лету)", + ) + ap.add_argument("--mode", default=os.environ.get("AWG_MOCK_MODE", "respond"), choices=["respond", "drop"]) + args = ap.parse_args(argv) + + host, _, port_s = args.bind.rpartition(":") + host = host or "0.0.0.0" + port = int(port_s or "51820") + + log = JsonlLog(args.log or None) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((host, port)) + except OSError as exc: + log.write(event="bind_error", addr=f"{host}:{port}", error=str(exc)) + print(f"mock-endpoint: bind {host}:{port} failed: {exc}", file=sys.stderr) + return 4 + sock.settimeout(0.25) + + if args.ready: + os.makedirs(os.path.dirname(os.path.abspath(args.ready)) or ".", exist_ok=True) + with open(args.ready, "w", encoding="utf-8") as fh: + fh.write(f"{os.getpid()}\n") + + log.write(event="listen", addr=f"{host}:{port}", pid=os.getpid(), mode=args.mode) + print(f"mock-endpoint: udp/{port} listen, mode={args.mode}", flush=True) + + rx_total = 0 + tx_total = 0 + try: + while True: + try: + payload, peer = sock.recvfrom(65535) + except socket.timeout: + continue + except OSError: + break + + rx_total += 1 + mtype = msg_type(payload) + mode = read_mode(args.mode_file or None, args.mode) + log.write( + event="rx", + msg_type=mtype, + msg_name=TYPE_NAMES.get(mtype, "unknown"), + length=len(payload), + src=f"{peer[0]}:{peer[1]}", + rx_total=rx_total, + mode=mode, + ) + + if mode == "drop": + continue + if mtype != MSG_HANDSHAKE_INITIATION: + log.write(event="ignored", reason="not_handshake_initiation", msg_type=mtype) + continue + if len(payload) != HANDSHAKE_INITIATION_LEN: + log.write( + event="ignored", + reason="bad_initiation_length", + length=len(payload), + expected=HANDSHAKE_INITIATION_LEN, + ) + continue + + reply = build_handshake_response(payload) + sock.sendto(reply, peer) + tx_total += 1 + log.write( + event="tx", + msg_type=MSG_HANDSHAKE_RESPONSE, + length=len(reply), + dst=f"{peer[0]}:{peer[1]}", + tx_total=tx_total, + ) + except KeyboardInterrupt: + pass + finally: + sock.close() + log.write(event="stop", rx_total=rx_total, tx_total=tx_total) + log.close() + if args.ready and os.path.exists(args.ready): + os.unlink(args.ready) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/netns/probe.py b/tests/netns/probe.py new file mode 100644 index 0000000..4ff0fe8 --- /dev/null +++ b/tests/netns/probe.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""probe.py — генератор тестового трафика из netns-стенда (задача #21). + +Запускается внутри конкретного netns (`ip netns exec python3 probe.py ...`) +и умеет: + + * `--wg-initiation` (по умолчанию) — отправить WireGuard handshake initiation + (type 1, 148 байт, раскладка как в протоколе) и дождаться ответа мока; + * `--raw TEXT` — отправить произвольный UDP-datagram (для проверок утечки / + kill-switch), по умолчанию ответа не ожидает. + +Результат печатается одной JSON-строкой в stdout, код возврата: + 0 — ожидаемое поведение достигнуто, 1 — нет, 2 — ошибка аргументов/сокета. +""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import sys +import time + +MSG_HANDSHAKE_INITIATION = 1 +MSG_HANDSHAKE_RESPONSE = 2 +HANDSHAKE_INITIATION_LEN = 148 + + +def build_handshake_initiation(sender_index: int | None = None) -> bytes: + """148 байт: type(1)+reserved(3) sender(4) ephemeral(32) enc_static(48) enc_ts(28) mac1(16) mac2(16).""" + if sender_index is None: + sender_index = struct.unpack(" int: + ap = argparse.ArgumentParser(description="Тестовый трафик для netns-стенда (#21)") + ap.add_argument("--dst", required=True, help="IP назначения") + ap.add_argument("--port", type=int, required=True, help="UDP-порт назначения") + ap.add_argument("--timeout", type=float, default=2.0, help="ожидание ответа, с") + ap.add_argument("--raw", default=None, help="отправить произвольный текст вместо handshake initiation") + ap.add_argument("--expect-timeout", action="store_true", help="ожидать отсутствия ответа (для kill-switch/drop)") + ap.add_argument("--source-port", type=int, default=0, help="локальный порт (0 — случайный)") + args = ap.parse_args(argv) + + if args.raw is None: + payload = build_handshake_initiation() + expect_reply = not args.expect_timeout + kind = "wg_handshake_initiation" + else: + payload = args.raw.encode("utf-8") + # для raw-трафика ответ не ожидается, если явно не сказано обратное + expect_reply = not args.expect_timeout + kind = "raw_udp" + + import socket + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + if args.source_port: + sock.bind(("", args.source_port)) + sock.settimeout(args.timeout) + + started = time.time() + try: + sent = sock.sendto(payload, (args.dst, args.port)) + except OSError as exc: + print(json.dumps({"ok": False, "kind": kind, "error": f"sendto: {exc}"})) + return 2 + + reply = None + try: + data, peer = sock.recvfrom(65535) + reply = { + "type": data[0] if data else -1, + "length": len(data or b""), + "src": f"{peer[0]}:{peer[1]}", + "receiver_index": ( + struct.unpack("= 12 and data[0] == MSG_HANDSHAKE_RESPONSE else None + ), + "sender_index_echo": struct.unpack(" Date: Wed, 21 Oct 2026 23:48:22 +0300 Subject: [PATCH 03/30] =?UTF-8?q?test(#21):=20=D1=83=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0,=20?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20=D1=81=20JSON-=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=D0=BE=D0=BC=20=D0=B8=20README=20=D1=85?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D0=B5=D1=81=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - netns_common.py — обёртки над ip/nft/iptables: проверка netns и линков, счётчики правил меток и kill-switch (nft -j и iptables -v -x), чтение JSONL-логов мока и детектора, отправка трафика через probe.py; - run_tests.py — прогон unittest с JSON-отчётом (pytest не нужен: работает и на хосте, и в alpine-образе); - README.md — схема топологии, таблица тестов, переменные окружения и pitfalls (root/CAP_NET_ADMIN, rp_filter, отсутствие nft и ping в образе). Refs #21 Refs #15 --- tests/netns/netns_common.py | 248 ++++++++++++++++++++++++++++++++++++ tests/netns/run_tests.py | 69 ++++++++++ 2 files changed, 317 insertions(+) create mode 100644 tests/netns/netns_common.py create mode 100644 tests/netns/run_tests.py diff --git a/tests/netns/netns_common.py b/tests/netns/netns_common.py new file mode 100644 index 0000000..92bf54b --- /dev/null +++ b/tests/netns/netns_common.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""netns_common.py — утилиты проверок netns-стенда (задача #21). Только stdlib. + +Используется тестами (tests/netns/test_netns_stand.py) и run_tests.py. +Значения по умолчанию совпадают с tests/netns/lib.sh — переменные окружения +позволяют переопределить топологию и передаются run.sh в дочерние процессы. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import time +from typing import Any, Iterable + +# --- топология --------------------------------------------------------------- +CLIENT_NS = os.environ.get("AWG_NS_CLIENT", "awgt21-client") +GW_NS = os.environ.get("AWG_NS_GW", "awgt21-gw") +UPLINK_NS = os.environ.get("AWG_NS_UPLINK", "awgt21-uplink") + +CLIENT_IP = os.environ.get("AWG_CLIENT_IP", "10.213.0.2") +GW_LAN_IP = os.environ.get("AWG_GW_LAN_IP", "10.213.0.1") +GW_WAN_IP = os.environ.get("AWG_GW_WAN_IP", "10.214.0.1") +UPLINK_IP = os.environ.get("AWG_UPLINK_IP", "10.214.0.2") + +MOCK_PORT = int(os.environ.get("AWG_MOCK_PORT", "51820")) +LEAK_IP = os.environ.get("AWG_LEAK_IP", "198.51.100.9") +LEAK_PORT = int(os.environ.get("AWG_LEAK_PORT", "9999")) + +# имена veth-интерфейсов (совпадают с tests/netns/lib.sh) +VETH_CLIENT = os.environ.get("AWG_VETH_CLIENT", "veth-c-gw") +VETH_GW_LAN = os.environ.get("AWG_VETH_GW_LAN", "veth-gw-lan") +VETH_GW_WAN = os.environ.get("AWG_VETH_GW_WAN", "veth-gw-wan") +VETH_UPLINK = os.environ.get("AWG_VETH_UPLINK", "veth-uplink") + +FWMARK = int(os.environ.get("AWG_FWMARK", "51820")) +ROUTE_TABLE = int(os.environ.get("AWG_ROUTE_TABLE", "51820")) + +WORKDIR = pathlib.Path(os.environ.get("AWG_NETNS_WORKDIR", "/tmp/awg-netns")) +MOCK_LOG = pathlib.Path(os.environ.get("AWG_MOCK_LOG", str(WORKDIR / "mock-endpoint.jsonl"))) +MOCK_READY = pathlib.Path(os.environ.get("AWG_MOCK_READY", str(WORKDIR / "mock-endpoint.ready"))) +MOCK_MODE_FILE = pathlib.Path(os.environ.get("AWG_MOCK_MODE_FILE", str(WORKDIR / "mock-mode"))) +LEAK_LOG = pathlib.Path(os.environ.get("AWG_LEAK_LOG", str(WORKDIR / "leak-sink.jsonl"))) +LEAK_READY = pathlib.Path(os.environ.get("AWG_LEAK_READY", str(WORKDIR / "leak-sink.ready"))) + +HERE = pathlib.Path(__file__).resolve().parent +PROBE = HERE / "probe.py" + +NFT_TABLE = "awgtest" +C_MARK_OUT = "awgtest-mark-out" +C_MARK_FWD = "awgtest-mark-fwd" +C_DROP = "awgtest-killswitch-drop" +IPT_CHAIN_MARK = "awgtest-mark" +IPT_CHAIN_FWD = "awgtest-mark-fwd" +IPT_CHAIN_KS = "awgtest-ks" + + +class StandError(RuntimeError): + """Ошибка стенда (не теста): топология/утилиты не отвечают как ожидается.""" + + +def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess: + """Запуск команды в корневом netns.""" + cp = subprocess.run( + list(args), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if check and cp.returncode != 0: + raise StandError(f"команда {list(args)!r} завершилась с кодом {cp.returncode}: {cp.stderr.strip()}") + return cp + + +def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess: + """Запуск команды внутри указанного netns.""" + return sh(["ip", "netns", "exec", ns, *args], check=check, timeout=timeout) + + +def ns_exists(ns: str) -> bool: + cp = sh(["ip", "netns", "list"], check=False) + return any(line.split()[0] == ns for line in cp.stdout.splitlines() if line.strip()) + + +def addrs(ns: str) -> str: + return sh(["ip", "-n", ns, "-br", "addr", "show"]).stdout + + +def links(ns: str) -> str: + return sh(["ip", "-n", ns, "-br", "link", "show"]).stdout + + +def link_is_up(ns: str, ifname: str) -> bool: + cp = sh(["ip", "-n", ns, "-br", "link", "show", ifname], check=False) + return cp.returncode == 0 and "UP" in cp.stdout + + +# --- netfilter: backend, правила, счётчики ----------------------------------- +def fw_kind() -> str: + """Backend netfilter в netns шлюза: nft|iptables (AWG_FW_BACKEND форсирует выбор).""" + want = os.environ.get("AWG_FW_BACKEND", "auto") + if want in ("nft", "iptables"): + if ns_sh(GW_NS, ["sh", "-c", f"command -v {want}"], check=False).returncode == 0: + return want + return "none" + if ns_sh(GW_NS, ["sh", "-c", "command -v nft"], check=False).returncode == 0: + return "nft" + if ns_sh(GW_NS, ["sh", "-c", "command -v iptables"], check=False).returncode == 0: + return "iptables" + return "none" + + +def nft_rules() -> list[dict[str, Any]]: + """Правила таблицы inet awgtest в JSON-виде (nft -j list table).""" + cp = ns_sh(GW_NS, ["nft", "-j", "list", "table", "inet", NFT_TABLE], check=False) + if cp.returncode != 0: + raise StandError(f"nft list table inet {NFT_TABLE} вернул {cp.returncode}: {cp.stderr.strip()}") + payload = json.loads(cp.stdout) + return [obj["rule"] for obj in payload.get("nftables", []) if "rule" in obj] + + +def _nft_rule_counter(rule: dict[str, Any]) -> int | None: + for expr in rule.get("expr", []): + if isinstance(expr, dict) and "counter" in expr: + return int(expr["counter"].get("packets", 0)) + return None + + +def fw_counters() -> dict[str, Any]: + """Счётчики правил: {fw, mark_out, mark_fwd, killswitch_drop} (None — недоступно/нет правила).""" + kind = fw_kind() + out: dict[str, Any] = {"fw": kind, "mark_out": None, "mark_fwd": None, "killswitch_drop": None} + if kind == "nft": + for rule in nft_rules(): + comment = str(rule.get("comment", "")) + counter = _nft_rule_counter(rule) + if comment == C_MARK_OUT: + out["mark_out"] = counter + elif comment == C_MARK_FWD: + out["mark_fwd"] = counter + elif comment == C_DROP: + out["killswitch_drop"] = counter + elif kind == "iptables": + out["mark_out"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_MARK) + out["mark_fwd"] = _iptables_counter("-t", "mangle", "-L", IPT_CHAIN_FWD) + out["killswitch_drop"] = _iptables_counter("-t", "filter", "-L", IPT_CHAIN_KS) + return out + + +def _iptables_counter(*args: str) -> int | None: + """Пакеты первого правила в цепочке iptables (в alpine iptables — nft-backend).""" + cp = ns_sh(GW_NS, ["iptables", *args, "-v", "-n", "-x"], check=False) + if cp.returncode != 0: + return None + for line in cp.stdout.splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[0].isdigit() and fields[1].isdigit(): + return int(fields[0]) + return None + + +def iptables_rules() -> str: + parts = [] + for args in (("-t", "mangle", "-S", IPT_CHAIN_MARK), ("-t", "filter", "-S", IPT_CHAIN_KS)): + cp = ns_sh(GW_NS, ["iptables", *args], check=False) + parts.append(cp.stdout.strip() or cp.stderr.strip()) + return "\n".join(parts) + + +# --- артефакты процесса (логи мока и детектора утечки) ----------------------- +def read_jsonl(path: pathlib.Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + events: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events + + +def wait_ready(path: pathlib.Path, timeout: float = 10.0, what: str = "процесс") -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if path.exists(): + return + time.sleep(0.1) + raise StandError(f"{what} не поднялся за {timeout}с (нет файла {path})") + + +def wait_jsonl(path: pathlib.Path, predicate, timeout: float = 5.0) -> dict[str, Any] | None: + deadline = time.time() + timeout + while time.time() < deadline: + for event in read_jsonl(path): + if predicate(event): + return event + time.sleep(0.1) + return None + + +# --- управление моком -------------------------------------------------------- +def set_mock_mode(mode: str) -> None: + if mode not in ("respond", "drop"): + raise ValueError(f"неизвестный режим мока: {mode}") + MOCK_MODE_FILE.parent.mkdir(parents=True, exist_ok=True) + MOCK_MODE_FILE.write_text(mode + "\n", encoding="utf-8") + time.sleep(0.3) # мок читает режим на каждом пакете + + +# --- трафик ------------------------------------------------------------------ +def probe( + src_ns: str, + dst: str, + port: int, + *, + expect_timeout: bool = False, + raw: str | None = None, + timeout: float = 2.0, +) -> dict[str, Any]: + """Отправить тестовый трафик из netns и вернуть разобранный JSON-ответ probe.py.""" + args = ["python3", str(PROBE), "--dst", dst, "--port", str(port), "--timeout", str(timeout)] + if raw is not None: + args += ["--raw", raw] + if expect_timeout: + args.append("--expect-timeout") + cp = ns_sh(src_ns, args, check=False, timeout=timeout + 10) + line = cp.stdout.strip().splitlines()[-1] if cp.stdout.strip() else "{}" + try: + result = json.loads(line) + except json.JSONDecodeError as exc: # pragma: no cover - диагностика + raise StandError(f"probe.py вернул не-JSON ({exc}): stdout={cp.stdout!r} stderr={cp.stderr!r}") from exc + result["exit_code"] = cp.returncode + return result + + +def mock_events() -> list[dict[str, Any]]: + return read_jsonl(MOCK_LOG) + + +def leak_events() -> list[dict[str, Any]]: + return [e for e in read_jsonl(LEAK_LOG) if e.get("event") == "leak"] diff --git a/tests/netns/run_tests.py b/tests/netns/run_tests.py new file mode 100644 index 0000000..94bbff0 --- /dev/null +++ b/tests/netns/run_tests.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""run_tests.py — прогон netns-тестов с JSON-отчётом (задача #21). Только stdlib. + +Запускается из tests/netns/run.sh. Не требует pytest: unittest есть в stdlib, +поэтому стенд работает и на хосте, и в alpine-образе приложения. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import sys +import time +import unittest + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Прогон netns-тестов awg-dash (#21)") + ap.add_argument("--report", default=os.environ.get("AWG_NETNS_REPORT", "")) + ap.add_argument("--pattern", default="test_*.py") + ap.add_argument("--verbosity", type=int, default=2) + args = ap.parse_args(argv) + + here = pathlib.Path(__file__).resolve().parent + # тесты стенда пропускаются вне run.sh (нужны root + поднятый стенд) + os.environ.setdefault("AWG_NETNS_RUN", "1") + + loader = unittest.TestLoader() + loader.sortTestMethodsUsing = staticmethod(lambda a, b: (a > b) - (a < b)) # стабильный алфавитный порядок + suite = loader.discover(str(here), pattern=args.pattern, top_level_dir=str(here)) + + started = time.time() + result = unittest.TextTestRunner(verbosity=args.verbosity).run(suite) + duration = round(time.time() - started, 3) + + details = [ + {"test": str(test), "status": "failed", "message": message} + for test, message in list(result.failures) + list(result.errors) + ] + details += [{"test": str(test), "status": "skipped", "message": message} for test, message in result.skipped] + + payload = { + "suite": "tests/netns", + "tests": result.testsRun, + "passed": result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped), + "failures": len(result.failures), + "errors": len(result.errors), + "skipped": len(result.skipped), + "duration_s": duration, + "details": details, + "returncode": 0 if result.wasSuccessful() else 1, + } + + if args.report: + path = pathlib.Path(args.report) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + print( + "[netns] тесты: {tests} всего, {passed} ok, {failures} fail, {errors} err, " + "{skipped} skip, {duration_s}s".format(**payload) + ) + return int(payload["returncode"]) + + +if __name__ == "__main__": + sys.exit(main()) From 87cf5fe05f993f1cc96b3e5e50ed6c5cdea059e0 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:48:51 +0300 Subject: [PATCH 04/30] =?UTF-8?q?test(#21):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20netns-=D1=81=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D1=85=D0=B0=D1=80=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_netns_stand.py — 8 тестов: топология и линки, policy routing по fwmark, правила меток и kill-switch, готовность мока и детектора, доставка handshake из LAN и из шлюза с ростом счётчика метки, имитация недоступности провайдера (mode=drop), положительный контроль утечки и блокировка при kill-switch. Тесты пропускаются вне run.sh, чтобы не падать при случайном pytest-прогоне; - README.md харнесса — схема топологии, состав файлов, таблица тестов, переменные окружения и pitfalls (root/CAP_NET_ADMIN, rp_filter, отсутствие nft и ping в alpine-образе). Refs #21 Refs #15 --- tests/netns/README.md | 107 ++++++++++++++-- tests/netns/test_netns_stand.py | 210 ++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 tests/netns/test_netns_stand.py diff --git a/tests/netns/README.md b/tests/netns/README.md index e6139e1..e75510f 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -1,13 +1,104 @@ # tests/netns — сетевой тестовый стенд -Задача **#21**, используется тестами #41 и #43. +Задача **#21**, используется тестами split-tunnel (#40, #41, #43). -Цель: проверять split-tunnel без реального VPN-провайдера и без доступа к продакшн-сети. +Цель: проверять маршрутизацию, policy routing, метки трафика и fail-closed +**без реального VPN-провайдера** и без доступа к продакшн-сети. -План харнесса: -1. Создать netns: `client`, `gw` (шлюз с awg-интерфейсом), `uplink` (имитация ISP). -2. Поднять мок WireGuard-endpoint (userspace) вместо реального сервера. -3. Запустить шлюз в netns с теми же правилами, что в контейнере. -4. Проверить, куда ушёл пакет: интерфейс/метка/счётчики ipset и iptables. +## Топология -Точки входа: `tests/netns/run.sh` (вызывается через `make test-netns`). +``` + client ns gw ns (шлюз awg-dash) uplink ns («ISP») + 10.213.0.2/24 ──veth──► 10.213.0.1/24 10.214.0.1/24 ──veth──► 10.214.0.2/24 + (LAN-устройство) ip_forward=1, ip rule fwmark 51820 мок-endpoint udp/51820 + → table 51820 → default via uplink детектор утечки + 198.51.100.9:9999 +``` + +| Namespace | По умолчанию | Роль | Адреса | +|---|---|---|---| +| `awgt21-client` | `AWG_NS_CLIENT` | LAN-устройство за шлюзом | `10.213.0.2/24` | +| `awgt21-gw` | `AWG_NS_GW` | хост со шлюзом: forwarding, policy routing, netfilter | `10.213.0.1/24`, `10.214.0.1/24` | +| `awgt21-uplink` | `AWG_NS_UPLINK` | «ISP»: мок WireGuard-endpoint + детектор утечки | `10.214.0.2/24`, `198.51.100.9/32` | + +Правила в `gw`: `ip rule fwmark 51820 lookup 51820` + `table 51820 default via 10.214.0.2` +(как в `wg-quick`), метки ставит netfilter на исходящий трафик шлюза (OUTPUT) и на +транзитный трафик LAN (FORWARD). Kill-switch — правило DROP в FORWARD для трафика +LAN, уходящего напрямую в «публичную» сеть `198.51.100.0/24` (RFC 5737 TEST-NET-2). +Адрес детектора утечки поднят на `lo` аплинка: если при включённом kill-switch пакет +дошёл до детектора — тест падает. + +## Запуск + +```bash +make test-netns # поднять стенд, прогнать тесты, снести стенд +make test-netns-keep # оставить топологию и логи для отладки +make test-netns NETNS_ARGS="--no-tests" # только поднять стенд +make test-netns-ci # то же в привилегированном контейнере (путь для CI) + +bash tests/netns/topology.sh status # состояние топологии +bash tests/netns/topology.sh down # снести топологию вручную +bash tests/netns/topology.sh killswitch on # включить fail-closed правило +``` + +Требования: Linux, **root** (или CAP_NET_ADMIN + CAP_SYS_ADMIN), `ip`, `python3`; +backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только +`iptables`, на Debian/Ubuntu — оба). Код возврата `3` означает «заблокировано +окружением»: нет прав на создание netns. + +## Состав + +| Файл | Назначение | +|---|---| +| `run.sh` | точка входа: preflight → топология → мок → тесты → снос | +| `lib.sh` | конфигурация стенда + правила netfilter (nft/iptables) | +| `topology.sh` | `up`/`down`/`status`/`killswitch on|off` | +| `mock_wg_endpoint.py` | мок WireGuard-endpoint: отвечает на handshake initiation | +| `leak_sink.py` | детектор утечки: UDP+TCP `198.51.100.9:9999` | +| `probe.py` | генератор тестового трафика из указанного netns | +| `netns_common.py` | утилиты для тестов (ns_exec, счётчики правил, JSONL-логи) | +| `run_tests.py` | прогон unittest + JSON-отчёт | +| `test_netns_stand.py` | 8 интеграционных тестов стенда | + +## Что проверяют тесты (`test_netns_stand.py`) + +1. `test_01` — netns и veth-пары созданы, адреса выданы, `ip_forward=1`. +2. `test_02` — policy routing: `ip rule fwmark 51820` → `table 51820` → аплинк. +3. `test_03` — правила меток (OUTPUT/FORWARD) и DROP kill-switch на месте. +4. `test_04` — мок-endpoint и детектор утечки слушают и залогировали `listen`. +5. `test_05` — handshake initiation из LAN доходит до мока, мок отвечает + (148 → 92 байта), счётчик правила метки растёт. +6. `test_06` — то же для трафика, инициированного самим шлюзом (OUTPUT-метка). +7. `test_07` — режим `drop` мока имитирует недоступность провайдера: ответа нет, + пакеты фиксируются; возврат в `respond` восстанавливает стенд. +8. `test_08` — kill-switch: при выключенном правиле трафик доходит до детектора + (положительный контроль), при включённом — утечки нет. + +## Параметры (переменные окружения) + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `AWG_NETNS_PREFIX` | `awgt21` | префикс имён namespace/veth | +| `AWG_NS_CLIENT` / `AWG_NS_GW` / `AWG_NS_UPLINK` | `awgt21-*` | имена namespace | +| `AWG_MOCK_PORT` | `51820` | порт мока WireGuard-endpoint | +| `AWG_LEAK_IP` / `AWG_LEAK_PORT` | `198.51.100.9` / `9999` | адрес и порт детектора утечки | +| `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing | +| `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение | +| `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда | + +## Pitfalls + +* **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе + `run.sh` завершится кодом 3 с сообщением «заблокировано окружением». +* **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт + асимметрию reverse-пути, strict-режим молча дропает ответы. +* **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard + (тип и длины), но не выполняет Noise IK handshake. Годится для топологии, + меток, policy routing и fail-closed — **не** для проверки шифрования, MTU и + пропускной способности. +* **`nft` в alpine-образе нет**: в контейнере используется `iptables` + (nft-backend). Стенд поддерживает оба backend; при отладке форсируйте + `AWG_FW_BACKEND=iptables`. +* **`ping` в образе нет** (пакет `iputils` не установлен) — тесты не используют + ICMP: связность проверяется UDP-трафиком через мок и детектор утечки. +* **Остатки после падения**: `bash tests/netns/topology.sh down` и `ip netns list`. diff --git a/tests/netns/test_netns_stand.py b/tests/netns/test_netns_stand.py new file mode 100644 index 0000000..0675223 --- /dev/null +++ b/tests/netns/test_netns_stand.py @@ -0,0 +1,210 @@ +"""Интеграционные тесты netns-стенда awg-dash (задача #21, эпик #15). + +Проверяют, что стенд реально поднимает топологию client/gw/uplink, мок +WireGuard-endpoint отвечает без настоящего провайдера, тестовый трафик +маркируется, а kill-switch не пускает трафик напрямую. + +Запускать только через `tests/netns/run.sh` (или `make test-netns`): тестам +нужны root, поднятая топология и запущенные процессы стенда. Вне run.sh тесты +помечаются skipped, чтобы случайный прогон pytest/CI не падал. +""" + +from __future__ import annotations + +import os +import time +import unittest + +import netns_common as nc + +REASON = "netns-стенд не запущен: используйте tests/netns/run.sh (нужны root и CAP_NET_ADMIN)" +KILLSWITCH_SCRIPT = str(nc.HERE / "topology.sh") + + +@unittest.skipUnless(os.environ.get("AWG_NETNS_RUN") == "1", REASON) +class NetnsStandTest(unittest.TestCase): + """Порядок методов значим: стенд stateful, шаги идут как test_01..test_08.""" + + maxDiff = None + + def _killswitch(self, state: str) -> None: + nc.sh(["bash", KILLSWITCH_SCRIPT, "killswitch", state]) + + # --- AC-3, шаг 1: топология --------------------------------------------- + def test_01_namespaces_and_links(self) -> None: + for ns in (nc.CLIENT_NS, nc.GW_NS, nc.UPLINK_NS): + self.assertTrue(nc.ns_exists(ns), f"netns {ns} не создан") + + self.assertIn(nc.CLIENT_IP, nc.addrs(nc.CLIENT_NS), "у клиента нет ожидаемого адреса") + gw_addrs = nc.addrs(nc.GW_NS) + self.assertIn(nc.GW_LAN_IP, gw_addrs, "у шлюза нет LAN-адреса") + self.assertIn(nc.GW_WAN_IP, gw_addrs, "у шлюза нет WAN-адреса") + self.assertIn(nc.UPLINK_IP, nc.addrs(nc.UPLINK_NS), "у аплинка нет ожидаемого адреса") + + for ns, ifname in ( + (nc.CLIENT_NS, nc.VETH_CLIENT), + (nc.GW_NS, nc.VETH_GW_LAN), + (nc.GW_NS, nc.VETH_GW_WAN), + (nc.UPLINK_NS, nc.VETH_UPLINK), + ): + self.assertTrue(nc.link_is_up(ns, ifname), f"{ifname} в {ns} не UP") + + forwarding = nc.ns_sh(nc.GW_NS, ["sysctl", "-n", "net.ipv4.ip_forward"]).stdout.strip() + self.assertEqual("1", forwarding, "в шлюзе не включён ip_forward — транзит не пойдёт") + + # --- policy routing без реального провайдера ------------------------------ + def test_02_policy_routing_by_fwmark(self) -> None: + rules = nc.ns_sh(nc.GW_NS, ["ip", "rule", "show"]).stdout + self.assertIn(f"lookup {nc.ROUTE_TABLE}", rules, "нет ip rule fwmark -> table") + self.assertIn("fwmark", rules, "правило policy routing не содержит fwmark") + + table = nc.ns_sh(nc.GW_NS, ["ip", "route", "show", "table", str(nc.ROUTE_TABLE)]).stdout + self.assertIn(f"via {nc.UPLINK_IP}", table, f"в таблице {nc.ROUTE_TABLE} нет default via аплинк") + + route_get = nc.ns_sh( + nc.GW_NS, ["ip", "route", "get", nc.UPLINK_IP, "mark", str(nc.FWMARK)] + ).stdout + self.assertIn( + f"table {nc.ROUTE_TABLE}", + route_get, + f"маршрут для fwmark {nc.FWMARK} не берётся из таблицы {nc.ROUTE_TABLE}: {route_get}", + ) + + def test_03_netfilter_mark_and_killswitch_rules(self) -> None: + kind = nc.fw_kind() + self.assertIn(kind, ("nft", "iptables"), "в netns шлюза нет ни nft, ни iptables") + + if kind == "nft": + comments = {str(rule.get("comment", "")) for rule in nc.nft_rules()} + for needle in (nc.C_MARK_OUT, nc.C_MARK_FWD, nc.C_DROP): + self.assertIn(needle, comments, f"нет правила nft с комментарием {needle}") + else: + rules = nc.iptables_rules() + self.assertIn(nc.IPT_CHAIN_MARK, rules, "нет цепочки метки iptables") + self.assertIn(nc.IPT_CHAIN_KS, rules, "нет цепочки kill-switch iptables") + self.assertIn("MARK", rules, "нет правила MARK --set-mark") + self.assertIn("DROP", rules, "нет правила DROP kill-switch") + + # --- мок-endpoint -------------------------------------------------------- + def test_04_mock_endpoint_and_leak_sink_ready(self) -> None: + self.assertTrue(nc.MOCK_READY.exists(), f"нет флага готовности мока {nc.MOCK_READY}") + self.assertTrue(nc.LEAK_READY.exists(), f"нет флага готовности детектора {nc.LEAK_READY}") + + listen = nc.wait_jsonl(nc.MOCK_LOG, lambda e: e.get("event") == "listen", timeout=3) + self.assertIsNotNone(listen, "в логе мока нет события listen") + self.assertIn(str(nc.MOCK_PORT), str(listen.get("addr")), "мок слушает не тот порт") + + sink = nc.wait_jsonl(nc.LEAK_LOG, lambda e: e.get("event") == "listen", timeout=3) + self.assertIsNotNone(sink, "детектор утечки не залогировал listen") + + # --- AC-3, шаг 2: трафик маркируется и доходит до мока -------------------- + def test_05_forwarded_handshake_reaches_mock_and_is_marked(self) -> None: + before = nc.fw_counters() + + result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue( + result.get("reply_received"), + f"мок-endpoint не ответил на handshake initiation: {result}", + ) + + rx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "rx" and e.get("msg_type") == 1 and str(e.get("src", "")).startswith(nc.CLIENT_IP), + timeout=3, + ) + self.assertIsNotNone(rx, "мок не увидел handshake initiation от клиента") + self.assertEqual(148, rx["length"], "длина handshake initiation не 148 байт") + self.assertEqual("handshake_initiation", rx["msg_name"]) + + tx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "tx" and e.get("msg_type") == 2, + timeout=3, + ) + self.assertIsNotNone(tx, "мок не отправил handshake response") + self.assertEqual(92, tx["length"], "длина handshake response не 92 байта") + + reply = result.get("reply") or {} + self.assertEqual( + reply.get("sender_index_echo"), + reply.get("receiver_index"), + "мок не подставил sender_index клиента как receiver_index", + ) + + after = nc.fw_counters() + self.assertIsNotNone(after["mark_fwd"], "нет счётчика правила метки транзитного трафика") + self.assertGreater( + after["mark_fwd"], before["mark_fwd"] or 0, "счётчик правила метки (FORWARD) не вырос — трафик не маркируется" + ) + + def test_06_gateway_originated_handshake_is_marked(self) -> None: + before = nc.fw_counters() + result = nc.probe(nc.GW_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue(result.get("reply_received"), f"мок не ответил на handshake из netns шлюза: {result}") + + after = nc.fw_counters() + self.assertIsNotNone(after["mark_out"], "нет счётчика правила метки исходящего трафика шлюза") + self.assertGreater( + after["mark_out"], before["mark_out"] or 0, "счётчик правила метки (OUTPUT) не вырос" + ) + + # --- имитация недоступности провайдера ------------------------------------ + def test_07_provider_outage_drop_mode(self) -> None: + nc.set_mock_mode("drop") + try: + result = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, expect_timeout=True, timeout=1.5) + self.assertTrue(result.get("ok"), f"в режиме drop ожидалось отсутствие ответа: {result}") + self.assertFalse(result.get("reply_received"), "мок ответил, хотя провайдер «недоступен»") + + rx = nc.wait_jsonl( + nc.MOCK_LOG, + lambda e: e.get("event") == "rx" and e.get("mode") == "drop", + timeout=3, + ) + self.assertIsNotNone(rx, "мок в режиме drop не зафиксировал пакет") + finally: + nc.set_mock_mode("respond") + + recovered = nc.probe(nc.CLIENT_NS, nc.UPLINK_IP, nc.MOCK_PORT, timeout=2.0) + self.assertTrue(recovered.get("reply_received"), "стенд не восстановился после возврата режима respond") + + # --- kill-switch (fail-closed) ------------------------------------------- + def test_08_killswitch_blocks_direct_egress(self) -> None: + marker = "awgtest-leak-probe" + + # 1) положительный контроль: при выключенном kill-switch трафик доходит + self._killswitch("off") + nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0) + control = nc.wait_jsonl( + nc.LEAK_LOG, + lambda e: e.get("event") == "leak" and e.get("length") == len(marker), + timeout=3, + ) + self.assertIsNotNone( + control, + "контрольная проверка не прошла: при выключенном kill-switch трафик не дошёл до детектора", + ) + baseline = len(nc.leak_events()) + + # 2) kill-switch включён -> трафик, предназначенный туннелю, не должен утечь + self._killswitch("on") + before_drop = nc.fw_counters() + nc.probe(nc.CLIENT_NS, nc.LEAK_IP, nc.LEAK_PORT, raw=marker, expect_timeout=True, timeout=1.0) + time.sleep(1.0) + + self.assertEqual( + baseline, + len(nc.leak_events()), + "трафик утёк напрямую при включённом kill-switch (fail-closed нарушен)", + ) + after_drop = nc.fw_counters() + if after_drop["killswitch_drop"] is not None: + self.assertGreater( + after_drop["killswitch_drop"], + before_drop["killswitch_drop"] or 0, + "счётчик DROP kill-switch не вырос — правило не сработало", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From da1c750297d928d9f68620a3700993d3acff37c1 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:49:11 +0300 Subject: [PATCH 05/30] =?UTF-8?q?feat(#21):=20dev-=D1=81=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=20=E2=80=94=20compose-=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,=20?= =?UTF-8?q?health-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.dev.yml: отдельный project awg-dash-dev, порт 8080 в контейнере (наружу AWG_DASH_PORT, по умолчанию 8180 — 8080 занят на dev-хосте), bind mount /opt/awg-dash-dev/{config,data} -> /config,/data, healthcheck /api/v1/health, NET_ADMIN/NET_RAW/SYS_MODULE, /dev/net/tun, ip_forward, лимит логов; - docker-compose.stub.yml + api_stub.py — DEV-ONLY заглушка health: AC-1 требует FastAPI-каркаса (#31), до него проверяем плумбинг стенда без обмана отчёта (в ответе ивно стоит "stub": true); - docker-compose.test.yml: привилегированный netns-раннер (CAP_SYS_ADMIN нужен для `ip netns add`) + сервис app для API-тестов. Refs #21 Refs #15 --- deploy/api_stub.py | 84 ++++++++++++++++++++++++++++++++++ deploy/docker-compose.dev.yml | 68 +++++++++++++++++++++++++++ deploy/docker-compose.stub.yml | 23 ++++++++++ deploy/docker-compose.test.yml | 70 ++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 deploy/api_stub.py create mode 100644 deploy/docker-compose.dev.yml create mode 100644 deploy/docker-compose.stub.yml create mode 100644 deploy/docker-compose.test.yml diff --git a/deploy/api_stub.py b/deploy/api_stub.py new file mode 100644 index 0000000..848922d --- /dev/null +++ b/deploy/api_stub.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""api_stub.py — DEV-ONLY заглушка health-эндпоинта (задача #21). + +Существует ровно для одной цели: до реализации FastAPI-каркаса (#31) проверить +плумбинг dev-стенда — маппинг порта, healthcheck контейнера, тома /config и +/data, переживание `docker compose restart`. + +Отдаёт: + GET /api/v1/health -> 200 {"status":"ok","stub":true,...} + GET /healthz -> 200 (алиас для отладки) + всё остальное -> 404 {"error":"not_implemented","issue":"#31"} + +Чего НЕ делает: никакой логики шлюза, никакого доступа к awg/iptables. +В staging/prod запускать запрещено (см. deploy/dev-stand.md). +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +STARTED_AT = time.time() +STUB_ENV = os.environ.get("AWG_API_STUB", "0") + + +class Handler(BaseHTTPRequestHandler): + server_version = "awg-dash-stub/0.1" + protocol_version = "HTTP/1.1" + + def _json(self, code: int, payload: dict) -> None: + body = (json.dumps(payload, sort_keys=True) + "\n").encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 (HTTP-метод) + path = self.path.split("?", 1)[0] + if path in ("/api/v1/health", "/healthz"): + self._json( + 200, + { + "status": "ok", + "stub": True, + "issue": "#31", + "note": "заглушка dev-стенда: реальный API появится в #31", + "uptime_s": round(time.time() - STARTED_AT, 3), + "stub_env": STUB_ENV, + }, + ) + return + self._json(404, {"error": "not_implemented", "issue": "#31", "path": path}) + + def do_HEAD(self) -> None: # noqa: N802 + self.send_response(200 if self.path.split("?", 1)[0] in ("/api/v1/health", "/healthz") else 404) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt: str, *args) -> None: # лог в stdout контейнера + print(f"[api-stub] {self.address_string()} {fmt % args}", flush=True) + + +def main() -> int: + ap = argparse.ArgumentParser(description="DEV-ONLY заглушка health для dev-стенда (#21)") + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--domain", default="/config", help="каталог, доступность которого проверяем (для лога)") + args = ap.parse_args() + + print( + f"[api-stub] DEV-ONLY заглушка на {args.host}:{args.port}; каталог {args.domain} " + f"{'доступен' if os.path.isdir(args.domain) else 'НЕ доступен'}", + flush=True, + ) + ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..1097bd3 --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,68 @@ +# dev/staging стенд awg-dash (задача #21, эпик #15). +# +# Отдельный compose-project `awg-dash-dev` — не пересекается с контейнерами PoC +# (amneziawg-client) и другими сервисами dev-хоста. +# +# Запуск: make dev-up (поднимает стек с образом awg-dash:dev) +# Проверка AC: make dev-verify (health 200 + переживание restart для /config /data) +# Логи: make dev-logs +# Остановка: make dev-down (тома/каталоги состояния сохраняются) +# +# AC-1 (health 200) выполняется только после реализации FastAPI-каркаса (#31): +# до этого контейнер отдаёт healthcheck unhealthy, а `/config` `/data` уже +# проверяются скриптом deploy/verify-dev-stand.sh. Заглушка для ранней проверки +# стенда — deploy/docker-compose.stub.yml + `make dev-up-stub`. + +name: ${COMPOSE_PROJECT_NAME:-awg-dash-dev} + +services: + awg-dash: + image: ${AWG_DASH_IMAGE:-awg-dash:dev} + build: + context: .. + dockerfile: ${AWG_DASH_DOCKERFILE:-deploy/Dockerfile} + args: + AMNEZIAWG_GO_REF: ${AMNEZIAWG_GO_REF:-master} + AMNEZIAWG_TOOLS_REF: ${AMNEZIAWG_TOOLS_REF:-master} + container_name: awg-dash-dev + restart: unless-stopped + # Порт приложения внутри образа всегда 8080 (EXPOSE/AWG_API_PORT). + # На dev-хосте 192.168.1.147 порт 8080 занят (drawio) — по умолчанию 8180. + ports: + - "${AWG_DASH_PORT:-8180}:8080" + volumes: + # Состояние вынесено на хост, чтобы правки конфигурации переживали + # `docker compose restart` (AC задачи #21) и пересоздание контейнера. + - ${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config + - ${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/data:/data + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + # NET_ADMIN — iptables/ipset/ip rule; NET_RAW — ping/socket-проверки; + # SYS_MODULE — sysctl net.ipv4.ip_forward и модули ядра (как в ТЗ). + - NET_ADMIN + - NET_RAW + - SYS_MODULE + sysctls: + net.ipv4.ip_forward: "1" + net.ipv4.conf.all.src_valid_mark: "1" + environment: + AWG_CONFIG_DIR: /config + AWG_DATA_DIR: /data + AWG_RUN_DIR: /run/awg + AWG_API_HOST: 0.0.0.0 + AWG_API_PORT: "8080" + AWG_LOG_LEVEL: ${AWG_LOG_LEVEL:-info} + AWG_DEV_STAND: "1" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"] + interval: 15s + timeout: 5s + start_period: 30s + retries: 4 + stop_grace_period: 20s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/deploy/docker-compose.stub.yml b/deploy/docker-compose.stub.yml new file mode 100644 index 0000000..936efb3 --- /dev/null +++ b/deploy/docker-compose.stub.yml @@ -0,0 +1,23 @@ +# Оверрайд для dev-стенда: health-заглушка вместо ещё не реализованного API. +# +# Зачем: AC-1 задачи #21 (`curl :8080/api/v1/health` -> 200) физически не может +# выполниться до появления FastAPI-каркаса (#31, Sprint 2). Чтобы проверить +# плумбинг стенда (порт, healthcheck, тома, restart) уже сейчас, этот оверрайд +# подменяет entrypoint на минимальный HTTP-сервер из deploy/api_stub.py. +# +# Это НЕ приложение: заглушка отдаёт 200 только на /api/v1/health и явно +# помечает ответ `"stub": true`. В prod/staging не использовать. +# +# Запуск: docker compose -f deploy/docker-compose.dev.yml -f deploy/docker-compose.stub.yml up -d +# (или make dev-up-stub / make dev-verify-stub) +# +# После мержа #31 оверрайд не нужен: `make dev-up` поднимает реальный API. + +services: + awg-dash: + entrypoint: ["python3", "/opt/awg-dash-stub/api_stub.py"] + command: ["--host", "0.0.0.0", "--port", "8080", "--domain", "/config"] + volumes: + - ../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro + environment: + AWG_API_STUB: "1" diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml new file mode 100644 index 0000000..da01e80 --- /dev/null +++ b/deploy/docker-compose.test.yml @@ -0,0 +1,70 @@ +# Тестовый стенд awg-dash (задача #21): netns-топология + мок WireGuard-endpoint. +# +# Использование: +# docker compose -f deploy/docker-compose.test.yml run --rm netns # прогон стенда +# docker compose -f deploy/docker-compose.test.yml run --rm netns --keep-topology +# docker compose -f deploy/docker-compose.test.yml up -d app # приложение для API-тестов +# (make test-netns-ci — обёртка над первым вызовом) +# +# Контейнеру `netns` нужен --privileged: внутри создаются сетевые namespace +# (`ip netns add`) и veth-пары, для этого требуются CAP_SYS_ADMIN + CAP_NET_ADMIN. +# Репозиторий монтируется только для чтения; артефакты пишутся в /artifacts и /tmp. + +name: ${COMPOSE_PROJECT_NAME:-awg-dash-test} + +services: + netns: + image: ${AWG_DASH_IMAGE:-awg-dash:dev} + build: + context: .. + dockerfile: ${AWG_DASH_DOCKERFILE:-deploy/Dockerfile} + container_name: awg-dash-test-netns + privileged: true + working_dir: /repo + entrypoint: ["/bin/bash", "/repo/tests/netns/run.sh"] + command: ["--report", "/artifacts/netns-report.json"] + volumes: + - ..:/repo:ro + - ${AWG_DASH_TEST_ARTIFACTS:-./artifacts}:/artifacts + environment: + AWG_NETNS_WORKDIR: /tmp/awg-netns + AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21} + # nft в образе приложения нет — там iptables (см. tests/netns/README.md) + AWG_FW_BACKEND: ${AWG_FW_BACKEND:-auto} + tmpfs: + - /tmp:mode=1777 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + + app: + image: ${AWG_DASH_IMAGE:-awg-dash:dev} + container_name: awg-dash-test-app + restart: "no" + volumes: + - awg-dash-test-config:/config + - awg-dash-test-data:/data + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - NET_RAW + - SYS_MODULE + environment: + AWG_CONFIG_DIR: /config + AWG_DATA_DIR: /data + AWG_API_HOST: 0.0.0.0 + AWG_API_PORT: "8080" + AWG_TEST_STAND: "1" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"] + interval: 10s + timeout: 5s + start_period: 20s + retries: 5 + +volumes: + awg-dash-test-config: + awg-dash-test-data: From 7d46df5ede7aaecbcfa08dc18c3089d5f94db353 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:49:43 +0300 Subject: [PATCH 06/30] =?UTF-8?q?test(#21):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20AC=20=D0=BD=D0=B0=20Docker-=D1=85?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5=20=D0=B8=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20compose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify-dev-stand.sh (`make dev-verify`): AC-1 (health 200, различает api=real/stub), AC-2 (маркеры в /config и /data до и после restart и --force-recreate, включая проверку на хосте), проверка монтирований и health контейнера, JSON-отчёт; коды 0/1/2/3 — ok/regression/blocked-#31/ blocked-env; - tests/deploy/validate_compose.py — проверки без Docker: контейнерный порт 8080, тома /config и /data, healthcheck /api/v1/health, NET_ADMIN и /dev/net/tun, ip_forward, лимит логов, privileged netns-раннер и DEV-ONLY заглушка. Флаг --require-dockerfile для интеграционного дерева. Refs #21 Refs #15 --- deploy/verify-dev-stand.sh | 211 +++++++++++++++++++++++++++++++ tests/deploy/validate_compose.py | 201 +++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 deploy/verify-dev-stand.sh create mode 100644 tests/deploy/validate_compose.py diff --git a/deploy/verify-dev-stand.sh b/deploy/verify-dev-stand.sh new file mode 100644 index 0000000..4c243aa --- /dev/null +++ b/deploy/verify-dev-stand.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# verify-dev-stand.sh — проверка acceptance criteria задачи #21 на Docker-хосте. +# +# Запускать из корня репозитория на машине с Docker: +# bash deploy/verify-dev-stand.sh # реальный API (после мержа #31) +# bash deploy/verify-dev-stand.sh --stub # проверка плумбинга до #31 (health-заглушка) +# +# Что проверяет: +# AC-1: `curl http://:/api/v1/health` -> 200; +# AC-2: правки в /config и /data переживают `docker compose restart` +# (и дополнительно пересоздание контейнера); +# пласт: монтирования /config и /data указывают на ожидаемый каталог хоста, +# контейнер healthy, образ и compose-файлы на месте. +# +# Коды возврата: +# 0 — AC-1 и AC-2 выполнены; +# 1 — AC нарушены (регрессия стенда); +# 2 — AC-1 заблокирован: API ещё не реализован (#31), стенд при этом поднят; +# 3 — заблокировано окружением (нет docker/compose/файлов). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_DEV="$ROOT/deploy/docker-compose.dev.yml" +COMPOSE_STUB="$ROOT/deploy/docker-compose.stub.yml" + +STUB=0 +DO_UP=1 +DO_BUILD=1 +KEEP_STAND=0 +HOST="127.0.0.1" +PORT="${AWG_DASH_PORT:-8180}" +HOST_DIR="${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}" +JSON_OUT="" +HEALTH_PATH="/api/v1/health" + +usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; } + +while [ $# -gt 0 ]; do + case "$1" in + --stub) STUB=1 ;; + --no-up) DO_UP=0 ;; + --skip-build) DO_BUILD=0 ;; + --keep) KEEP_STAND=1 ;; + --host) HOST="${2:?}"; shift ;; + --port) PORT="${2:?}"; shift ;; + --host-dir) HOST_DIR="${2:?}"; shift ;; + --compose) COMPOSE_DEV="${2:?}"; shift ;; + --json) JSON_OUT="${2:?}"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "неизвестная опция: $1" >&2; exit 3 ;; + esac + shift +done + +fatal_env() { printf '[verify][blocked] %s\n' "$*" >&2; exit 3; } +say() { printf '[verify] %s\n' "$*"; } +ok() { printf '[verify][ ok ] %s\n' "$*"; } +bad() { printf '[verify][fail] %s\n' "$*" >&2; } + +# --- предусловия ------------------------------------------------------------- +command -v docker >/dev/null 2>&1 || fatal_env "нет docker в PATH" +docker compose version >/dev/null 2>&1 || fatal_env "нет плагина 'docker compose'" +[ -f "$COMPOSE_DEV" ] || fatal_env "нет файла $COMPOSE_DEV" + +COMPOSE_ARGS=(-f "$COMPOSE_DEV") +if [ "$STUB" -eq 1 ]; then + [ -f "$COMPOSE_STUB" ] || fatal_env "нет файла $COMPOSE_STUB" + COMPOSE_ARGS+=(-f "$COMPOSE_STUB") +fi +compose() { docker compose "${COMPOSE_ARGS[@]}" "$@"; } + +AC1="not_run" +AC2="not_run" +API_MODE="unknown" +NOTES=() +FAILURES=0 + +# --- поднятие стенда --------------------------------------------------------- +if [ "$DO_UP" -eq 1 ]; then + UP_ARGS=(up -d) + [ "$DO_BUILD" -eq 1 ] && UP_ARGS+=(--build) + say "docker compose ${COMPOSE_ARGS[*]} ${UP_ARGS[*]}" + compose "${UP_ARGS[@]}" || fatal_env "не удалось поднять dev-стенд (см. вывод docker compose)" +fi + +compose ps >/dev/null 2>&1 || fatal_env "compose project не поднят" +CID="$(compose ps -q awg-dash | head -n 1)" +[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project" + +wait_health() { + local tries="${1:-30}" code i + for ((i = 1; i <= tries; i++)); do + code="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || true)" + [ "$code" = "200" ] && return 0 + sleep 2 + done + return 1 +} + +# --- AC-1: health 200 -------------------------------------------------------- +BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)" +if wait_health 20; then + BODY="$(curl -s -m 3 "http://${HOST}:${PORT}${HEALTH_PATH}" || true)" + if printf '%s' "$BODY" | grep -q '"stub": *true'; then + API_MODE="stub" + NOTES+=("health отвечает заглушка (#31): реальный API ещё не смёржен") + ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=stub)" + else + API_MODE="real" + ok "AC-1: http://${HOST}:${PORT}${HEALTH_PATH} -> 200 (api=real)" + fi + AC1="pass" +else + CODE="$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://${HOST}:${PORT}${HEALTH_PATH}" || echo "000")" + say "health-check вернул ${CODE}; последние логи контейнера:" + compose logs --tail=15 awg-dash || true + if [ "$STUB" -eq 0 ]; then + AC1="blocked" + NOTES+=("AC-1 заблокирован: FastAPI-каркас (#31) ещё не реализован — /api/v1/health не отвечает") + printf '[verify][blocked] AC-1 требует #31 (см. deploy/dev-stand.md)\n' >&2 + else + AC1="fail" + FAILURES=$((FAILURES + 1)) + bad "AC-1: даже с заглушкой health не 200 (код ${CODE})" + fi +fi + +# --- AC-2: состояние переживает restart -------------------------------------- +if [ "$AC1" = "blocked" ]; then + NOTES+=("AC-2 проверяем без API: маркерные файлы в /config и /data") +fi + +STAMP="$(date +%s)" +MARKER_TEXT="awg-dash-ac2-${STAMP}" +say "AC-2: пишу маркеры в /config и /data и перезапускаю контейнер" +if compose exec -T awg-dash sh -c "printf '%s\n' '$MARKER_TEXT' > /config/.ac2-marker && printf '%s\n' '$MARKER_TEXT' > /data/.ac2-marker"; then + # 1) restart (как в AC) + compose restart awg-dash >/dev/null + if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после restart health не поднялся за 40с"); fi + CFG_AFTER="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" + DATA_AFTER="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" + + # 2) пересоздание контейнера (сильнее, чем restart) + compose up -d --force-recreate >/dev/null + if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после пересоздания health не поднялся за 40с"); fi + CFG_RECREATE="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" + DATA_RECREATE="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" + + HOST_CFG="$(tr -d '\r\n' < "${HOST_DIR}/config/.ac2-marker" 2>/dev/null || true)" + HOST_DATA="$(tr -d '\r\n' < "${HOST_DIR}/data/.ac2-marker" 2>/dev/null || true)" + + if [ "$CFG_AFTER" = "$MARKER_TEXT" ] && [ "$DATA_AFTER" = "$MARKER_TEXT" ] \ + && [ "$CFG_RECREATE" = "$MARKER_TEXT" ] && [ "$DATA_RECREATE" = "$MARKER_TEXT" ]; then + AC2="pass" + ok "AC-2: /config и /data пережили restart и пересоздание контейнера" + if [ "$HOST_CFG" = "$MARKER_TEXT" ] && [ "$HOST_DATA" = "$MARKER_TEXT" ]; then + ok "AC-2: маркеры видны на хосте (${HOST_DIR}/{config,data}) — это bind mount, а не слой контейнера" + else + NOTES+=("маркеры не видны в ${HOST_DIR} — проверьте, что это bind mount, а не volume") + fi + else + AC2="fail" + FAILURES=$((FAILURES + 1)) + bad "AC-2: маркеры не пережили restart/пересоздание (cfg=$CFG_AFTER data=$DATA_AFTER recreate=$CFG_RECREATE/$DATA_RECREATE)" + fi +else + AC2="fail" + FAILURES=$((FAILURES + 1)) + bad "AC-2: не удалось записать маркеры в /config и /data (exec в контейнер не работает)" +fi + +# --- монтирования и здоровье ------------------------------------------------- +MOUNTS="$(docker inspect -f '{{range .Mounts}}{{.Destination}}<-{{.Source}}{{"\n"}}{{end}}' "$CID" 2>/dev/null || true)" +printf '%s\n' "$MOUNTS" | grep -q '/config' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /config"; } +printf '%s\n' "$MOUNTS" | grep -q '/data' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /data"; } +HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo unknown)" + +say "контейнер: $(docker inspect -f '{{.Name}} {{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")" +say "монтирования:"; printf '%s\n' "$MOUNTS" | sed 's/^/ /' +say "образ: $(docker inspect -f '{{.Config.Image}}' "$CID")" + +if [ "$JSON_OUT" != "" ]; then + mkdir -p "$(dirname "$JSON_OUT")" + cat > "$JSON_OUT" </dev/null | sed 's/,$//')] +} +EOF + ok "отчёт: ${JSON_OUT}" +fi + +if [ "$KEEP_STAND" -eq 0 ]; then + say "стенд оставлен запущенным (снять: docker compose ${COMPOSE_ARGS[*]} down)" +fi + +if [ "$FAILURES" -gt 0 ]; then + exit 1 +fi +[ "$AC1" = "blocked" ] && exit 2 +exit 0 diff --git a/tests/deploy/validate_compose.py b/tests/deploy/validate_compose.py new file mode 100644 index 0000000..0fa8b3c --- /dev/null +++ b/tests/deploy/validate_compose.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""validate_compose.py — статическая проверка compose-файлов стенда (задача #21). + +Проверяет инварианты, которые нужны acceptance criteria #21 и не проверяются +самим `docker compose config`: + + deploy/docker-compose.dev.yml + * приложение слушает контейнерный порт 8080 и публикуется на хост; + * /config и /data смонтированы (состояние переживает restart); + * healthcheck ходит в /api/v1/health; + * выданы NET_ADMIN-возможности, проброшен /dev/net/tun, включён ip_forward; + * включён ограниченный json-file-лог (иначе логи съедят диск на dev-хосте). + + deploy/docker-compose.test.yml + * сервис netns привилегированный (нужен CAP_SYS_ADMIN/NET_ADMIN для `ip netns add`); + * entrypoint — tests/netns/run.sh, отчёт пишется в /artifacts; + * существуют все файлы, на которые ссылаются тома. + + deploy/docker-compose.stub.yml + * подменяет entrypoint на deploy/api_stub.py и явно помечен как DEV-ONLY. + +Запуск: make test-manifests / python3 tests/deploy/validate_compose.py +Коды возврата: 0 — ок (только warnings), 1 — есть ошибки. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +try: + import yaml +except ImportError: # pragma: no cover - зависит от окружения + yaml = None # type: ignore[assignment] + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DEV = ROOT / "deploy" / "docker-compose.dev.yml" +TEST = ROOT / "deploy" / "docker-compose.test.yml" +STUB = ROOT / "deploy" / "docker-compose.stub.yml" + +HEALTH_PATH = "/api/v1/health" + + +def load(path: pathlib.Path) -> dict: + if yaml is None: + raise SystemExit("нужен PyYAML: pip install -r requirements-dev.txt (или make venv)") + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + +def _ports(service: dict) -> list[str]: + return [str(p) for p in service.get("ports", []) or []] + + +def _volumes(service: dict) -> list[str]: + return [str(v) for v in service.get("volumes", []) or []] + + +def _mount_targets(service: dict) -> list[str]: + """Точки монтирования внутри контейнера. + + Строки вида `${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config` содержат + ':' внутри подстановки переменной, поэтому перед разбором подстановки заменяем + на плейсхолдер без двоеточий. + """ + targets = [] + for spec in _volumes(service): + clean = re.sub(r"\$\{[^}]*\}", "SRC", spec) + parts = clean.rsplit(":", 2) + if len(parts) >= 2: + # 2 части: src:target; 3 части: src:target:mode + targets.append(parts[-2] if len(parts) == 3 else parts[-1]) + return targets + + +def validate_dev(errors: list[str], warnings: list[str], require_dockerfile: bool) -> None: + if not DEV.exists(): + errors.append(f"{DEV.name}: файл не найден") + return + doc = load(DEV) + services = doc.get("services") or {} + svc = services.get("awg-dash") + if not svc: + errors.append("dev: нет сервиса awg-dash") + return + + if not doc.get("name"): + errors.append("dev: не задан `name` — compose project не изолирован от других стендов") + + ports = _ports(svc) + if "8080" not in " ".join(ports): + errors.append("dev: порт 8080 не публикуется наружу") + targets = _mount_targets(svc) + for needle in ("/config", "/data"): + if needle not in targets: + errors.append(f"dev: не смонтирован {needle} (состояние не переживёт restart — AC-2)") + + health = (svc.get("healthcheck") or {}).get("test") + if not health or HEALTH_PATH not in jsonish(health): + errors.append(f"dev: healthcheck не ходит в {HEALTH_PATH}") + + caps = [str(c) for c in svc.get("cap_add", []) or []] + if "NET_ADMIN" not in caps: + errors.append("dev: нет cap_add NET_ADMIN (iptables/ipset/ip rule не заработают)") + devices = " ".join(str(d) for d in svc.get("devices", []) or []) + if "/dev/net/tun" not in devices: + errors.append("dev: не проброшен /dev/net/tun") + if str((svc.get("sysctls") or {}).get("net.ipv4.ip_forward", "")) not in ("1", "true", "True"): + errors.append("dev: не включён net.ipv4.ip_forward") + + logging = svc.get("logging") or {} + if str(logging.get("driver", "")) != "json-file": + warnings.append("dev: не задан json-file logging — на dev-хосте логи могут расти без ограничений") + + dockerfile = ((svc.get("build") or {}).get("dockerfile")) or "deploy/Dockerfile" + if not (ROOT / dockerfile).exists(): + msg = f"dev: build.dockerfile {dockerfile} отсутствует в дереве" + (errors if require_dockerfile else warnings).append( + msg + ("" if require_dockerfile else " (ожидаемо до мержа #18)") + ) + + +def validate_test(errors: list[str], warnings: list[str]) -> None: + if not TEST.exists(): + errors.append(f"{TEST.name}: файл не найден") + return + doc = load(TEST) + services = doc.get("services") or {} + svc = services.get("netns") + if not svc: + errors.append("test: нет сервиса netns (netns-харнесс)") + return + if svc.get("privileged") is not True: + errors.append("test: сервис netns не privileged — `ip netns add` внутри контейнера не сработает") + entry = " ".join(str(x) for x in svc.get("entrypoint", []) or []) + if "tests/netns/run.sh" not in entry: + errors.append("test: entrypoint сервиса netns не указывает на tests/netns/run.sh") + if not (ROOT / "tests" / "netns" / "run.sh").exists(): + errors.append("test: нет файла tests/netns/run.sh") + if "/artifacts" not in _mount_targets(svc): + warnings.append("test: отчёт стенда не выносится в /artifacts") + tmpfs = " ".join(str(t) for t in svc.get("tmpfs", []) or []) + if "/tmp" not in tmpfs: + warnings.append("test: /tmp не tmpfs — логи стенда останутся в слое контейнера") + + +def validate_stub(errors: list[str], warnings: list[str]) -> None: + if not STUB.exists(): + warnings.append("stub: оверрайд с health-заглушкой отсутствует") + return + doc = load(STUB) + svc = (doc.get("services") or {}).get("awg-dash") + if not svc: + errors.append("stub: нет сервиса awg-dash") + return + entry = " ".join(str(x) for x in svc.get("entrypoint", []) or []) + if "api_stub.py" not in entry: + errors.append("stub: entrypoint не указывает на deploy/api_stub.py") + if not (ROOT / "deploy" / "api_stub.py").exists(): + errors.append("stub: нет файла deploy/api_stub.py") + if "DEV" not in STUB.read_text(encoding="utf-8").upper(): + warnings.append("stub: в файле нет явной пометки DEV-ONLY") + + +def jsonish(value: object) -> str: + if isinstance(value, list): + return " ".join(str(v) for v in value) + return str(value) + + +def validate(require_dockerfile: bool = False) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + validate_dev(errors, warnings, require_dockerfile) + validate_test(errors, warnings) + validate_stub(errors, warnings) + return errors, warnings + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Проверка compose-файлов dev/тест-стенда awg-dash (#21)") + ap.add_argument("--require-dockerfile", action="store_true", help="требовать наличие deploy/Dockerfile (#18 смёржен)") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args(argv) + + errors, warnings = validate(args.require_dockerfile) + if not args.quiet: + for warning in warnings: + print(f"[warn] {warning}") + for error in errors: + print(f"[error] {error}") + if errors: + print(f"validate-compose: {len(errors)} ошибок, {len(warnings)} предупреждений") + return 1 + print(f"validate-compose: ok ({len(warnings)} предупреждений)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 59c3ad4918765be1e37faf2c573160a6caea7145 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:50:01 +0300 Subject: [PATCH 07/30] =?UTF-8?q?test(#21):=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=D0=BD=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20compose,=20Make-=D1=86=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20deploy?= =?UTF-8?q?/README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/deploy/test_compose_manifests.py — 7 тестов на compose-инварианты (без Docker, только PyYAML) — готовы к включению в CI (#17); - Makefile: dev-up/dev-up-stub/dev-down/dev-logs/dev-verify/dev-verify-stub/ dev-reset/test-netns/test-netns-keep/test-netns-ci/test-manifests/lint-shell, переменные HOST_DIR, DASH_PORT, NETNS_ARGS; dev-up без docker падает с внятным сообщением, а не трейсбеком; - deploy/README.md — таблица артефактов и быстрые команды стенда. Refs #21 Refs #15 --- Makefile | 59 ++++++++++++++++++++-- deploy/README.md | 29 +++++++++++ tests/deploy/test_compose_manifests.py | 68 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/deploy/test_compose_manifests.py diff --git a/Makefile b/Makefile index ae4f41a..47a94bf 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,21 @@ VENV ?= .venv BIN := $(VENV)/bin IMAGE ?= awg-dash:dev COMPOSE_DEV := deploy/docker-compose.dev.yml +COMPOSE_TEST := deploy/docker-compose.test.yml +COMPOSE_STUB := deploy/docker-compose.stub.yml +VERIFY := deploy/verify-dev-stand.sh +COMPOSE ?= docker compose +# Состояние dev-стенда: bind mount для /config и /data (AC-2 задачи #21). +HOST_DIR ?= /opt/awg-dash-dev +# Хост-порт стенда. На dev-хосте 8080 занят другим сервисом — по умолчанию 8180; +# внутри контейнера приложение всегда на 8080. +DASH_PORT ?= 8180 +NETNS_ARGS ?= +VERIFY_ARGS ?= +MANIFEST_ARGS ?= -.PHONY: help venv lint fmt test build dev-up dev-down dev-logs test-netns +.PHONY: help venv lint fmt test build dev-up dev-up-stub dev-down dev-logs dev-verify \ + dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell help: @grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | sed 's/:.*//' | sort -u @@ -29,16 +42,52 @@ build: @test -f deploy/Dockerfile || { echo 'deploy/Dockerfile ещё нет — задача #18'; exit 1; } docker build -f deploy/Dockerfile -t $(IMAGE) . +# --- dev/staging стенд (задача #21) ------------------------------------------ dev-up: @test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; } - docker compose -f $(COMPOSE_DEV) up -d --build + @command -v docker >/dev/null 2>&1 || { echo 'нет docker: dev-стенд поднимается на Docker-хосте (см. deploy/dev-stand.md)'; exit 1; } + AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) up -d --build + +# Health-заглушка вместо API до мержа #31 — только для проверки плумбинга стенда. +dev-up-stub: + @test -f $(COMPOSE_STUB) || { echo '$(COMPOSE_STUB) ещё нет — задача #21'; exit 1; } + AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) -f $(COMPOSE_STUB) up -d --build dev-down: - docker compose -f $(COMPOSE_DEV) down + AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) down dev-logs: - docker compose -f $(COMPOSE_DEV) logs -f --tail=100 + AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) logs -f --tail=100 +# Проверка AC #21 на Docker-хосте: health 200 + состояние переживает restart. +dev-verify: + AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) bash $(VERIFY) $(VERIFY_ARGS) + +dev-verify-stub: + AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) bash $(VERIFY) --stub $(VERIFY_ARGS) + +# Останавливает стенд; каталоги состояния сохраняются (удалять вручную). +dev-reset: + AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) down --remove-orphans + @echo "каталоги состояния сохранены: $(HOST_DIR)/config, $(HOST_DIR)/data (удалять вручную)" + +# --- netns-стенд (задача #21) ------------------------------------------------ test-netns: @test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; } - bash tests/netns/run.sh + bash tests/netns/run.sh $(NETNS_ARGS) + +# То же, но топология и логи остаются для отладки (снести: tests/netns/topology.sh down). +test-netns-keep: + bash tests/netns/run.sh --keep-topology --keep-logs $(NETNS_ARGS) + +# Тот же стенд в привилегированном контейнере — путь для CI (#17). +test-netns-ci: + AWG_DASH_IMAGE=$(IMAGE) $(COMPOSE) -f $(COMPOSE_TEST) run --rm netns + +# --- статические проверки манифестов и скриптов ------------------------------ +test-manifests: + $(PY) tests/deploy/validate_compose.py $(MANIFEST_ARGS) + +lint-shell: + @command -v shellcheck >/dev/null 2>&1 || { echo 'shellcheck не установлен (apt-get install shellcheck)'; exit 1; } + shellcheck -x -S style tests/netns/*.sh deploy/*.sh diff --git a/deploy/README.md b/deploy/README.md index 77c784a..c4254f3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -6,7 +6,36 @@ | `entrypoint.sh` | супервизор: `awg-agent` (root) + `awg-api` | #18 | | `docker-compose.dev.yml` | dev-стенд: порт 8080, тома `/config` `/data`, healthcheck | #21 | | `docker-compose.test.yml` | стенд для тестов | #21 | +| `docker-compose.stub.yml` | DEV-ONLY оверрайд: health-заглушка до мержа #31 | #21 | +| `api_stub.py` | минимальный `/api/v1/health` для проверки плумбинга стенда | #21 | +| `verify-dev-stand.sh` | проверка AC #21 на Docker-хосте (`make dev-verify`) | #21 | +| `dev-stand.md` | runbook dev-стенда: запуск, AC, откат, pitfalls | #21 | | `grafana/` | дашборд JSON (опциональный профиль monitoring) | #46 | | `awg0.conf.example` | пример клиентского конфига | #22 | Запуск по ТЗ (после релиза): `--network=host`, `--device=/dev/net/tun`, `--cap-add=NET_ADMIN --cap-add=SYS_MODULE`, `--restart always`. + +## Dev-стенд (задача #21) + +```bash +make dev-up # поднять стенд (compose project awg-dash-dev) +make dev-verify # AC: health 200 + /config /data переживают restart +make dev-up-stub # + health-заглушка, пока нет API (#31) +make dev-logs +make dev-down +``` + +* Состояние: `/opt/awg-dash-dev/{config,data}` на Docker-хосте (`AWG_DASH_HOST_DIR`). +* Хост-порт: `AWG_DASH_PORT` (по умолчанию 8180 — 8080 занят на dev-хосте; в контейнере всегда 8080). +* Подробности, таблица AC, откат и pitfalls — [dev-stand.md](dev-stand.md). + +### Тестовый стенд без реального провайдера + +```bash +make test-netns # netns-топология client/gw/uplink + мок WireGuard-endpoint +make test-netns-ci # то же в привилегированном контейнере (путь для CI, #17) +make test-manifests # статическая проверка compose-файлов (без Docker) +make lint-shell # shellcheck для скриптов стенда +``` + +Описание топологии, тестов и pitfalls — [../tests/netns/README.md](../tests/netns/README.md). diff --git a/tests/deploy/test_compose_manifests.py b/tests/deploy/test_compose_manifests.py new file mode 100644 index 0000000..1ec81c8 --- /dev/null +++ b/tests/deploy/test_compose_manifests.py @@ -0,0 +1,68 @@ +"""Тесты статических инвариантов compose-файлов стенда (задача #21). + +Проверяют то, что иначе выяснилось бы только на Docker-хосте: порт 8080, +монтирования /config и /data, healthcheck, capabilities, privileged netns-раннер. +Работают без Docker — только PyYAML (см. requirements-dev.txt). +""" + +from __future__ import annotations + +import pathlib +import unittest + +import validate_compose + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +@unittest.skipUnless(validate_compose.yaml is not None, "нужен PyYAML (make venv)") +class ComposeManifestsTest(unittest.TestCase): + def test_dev_compose_matches_acceptance_criteria(self) -> None: + errors, _ = validate_compose.validate(require_dockerfile=False) + self.assertEqual([], errors, "compose-файлы стенда нарушают инварианты AC #21") + + def test_dev_compose_publishes_app_port_to_host(self) -> None: + doc = validate_compose.load(validate_compose.DEV) + ports = " ".join(str(p) for p in doc["services"]["awg-dash"]["ports"]) + self.assertIn(":8080", ports, "наружу должен публиковаться контейнерный порт приложения 8080") + + def test_dev_compose_mounts_state_directories(self) -> None: + doc = validate_compose.load(validate_compose.DEV) + targets = validate_compose._mount_targets(doc["services"]["awg-dash"]) + self.assertIn("/config", targets) + self.assertIn("/data", targets) + + def test_healthcheck_hits_api_v1_health(self) -> None: + doc = validate_compose.load(validate_compose.DEV) + test_cmd = validate_compose.jsonish(doc["services"]["awg-dash"]["healthcheck"]["test"]) + self.assertIn("/api/v1/health", test_cmd) + + def test_test_compose_runs_netns_harness_privileged(self) -> None: + doc = validate_compose.load(validate_compose.TEST) + svc = doc["services"]["netns"] + self.assertTrue(svc["privileged"]) + self.assertIn("tests/netns/run.sh", " ".join(svc["entrypoint"])) + + def test_stub_override_is_labelled_dev_only(self) -> None: + text = validate_compose.STUB.read_text(encoding="utf-8") + self.assertIn("api_stub.py", text) + self.assertIn("DEV", text.upper()) + + def test_netns_harness_entrypoints_exist(self) -> None: + for rel in ( + "tests/netns/run.sh", + "tests/netns/topology.sh", + "tests/netns/lib.sh", + "tests/netns/probe.py", + "tests/netns/mock_wg_endpoint.py", + "tests/netns/leak_sink.py", + "deploy/api_stub.py", + "deploy/verify-dev-stand.sh", + ): + with self.subTest(rel=rel): + self.assertTrue((ROOT / rel).exists(), f"нет файла {rel}") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From b8f715d38ab938f82de48ee82dc21a31c5f9b34f Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 21 Oct 2026 23:50:19 +0300 Subject: [PATCH 08/30] =?UTF-8?q?docs(#21):=20runbook=20dev-=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=20=E2=80=94=20=D0=B1=D1=8B=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=80=D1=82,=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20AC,=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21 Refs #15 --- deploy/dev-stand.md | 108 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 deploy/dev-stand.md diff --git a/deploy/dev-stand.md b/deploy/dev-stand.md new file mode 100644 index 0000000..175025e --- /dev/null +++ b/deploy/dev-stand.md @@ -0,0 +1,108 @@ +# dev/staging стенд awg-dash — runbook + +Задача **#21** (эпик **#15**). Стенд для локальной разработки и проверки +acceptance criteria до появления staging-окружения. + +* Docker-хост: **192.168.1.147** (`ssh root@192.168.1.147`), Docker 29.4.x + compose v5. +* Каталог состояния: **`/opt/awg-dash-dev/{config,data}`** (bind mount → `/config`, `/data`). +* Compose project: **`awg-dash-dev`** (изолирован от PoC-контейнеров `amneziawg-client`). +* Хост-порт приложения: **8180** (`AWG_DASH_PORT`), внутри контейнера — **8080**. + Порт 8080 на dev-хосте занят другим сервисом (drawio), поэтому проброс нестандартный. + +## Быстрый старт + +```bash +# на Docker-хосте, из корня репозитория +export AWG_DASH_HOST_DIR=/opt/awg-dash-dev +make dev-up # docker compose up -d --build (проект awg-dash-dev) +make dev-verify # проверка AC: health 200 + состояние переживает restart +curl -fsS http://127.0.0.1:8180/api/v1/health +make dev-logs # логи контейнера +make dev-down # остановить (каталоги состояния сохраняются) +``` + +До реализации FastAPI-каркаса (**#31**) `/api/v1/health` не отвечает — контейнер +работает как агент и помечается `unhealthy`. Чтобы проверить плумбинг стенда +(порт, healthcheck, тома, restart) **до** #31, используйте health-заглушку: + +```bash +make dev-up-stub # + deploy/docker-compose.stub.yml (DEV-ONLY, api_stub.py) +make dev-verify-stub # AC-1 проходит на заглушке, в отчёте api=stub +``` + +Заглушка отвечает `{"status":"ok","stub":true,...}` только на `/api/v1/health` и +никогда не должна использоваться в staging/prod. + +## Проверка AC (#21) + +| AC | Команда | Ожидание | +|---|---|---| +| AC-1: health 200 | `curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:8180/api/v1/health` | `200` (после мержа #31; до него — заглушка через `dev-verify-stub`) | +| AC-2: состояние переживает restart | `make dev-verify` | маркеры в `/config` и `/data` совпадают до и после `docker compose restart` и `--force-recreate` | +| AC-3: netns + мок-endpoint | `make test-netns` | 8/8 тестов, netns `awgt21-{client,gw,uplink}` создаются и сносятся | + +`deploy/verify-dev-stand.sh` (за `make dev-verify`) печатает отчёт и возвращает: + +| Код | Значение | +|---|---| +| 0 | AC-1 и AC-2 выполнены | +| 1 | регрессия стенда (health не 200 с заглушкой, состояние не сохранилось) | +| 2 | AC-1 заблокирован: API (#31) ещё не реализован, стенд при этом поднят | +| 3 | заблокировано окружением: нет docker/compose или compose-файлов | + +## Состав стенда + +| Файл | Назначение | +|---|---| +| `docker-compose.dev.yml` | приложение: порт 8080 (наружу `${AWG_DASH_PORT:-8180}`), bind mount `/config` `/data`, healthcheck, NET_ADMIN/NET_RAW/SYS_MODULE, `/dev/net/tun` | +| `docker-compose.stub.yml` | DEV-ONLY оверрайд: health-заглушка до #31 (`api_stub.py`) | +| `docker-compose.test.yml` | тестовый стенд: привилегированный `netns`-раннер + `app` для API-тестов | +| `api_stub.py` | минимальный HTTP-сервер `/api/v1/health` (только dev) | +| `verify-dev-stand.sh` | проверка AC на Docker-хосте + JSON-отчёт | +| `../tests/netns/` | netns-харнесс: топология client/gw/uplink + мок WireGuard-endpoint | + +## Переменные окружения + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `AWG_DASH_PORT` | `8180` | хост-порт приложения | +| `AWG_DASH_HOST_DIR` | `/opt/awg-dash-dev` | каталог состояния на хосте | +| `AWG_DASH_IMAGE` | `awg-dash:dev` | имя образа (build или pull) | +| `AWG_DASH_DOCKERFILE` | `deploy/Dockerfile` | путь к Dockerfile для build | +| `COMPOSE_PROJECT_NAME` | `awg-dash-dev` | изоляция от других стендов | +| `AWG_LOG_LEVEL` | `info` | уровень логирования приложения | + +## Зависимости + +| Зависимость | Состояние | Влияние на стенд | +|---|---|---| +| #16 (каркас репо, Makefile) | PR #47 | нужен `Makefile`, `requirements.txt`, `deploy/` | +| #18 (образ, entrypoint) | PR #49 | нужен `deploy/Dockerfile` для `--build` | +| #31 (каркас FastAPI, `/api/v1/health`) | Sprint 2 | без него AC-1 выполняется только с заглушкой | +| #17 (CI-пайплайн) | Sprint 0 | `make test-netns-ci`, `make test-manifests` — готовые гейты для пайплайна | + +## Откат и уборка + +```bash +make dev-down # остановить контейнеры проекта awg-dash-dev +make dev-reset # down --remove-orphans (состояние сохраняется) +docker image rm awg-dash:dev # откатить образ (пересобрать из предыдущего коммита) +du -sh /opt/awg-dash-dev # состояние стенда +``` + +Перед откатом образа проверьте проект: `docker compose -p awg-dash-dev ls`. +Состояние (`/opt/awg-dash-dev`) не удаляется автоматически — это осознанно: там +конфиги туннеля (`/config/awg0.conf`). Удаляйте вручную и только после бэкапа. + +## Pitfalls + +* **Порт 8080 занят** на dev-хосте — наружу публикуется 8180. Меняйте `AWG_DASH_PORT`. +* **`/dev/net/tun` обязателен**: без него entrypoint падает (`fail '/dev/net/tun не проброшен'`). +* **Права на каталоги состояния**: entrypoint делает `chown awg:awg /config /data`; + каталоги создаются на хосте от root — не меняйте владельца на хосте вручную. +* **`docker compose restart` сохраняет тома**; `down -v` — нет. Никогда не используйте + `down -v` на этом стенде: это удалит `/config` и `/data`. +* **Заглушка `api_stub.py`** — только dev: она отвечает 200 на `/api/v1/health` и + маскирует отсутствие API. Не включайте `docker-compose.stub.yml` в staging. +* **netns-стенд вне Docker** требует root: `make test-netns` вернёт код 3, если нет + CAP_NET_ADMIN/CAP_SYS_ADMIN (например, в непривилегированном CI-раннере). From 8e0d76147eb07ebcceb5064440d16ac1b64c92a1 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Thu, 22 Oct 2026 00:13:02 +0300 Subject: [PATCH 09/30] =?UTF-8?q?deploy(#21):=20dev-up=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20health-=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B4=20=D0=B4=D0=BE=20#31;=20--build=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=84=D0=BE=D1=80=D1=81=D0=B8=D1=80=D1=83=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B1=D0=B5=D0=B7=20deploy/Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21 Refs #15 --- Makefile | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 47a94bf..d13af0e 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ NETNS_ARGS ?= VERIFY_ARGS ?= MANIFEST_ARGS ?= -.PHONY: help venv lint fmt test build dev-up dev-up-stub dev-down dev-logs dev-verify \ +.PHONY: help venv lint fmt test build dev-up dev-up-app dev-up-stub dev-down dev-logs dev-verify \ dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell help: @@ -43,32 +43,57 @@ build: docker build -f deploy/Dockerfile -t $(IMAGE) . # --- dev/staging стенд (задача #21) ------------------------------------------ +# Пока FastAPI-каркас (#31) не реализован, API в образе не поднимается и AC-1 +# (`curl :8080/api/v1/health` -> 200) недостижим. Чтобы гейт #21 проверялся на +# живом стенде, `dev-up` автоматически добавляет оверрайд с health-заглушкой +# (deploy/docker-compose.stub.yml), ПОКА файла приложения нет. После мержа #31 +# оверрайд отключается сам. Явные режимы: dev-up-app / dev-up-stub. +API_APP ?= backend/awg_api/app.py +ifneq ($(wildcard $(API_APP)),) +COMPOSE_DEV_EFFECTIVE := -f $(COMPOSE_DEV) +STUB_MODE := 0 +else +COMPOSE_DEV_EFFECTIVE := -f $(COMPOSE_DEV) -f $(COMPOSE_STUB) +STUB_MODE := 1 +endif +# deploy/Dockerfile приходит с задачей #18; до этого образ собирается отдельно, +# поэтому --build не форсируем, если Dockerfile отсутствует в дереве. +DEV_UP_BUILD := $(if $(wildcard deploy/Dockerfile),--build,) +DEV_ENV := AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) +DEV_VERIFY_ARGS := $(if $(filter 1,$(STUB_MODE)),--stub,) + dev-up: @test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; } @command -v docker >/dev/null 2>&1 || { echo 'нет docker: dev-стенд поднимается на Docker-хосте (см. deploy/dev-stand.md)'; exit 1; } - AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) up -d --build + @[ "$(STUB_MODE)" = "0" ] || echo 'dev-up: API (#31) ещё не в дереве — поднимаю стенд с health-заглушкой (deploy/docker-compose.stub.yml)' + $(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) up -d $(DEV_UP_BUILD) -# Health-заглушка вместо API до мержа #31 — только для проверки плумбинга стенда. +# Только реальный образ: проверка отказа API без заглушки / режим после #31. +dev-up-app: + @test -f $(COMPOSE_DEV) || { echo '$(COMPOSE_DEV) ещё нет — задача #21'; exit 1; } + $(DEV_ENV) $(COMPOSE) -f $(COMPOSE_DEV) up -d $(DEV_UP_BUILD) + +# Только health-заглушка (проверка плумбинга стенда до #31). dev-up-stub: @test -f $(COMPOSE_STUB) || { echo '$(COMPOSE_STUB) ещё нет — задача #21'; exit 1; } - AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) -f $(COMPOSE_STUB) up -d --build + $(DEV_ENV) $(COMPOSE) -f $(COMPOSE_DEV) -f $(COMPOSE_STUB) up -d $(DEV_UP_BUILD) dev-down: - AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) down + $(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down dev-logs: - AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) logs -f --tail=100 + $(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) logs -f --tail=100 # Проверка AC #21 на Docker-хосте: health 200 + состояние переживает restart. dev-verify: - AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) bash $(VERIFY) $(VERIFY_ARGS) + $(DEV_ENV) bash $(VERIFY) $(DEV_VERIFY_ARGS) $(VERIFY_ARGS) dev-verify-stub: - AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) bash $(VERIFY) --stub $(VERIFY_ARGS) + $(DEV_ENV) bash $(VERIFY) --stub $(VERIFY_ARGS) # Останавливает стенд; каталоги состояния сохраняются (удалять вручную). dev-reset: - AWG_DASH_HOST_DIR=$(HOST_DIR) $(COMPOSE) -f $(COMPOSE_DEV) down --remove-orphans + $(DEV_ENV) $(COMPOSE) $(COMPOSE_DEV_EFFECTIVE) down --remove-orphans @echo "каталоги состояния сохранены: $(HOST_DIR)/config, $(HOST_DIR)/data (удалять вручную)" # --- netns-стенд (задача #21) ------------------------------------------------ From 73e23bc3e4c26bde8e5fb4e43067e8871de7ad3c Mon Sep 17 00:00:00 2001 From: embeddevio Date: Thu, 22 Oct 2026 00:13:07 +0300 Subject: [PATCH 10/30] =?UTF-8?q?fix(#21):=20verify-dev-stand=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B0=D1=91=D1=82=20=D0=BB=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20[fail]=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20--forc?= =?UTF-8?q?e-recreate=20(=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=20id)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21 Refs #15 --- deploy/verify-dev-stand.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/verify-dev-stand.sh b/deploy/verify-dev-stand.sh index 4c243aa..c4e13f1 100644 --- a/deploy/verify-dev-stand.sh +++ b/deploy/verify-dev-stand.sh @@ -84,7 +84,8 @@ if [ "$DO_UP" -eq 1 ]; then fi compose ps >/dev/null 2>&1 || fatal_env "compose project не поднят" -CID="$(compose ps -q awg-dash | head -n 1)" +cid() { compose ps -q awg-dash | head -n 1; } +CID="$(cid)" [ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project" wait_health() { @@ -143,6 +144,9 @@ if compose exec -T awg-dash sh -c "printf '%s\n' '$MARKER_TEXT' > /config/.ac2-m # 2) пересоздание контейнера (сильнее, чем restart) compose up -d --force-recreate >/dev/null if [ "$AC1" = "pass" ]; then wait_health 20 || NOTES+=("после пересоздания health не поднялся за 40с"); fi + # контейнер пересоздан — id сменился; без перезапроса docker inspect падает с 'no such object' + CID="$(cid)" + [ -n "$CID" ] || fatal_env "контейнер awg-dash не найден после пересоздания" CFG_RECREATE="$(compose exec -T awg-dash cat /config/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" DATA_RECREATE="$(compose exec -T awg-dash cat /data/.ac2-marker 2>/dev/null | tr -d '\r\n' || true)" @@ -170,6 +174,8 @@ else fi # --- монтирования и здоровье ------------------------------------------------- +CID="$(cid)" +[ -n "$CID" ] || fatal_env "контейнер awg-dash не найден в compose project (после перезапуска)" MOUNTS="$(docker inspect -f '{{range .Mounts}}{{.Destination}}<-{{.Source}}{{"\n"}}{{end}}' "$CID" 2>/dev/null || true)" printf '%s\n' "$MOUNTS" | grep -q '/config' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /config"; } printf '%s\n' "$MOUNTS" | grep -q '/data' || { FAILURES=$((FAILURES + 1)); bad "нет монтирования /data"; } From 7af9cabad3963bb916e7b88682ec72c796e672ef Mon Sep 17 00:00:00 2001 From: embeddevio Date: Thu, 22 Oct 2026 00:13:07 +0300 Subject: [PATCH 11/30] =?UTF-8?q?docs(#21):=20=D0=B2=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B5=20compose=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=B0=D0=B2=D1=82=D0=BE-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20de?= =?UTF-8?q?v-up=20=D0=B4=D0=BE=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21 --- deploy/docker-compose.dev.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 1097bd3..6bed89a 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -8,10 +8,11 @@ # Логи: make dev-logs # Остановка: make dev-down (тома/каталоги состояния сохраняются) # -# AC-1 (health 200) выполняется только после реализации FastAPI-каркаса (#31): -# до этого контейнер отдаёт healthcheck unhealthy, а `/config` `/data` уже -# проверяются скриптом deploy/verify-dev-stand.sh. Заглушка для ранней проверки -# стенда — deploy/docker-compose.stub.yml + `make dev-up-stub`. +# AC-1 (health 200) до реализации FastAPI-каркаса (#31): сам образ API не поднимает, +# поэтому `make dev-up` автоматически добавляет оверрайд с health-заглушкой +# (deploy/docker-compose.stub.yml, ответ помечен "stub": true) и стенд проходит +# healthcheck. После появления backend/awg_api/app.py оверрайд не добавляется. +# Явные режимы: `make dev-up-app` (только реальный образ), `make dev-up-stub`. name: ${COMPOSE_PROJECT_NAME:-awg-dash-dev} From f5e8272c3ac51a6467712fd9d80362e4439b6ba4 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Thu, 22 Oct 2026 00:16:00 +0300 Subject: [PATCH 12/30] =?UTF-8?q?docs(#21):=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=20=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D1=85=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B3=D0=B5=D0=B9=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?AC=20=D0=BD=D0=B0=20192.168.1.147=20(HEAD=207af9caba)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21 Refs #15 --- deploy/VERIFICATION-21.md | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 deploy/VERIFICATION-21.md diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md new file mode 100644 index 0000000..11349b4 --- /dev/null +++ b/deploy/VERIFICATION-21.md @@ -0,0 +1,127 @@ +# Проверка dev/staging стенда (#21) — фактические прогоны на Docker-хосте + +Дата: 2026-10-22 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17) +Ветка: `feat/21-dev-stand`, HEAD `7af9caba` · Ref: #21, #15, #17, #18, #31 + +## 0. Стенд запускает именно то, что в ветке + +Каталог стенда `(/opt/awg-dash-dev/repo)` не является git-чекаутом (`git clone` по HTTP обрывается +на этом Gitea — `fetch-pack: invalid index-pack output`), поэтому файлы сверены побайтово +через git-blob-SHA с деревом HEAD: + +| файл | git blob (HEAD == хост) | +|---|---| +| `Makefile` | `d13af0e752cb0dfc28e94ed1ad3a4ea448d58811` | +| `deploy/docker-compose.dev.yml` | `6bed89a5ea2a197aa9f6fd6bcda6d8131303114d` | +| `deploy/docker-compose.stub.yml` | `936efb38dfff4ba8b942c9757acd4ba9c39c4064` | +| `deploy/docker-compose.test.yml` | `da01e80a459580cd063359c46560adb8285434d1` | +| `deploy/verify-dev-stand.sh` | `c4e13f11f9023700be531ebb312ad33d7beea4d8` | +| `deploy/api_stub.py` | `848922dc543a6a2abffa49c02efeba383f02455f` | +| `tests/deploy/validate_compose.py` | `0fa8b3c02675a364f4f5b447220680a7818faa5a` | +| `tests/netns/*` (10 файлов) | совпадают с деревом (см. `git ls-tree feat/21-dev-stand tests/netns`) | + +## 1. Предварительные пакеты на стенд-хосте + +```bash +apt-get install -y make python3-yaml python3-pytest python3-pytest-cov shellcheck +``` + +(`make` на хосте отсутствовал — без него гейты `make *` невоспроизводимы; пакеты добавлены +сверх существующего набора, ничего не удалялось и не переконфигурировалось.) + +## 2. Гейты (реальные прогоны) + +Лог целиком: `/opt/awg-dash-dev/verify-final-1792617226.log` (на хосте). + +| гейт | команда | результат | +|---|---|---| +| манифесты | `make test-manifests` | exit 0, `validate-compose: ok (1 предупреждений)`¹ | +| статический анализ шелла | `make lint-shell` | exit 0 | +| unit-тесты манифестов | `python3 -m pytest tests/deploy -q --no-cov` | exit 0 | +| netns-харнесс (AC-3) | `make test-netns` | exit 0, `8 ok / 0 fail / 0 err`, отчёт `/tmp/awg-netns/report.json` | +| AC-1 (health 200) | `make dev-up` + `curl :8180/api/v1/health` | HTTP 200, контейнер `healthy` | +| AC-2 (состояние переживает рестарт) | `make dev-verify` | `[ ok ] AC-1`, `[ ok ] AC-2` ×2, exit 0 | +| логи | `make dev-logs` | стрим логов, есть `[api-stub] ... GET /api/v1/health 200` | + +¹ предупреждение: `build.dockerfile deploy/Dockerfile отсутствует в дереве (ожидаемо до мержа #18)`. +Образ `awg-dash:dev` для стенда собран из Dockerfile PR #49 (ветка `feat/18-docker-runtime`), в дерево +этой ветки он не входит: `make build` вернёт внятную ошибку до мержа #18. Валидатор переключается в +режим ошибки флагом `make test-manifests MANIFEST_ARGS=--require-dockerfile` — этот режим нужно включить +в CI гейте после мержа #18. + +### AC-1 — детали + +``` +$ make dev-up +# (авто-режим: backend/awg_api/app.py отсутствует (#31) → + deploy/docker-compose.stub.yml) +$ curl -s -w '\nHTTP=%{http_code}\n' http://127.0.0.1:8180/api/v1/health +{"issue": "#31", "note": "заглушка dev-стенда: реальный API появится в #31", "status": "ok", + "stub": true, "stub_env": "1", "uptime_s": 14.909} +HTTP=200 +$ docker ps --filter name=awg-dash-dev +awg-dash-dev Up 15 seconds (healthy) 0.0.0.0:8180->8080/tcp, [::]:8180->8080/tcp +``` + +Почему с заглушкой: FastAPI-каркас (#31) ещё не реализован, сам образ HTTP не поднимает (entrypoint +пишет `awg_api/app.py ещё не реализован` и ждёт). Чтобы AC-1 #21 проверялся на живом compose-стенде +уже в Sprint 0, `make dev-up` добавляет оверрайд `docker-compose.stub.yml` **только пока +`backend/awg_api/app.py` отсутствует**; ответ явно помечен `"stub": true`. После мержа #31 оверрайд +не добавляется, `make dev-up` поднимает реальный API без правок Makefile. + +### AC-2 — детали + +``` +$ make dev-verify +[verify][ ok ] AC-1: http://127.0.0.1:8180/api/v1/health -> 200 (api=stub) +[verify] AC-2: пишу маркеры в /config и /data и перезапускаю контейнер +[verify][ ok ] AC-2: /config и /data пережили restart и пересоздание контейнера +[verify][ ok ] AC-2: маркеры видны на хосте (/opt/awg-dash-dev/{config,data}) — это bind mount, а не слой контейнера + +$ ls /opt/awg-dash-dev/config /opt/awg-dash-dev/data +/opt/awg-dash-dev/config/.ac2-marker (24 байта) +/opt/awg-dash-dev/data/.ac2-marker (24 байта) +``` + +Проверяется два сценария: `docker compose restart` (как в AC) и `up -d --force-recreate` (сильнее). + +### AC-3 (netns) — детали + +``` +[netns] создаю netns: client=awgt21-client gw=awgt21-gw uplink=awgt21-uplink +[ ok ] стенд поднят: awgt21-client(10.213.0.2) -> awgt21-gw(10.213.0.1/10.214.0.1) -> awgt21-uplink(10.214.0.2) +[ ok ] policy routing: fwmark 51820 -> table 51820 (default via 10.214.0.2) +[ ok ] mock endpoint: udp/51820 в awgt21-uplink; детектор утечки: 198.51.100.9:9999/udp +Ran 8 tests in 5.447s — OK +test_01_namespaces_and_links ... test_08_killswitch_blocks_direct_egress — все ok +[ ok ] топология снесена, namespace'ы удалены (ip netns list пусто) +``` + +`report.json`: `{"suite": "tests/netns", "tests": 8, "passed": 8, "failures": 0, "errors": 0, +"skipped": 0, "returncode": 0, "duration_s": 5.447}` + +## 3. Что чинилось в этой итерации + +| проблема | причина | правка | +|---|---|---| +| `make dev-up` давал `unhealthy` и AC-1 не выполнялся | API (#31) не реализован | `Makefile`: авто-подключение stub-оверрайда, пока нет `backend/awg_api/app.py`; `dev-up-app` / `dev-up-stub` для явных режимов | +| `make dev-verify` печатал `[verify][fail] нет монтирования /config` при реально сохранённых томах и падал | `verify-dev-stand.sh` кэшировал id контейнера и делал `docker inspect` по нему после `--force-recreate` → `no such object` | перезапрос id (`cid()`) после пересоздания и явный `fatal_env` вместо ложного fail | +| `dev-up` падал без `deploy/Dockerfile` (`--build`) | Dockerfile живёт в #18, ветка не смёржена | `--build` форсируется только при наличии `deploy/Dockerfile` | + +## 4. Ограничения и что делать дальше + +1. **CI-гейты (DoD) не автоматизированы:** Gitea Actions runner для `embeddevio/awg-dash` не зарегистрирован + (см. отчёт в #17, run #1 остаётся `queued`). Все гейты в таблице §2 прогнаны **вручную** на стенд-хосте; + после регистрации runner'а в #17 они переносятся в workflow без изменений команд. +2. **Порт:** на `192.168.1.147` хост-порт 8080 занят (`drawio`), поэтому стенд публикуется как `8180:8080` + (контейнерный порт приложения — 8080, `AWG_DASH_PORT` переопределяет хост-порт). +3. **Сборка образа** — только после мержа #18; до этого образ `awg-dash:dev` собирается из ветки #18 вручную. +4. **Prod/staging:** stub-оверрайд и `deploy/api_stub.py` — DEV-ONLY. В staging/prod поднимается только + `docker-compose.dev.yml` (или прод-манифест) без stub-файла. + +## 5. Откат / очистка + +```bash +make dev-down # остановить стенд, каталоги /opt/awg-dash-dev/{config,data} сохраняются +make dev-reset # + удалить осиротевшие контейнеры/сети проекта +make test-netns # netns-харнесс сносит топологию сам; аварийно: bash tests/netns/topology.sh down +``` From f0847001ee70d9536c57d142f10a18c41cc6df03 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:30:01 +0300 Subject: [PATCH 13/30] =?UTF-8?q?netns:=20=D0=B2=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=20namespace=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20nsenter?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B5=20?= =?UTF-8?q?ip=20netns=20exec=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib.sh: ns_exec()/ns_prefix() с автоопределением режима (AWG_NS_EXEC_MODE), stand_fail() для кода 4; cleanup в run.sh больше не оставляет мок и sink. - run.sh/topology.sh: коды возврата нормализованы к 0/1/3/4 (255 от iproute2 -> 4). Refs #21, Refs #15, Refs #56 --- tests/netns/lib.sh | 110 ++++++++++++++++++++++++++++++++++++++++----- tests/netns/run.sh | 40 ++++++++++++----- 2 files changed, 126 insertions(+), 24 deletions(-) diff --git a/tests/netns/lib.sh b/tests/netns/lib.sh index de2130f..8063752 100644 --- a/tests/netns/lib.sh +++ b/tests/netns/lib.sh @@ -67,6 +67,92 @@ fail() { printf '%s[fail]%s %s\n' "$_c_red" "$_c_off" "$*" >&2; exit 1; } # см. deploy/dev-stand.md. 4 — внутренняя ошибка стенда. blocked() { printf '%s[blocked]%s %s\n' "$_c_yel" "$_c_off" "$*" >&2; exit 3; } +# Ошибка самого стенда (не тестов и не окружения) — код 4. +stand_fail() { echo "[fail] $*" >&2; exit 4; } + +# --- вход в netns ----------------------------------------------------------- +# `ip netns exec` и `ip -n ` (iproute2 6.x) перемонтируют /sys внутри +# namespace и в контейнере падают с "mount of /sys failed: Operation not +# permitted", хотя `ip netns add` работает (issue #56). nsenter входит в тот же +# /var/run/netns/ без перемонтирования — используется как fallback. +# Режим: AWG_NS_EXEC_MODE=auto|ip|nsenter (auto — определить один раз пробой). +: "${AWG_NS_EXEC_MODE:=auto}" +NS_EXEC_RESOLVED="" +NS_EXEC_IP_CMD=(ip netns exec) + +_ns_exec_probe() { + # $1 — режим; 0 — вход в свежесозданный netns работает + local mode="$1" probe="${AWG_NETNS_PREFIX}-probe" rc=0 + ip netns add "$probe" 2>/dev/null || return 1 + case "$mode" in + ip) + timeout 5 "${NS_EXEC_IP_CMD[@]}" "$probe" true >/dev/null 2>&1 || rc=1 + ;; + nsenter) + timeout 5 nsenter --net="/var/run/netns/${probe}" true >/dev/null 2>&1 || rc=1 + ;; + *) + rc=1 + ;; + esac + ip netns delete "$probe" 2>/dev/null || true + return "$rc" +} + +select_ns_exec_mode() { + # Печатает выбранный режим; код 3 — окружение не даёт войти в netns. + case "$AWG_NS_EXEC_MODE" in + ip|nsenter) + echo "$AWG_NS_EXEC_MODE" + return 0 + ;; + esac + if _ns_exec_probe ip; then + echo ip + return 0 + fi + if command -v nsenter >/dev/null 2>&1 && _ns_exec_probe nsenter; then + warn "'ip netns exec' не работает (mount of /sys: Operation not permitted, issue #56) — использую nsenter --net" + echo nsenter + return 0 + fi + blocked "ни 'ip netns exec', ни 'nsenter --net' не работают: нет CAP_SYS_ADMIN/NET_ADMIN (см. issue #56)" +} + +# ns_exec <команда...> — выполнить команду внутри namespace. +ns_exec() { + local ns="$1" + shift + if [ -z "$NS_EXEC_RESOLVED" ]; then + NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3 + fi + case "$NS_EXEC_RESOLVED" in + nsenter) nsenter --net="/var/run/netns/${ns}" "$@" ;; + *) "${NS_EXEC_IP_CMD[@]}" "$ns" "$@" ;; + esac +} + +# ns_prefix — напечатать команду входа в netns ПО СЛОВУ НА СТРОКУ. +# Нужна для фонового запуска: `mapfile -t P < <(ns_prefix NS)` + +# `( exec "${P[@]}" python3 ... ) &` даёт в $! pid самого процесса, тогда cleanup +# убивает его, а не обёртку-функцию (иначе мок и детектор утечки остаются жить +# после прогона и держат stdout). +ns_prefix() { + local ns="$1" + if [ -z "$NS_EXEC_RESOLVED" ]; then + NS_EXEC_RESOLVED="$(select_ns_exec_mode)" || exit 3 + fi + if [ "$NS_EXEC_RESOLVED" = nsenter ]; then + echo nsenter + echo --net="/var/run/netns/${ns}" + else + echo ip + echo netns + echo exec + echo "$ns" + fi +} + # --- предусловия ------------------------------------------------------------ require_root() { [ "$(id -u)" -eq 0 ] || blocked "нужен root (или CAP_NET_ADMIN): запустите под sudo" @@ -98,18 +184,18 @@ fw_kind() { local want="${AWG_FW_BACKEND:-auto}" case "$want" in nft) - ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; } + ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1 && { echo nft; return; } echo none; return ;; iptables) - ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; } + ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1 && { echo iptables; return; } echo none; return ;; esac # command -v — builtin shell, поэтому проверяем через sh -c внутри netns - if ip netns exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then + if ns_exec "$AWG_NS_GW" sh -c 'command -v nft' >/dev/null 2>&1; then echo nft - elif ip netns exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then + elif ns_exec "$AWG_NS_GW" sh -c 'command -v iptables' >/dev/null 2>&1; then echo iptables else echo none @@ -119,8 +205,8 @@ fw_kind() { fw_available() { local kind; kind="$(fw_kind)" case "$kind" in - nft) ip netns exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;; - iptables) ip netns exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;; + nft) ns_exec "$AWG_NS_GW" nft list ruleset >/dev/null 2>&1 ;; + iptables) ns_exec "$AWG_NS_GW" iptables -t mangle -L -n >/dev/null 2>&1 ;; *) return 1 ;; esac } @@ -130,8 +216,8 @@ fw_setup() { local kind; kind="$(fw_kind)" case "$kind" in nft) - ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true - ip netns exec "$AWG_NS_GW" nft -f - </dev/null || true + ns_exec "$AWG_NS_GW" nft -f - </dev/null || true $ipt -t mangle -F "$IPT_CHAIN_MARK" $ipt -t mangle -A "$IPT_CHAIN_MARK" -p udp --dport "$AWG_MOCK_PORT" \ @@ -182,17 +268,17 @@ fw_set_killswitch() { local state="$1" kind; kind="$(fw_kind)" case "$kind:$state" in nft:on) - ip netns exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true + ns_exec "$AWG_NS_GW" nft delete table inet "$NFT_TABLE" 2>/dev/null || true fw_setup ;; nft:off) - ip netns exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks + ns_exec "$AWG_NS_GW" nft flush chain inet "$NFT_TABLE" forward_ks ;; iptables:on) fw_setup ;; iptables:off) - ip netns exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS" + ns_exec "$AWG_NS_GW" iptables -t filter -F "$IPT_CHAIN_KS" ;; *) fail "fw_set_killswitch: неизвестное состояние '$state' (ожидается on|off)" diff --git a/tests/netns/run.sh b/tests/netns/run.sh index ee7aa25..c6797bf 100644 --- a/tests/netns/run.sh +++ b/tests/netns/run.sh @@ -27,6 +27,12 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/netns/lib.sh source "$HERE/lib.sh" +# Контракт кодов возврата: 0 — успех, 1 — тесты упали, 3 — заблокировано +# окружением, 4 — ошибка стенда. Неожиданные коды утилит (например 255 от +# `ip netns exec` в контейнере — issue #56) нормализуем в 4, чтобы CI #17 +# различал «нет прав» и «сломался стенд». +trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "run.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR + KEEP_TOPO=0 NO_TESTS=0 REPORT="" @@ -52,6 +58,9 @@ require_root require_cmd ip require_cmd python3 require_netns_capability +NS_EXEC_RESOLVED="$(select_ns_exec_mode)" +export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED" +log "вход в netns: ${NS_EXEC_RESOLVED}" if [ -n "$WORKDIR_OPT" ]; then export AWG_NETNS_WORKDIR="$WORKDIR_OPT" @@ -81,10 +90,16 @@ cleanup() { [ "$CLEANED" -eq 1 ] && return 0 CLEANED=1 for pid in "$MOCK_PID" "$SINK_PID"; do - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - kill -TERM "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - fi + [ -n "$pid" ] || continue + kill -0 "$pid" 2>/dev/null || continue + kill -TERM "$pid" 2>/dev/null || true + # до 2с на штатное завершение, затем SIGKILL — иначе прогон «висит» на wait + for _ in $(seq 1 20); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true done if [ "$KEEP_TOPO" -eq 1 ]; then warn "топология сохранена (--keep-topology): ${AWG_NS_CLIENT} ${AWG_NS_GW} ${AWG_NS_UPLINK}" @@ -100,24 +115,25 @@ log "рабочий каталог: ${AWG_NETNS_WORKDIR}" bash "$HERE/topology.sh" up log "запускаю мок WireGuard-endpoint (udp/${AWG_MOCK_PORT}) в ${AWG_NS_UPLINK}" -ip netns exec "$AWG_NS_UPLINK" python3 "$HERE/mock_wg_endpoint.py" \ - --bind "0.0.0.0:${AWG_MOCK_PORT}" --log "$AWG_MOCK_LOG" --ready "$AWG_MOCK_READY" \ - --mode-file "$AWG_MOCK_MODE_FILE" --mode respond & +mapfile -t NS_PREFIX < <(ns_prefix "$AWG_NS_UPLINK") +( exec "${NS_PREFIX[@]}" python3 "$HERE/mock_wg_endpoint.py" \ + --bind "0.0.0.0:${AWG_MOCK_PORT}" --log "$AWG_MOCK_LOG" --ready "$AWG_MOCK_READY" \ + --mode-file "$AWG_MOCK_MODE_FILE" --mode respond ) & MOCK_PID=$! log "запускаю детектор утечки (${AWG_LEAK_IP}:${AWG_LEAK_PORT}) в ${AWG_NS_UPLINK}" -ip netns exec "$AWG_NS_UPLINK" python3 "$HERE/leak_sink.py" \ - --ip "$AWG_LEAK_IP" --port "$AWG_LEAK_PORT" --log "$AWG_LEAK_LOG" --ready "$AWG_LEAK_READY" & +( exec "${NS_PREFIX[@]}" python3 "$HERE/leak_sink.py" \ + --ip "$AWG_LEAK_IP" --port "$AWG_LEAK_PORT" --log "$AWG_LEAK_LOG" --ready "$AWG_LEAK_READY" ) & SINK_PID=$! # ждём готовности обоих процессов (файлы-флаги пишутся после bind) for _ in $(seq 1 100); do if [ -f "$AWG_MOCK_READY" ] && [ -f "$AWG_LEAK_READY" ]; then break; fi - if ! kill -0 "$MOCK_PID" 2>/dev/null; then fail "мок WireGuard-endpoint упал при старте"; fi + if ! kill -0 "$MOCK_PID" 2>/dev/null; then stand_fail "мок WireGuard-endpoint упал при старте"; fi sleep 0.1 done -[ -f "$AWG_MOCK_READY" ] || fail "мок WireGuard-endpoint не поднялся за 10с" -[ -f "$AWG_LEAK_READY" ] || fail "детектор утечки не поднялся за 10с" +[ -f "$AWG_MOCK_READY" ] || stand_fail "мок WireGuard-endpoint не поднялся за 10с" +[ -f "$AWG_LEAK_READY" ] || stand_fail "детектор утечки не поднялся за 10с" ok "мок-endpoint и детектор утечки готовы" # --- тесты ------------------------------------------------------------------- From 4aa69132ad9c31722f660b4f5c7627d630992100 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:30:22 +0300 Subject: [PATCH 14/30] =?UTF-8?q?netns:=20nsenter=20=D0=B2=20topology.sh,?= =?UTF-8?q?=20netns=5Fcommon=20=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - topology.sh: ns_exec для всех ip -n и ip netns exec, коды 0/1/3/4. - netns_common.py: ns_exec_mode()/ns_exec_direct(), addrs/links/link_is_up через ns_sh. - docker-compose.test.yml: проброс AWG_NS_EXEC_MODE в netns-сервис. - tests/netns/README.md: режимы входа в netns, коды возврата, новый pitfall. Refs #21, Refs #15, Refs #56 --- deploy/docker-compose.test.yml | 3 ++ tests/netns/README.md | 18 ++++++++-- tests/netns/netns_common.py | 62 +++++++++++++++++++++++++++++++--- tests/netns/topology.sh | 58 +++++++++++++++++-------------- 4 files changed, 109 insertions(+), 32 deletions(-) diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml index da01e80..b628c12 100644 --- a/deploy/docker-compose.test.yml +++ b/deploy/docker-compose.test.yml @@ -31,6 +31,9 @@ services: AWG_NETNS_PREFIX: ${AWG_NETNS_PREFIX:-awgt21} # nft в образе приложения нет — там iptables (см. tests/netns/README.md) AWG_FW_BACKEND: ${AWG_FW_BACKEND:-auto} + # В контейнере `ip netns exec` падает (mount of /sys, issue #56) — харнесс + # сам переключается на nsenter --net; переменная позволяет форсировать режим. + AWG_NS_EXEC_MODE: ${AWG_NS_EXEC_MODE:-auto} tmpfs: - /tmp:mode=1777 logging: diff --git a/tests/netns/README.md b/tests/netns/README.md index e75510f..8458631 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -35,6 +35,7 @@ make test-netns # поднять стенд, прогнать т make test-netns-keep # оставить топологию и логи для отладки make test-netns NETNS_ARGS="--no-tests" # только поднять стенд make test-netns-ci # то же в привилегированном контейнере (путь для CI) + # в контейнере вход в netns идёт через nsenter (issue #56) bash tests/netns/topology.sh status # состояние топологии bash tests/netns/topology.sh down # снести топологию вручную @@ -43,8 +44,14 @@ bash tests/netns/topology.sh killswitch on # включить fail-closed пр Требования: Linux, **root** (или CAP_NET_ADMIN + CAP_SYS_ADMIN), `ip`, `python3`; backend netfilter — `nft` или `iptables` (в alpine-образе приложения есть только -`iptables`, на Debian/Ubuntu — оба). Код возврата `3` означает «заблокировано -окружением»: нет прав на создание netns. +`iptables`, на Debian/Ubuntu — оба). + +Коды возврата: `0` — тесты прошли, `1` — тесты упали, `3` — «заблокировано окружением» +(нет прав на создание netns), `4` — ошибка самого стенда (сюда же нормализуются +неожиданные коды утилит, например `255` от `ip netns exec` в контейнере). +Вход в namespace выполняется через `ns_exec()`: `ip netns exec` в контейнере падает +(`mount of /sys failed`, issue #56), поэтому харнесс сам переключается на +`nsenter --net=/var/run/netns/`; режим форсируется `AWG_NS_EXEC_MODE`. ## Состав @@ -85,11 +92,18 @@ backend netfilter — `nft` или `iptables` (в alpine-образе прило | `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing | | `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение | | `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда | +| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56 | ## Pitfalls * **`ip netns add` в контейнере** требует `--privileged` (CAP_SYS_ADMIN) — иначе `run.sh` завершится кодом 3 с сообщением «заблокировано окружением». +* **`ip netns exec` и `ip -n ` в контейнере не работают** (iproute2 6.x + перемонтирует `/sys`: `mount of /sys failed: Operation not permitted`, rc=255), + хотя `ip netns add` и `nsenter --net=...` работают. Поэтому в скриптах только + `ns_exec`/`ns_prefix` (`lib.sh`) и `ns_exec_mode()` (`netns_common.py`), а все + `ip -n ` заменены на `ns_exec ip ...` (issue #56). Форсировать режим: + `AWG_NS_EXEC_MODE=nsenter|ip`. * **rp_filter** в шлюзе выставлен в `2` (loose): маркированная политика даёт асимметрию reverse-пути, strict-режим молча дропает ответы. * **Мок не умеет криптографию**: он повторяет раскладку сообщений WireGuard diff --git a/tests/netns/netns_common.py b/tests/netns/netns_common.py index 92bf54b..9d3bfd9 100644 --- a/tests/netns/netns_common.py +++ b/tests/netns/netns_common.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import os import pathlib +import shutil import subprocess import time from typing import Any, Iterable @@ -61,6 +62,10 @@ class StandError(RuntimeError): """Ошибка стенда (не теста): топология/утилиты не отвечают как ожидается.""" +# Кэш выбранного режима входа в netns (ip|nsenter), см. ns_exec_mode(). +_NS_EXEC_MODE: str | None = None + + def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess: """Запуск команды в корневом netns.""" cp = subprocess.run( @@ -75,9 +80,56 @@ def sh(args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subpro return cp +def ns_exec_direct( + mode: str, + ns: str, + args: Iterable[str], + check: bool = True, + timeout: float = 20.0, +) -> subprocess.CompletedProcess: + """Одна попытка входа в netns выбранным способом (ip|nsenter).""" + cmd = (["nsenter", f"--net=/var/run/netns/{ns}"] if mode == "nsenter" else ["ip", "netns", "exec", ns]) + list(args) + return sh(cmd, check=check, timeout=timeout) + + +def _probe_ns_exec(mode: str) -> bool: + probe = os.environ.get("AWG_NETNS_PREFIX", "awgt21") + "-probe" + if sh(["ip", "netns", "add", probe], check=False).returncode != 0: + return False + try: + return ns_exec_direct(mode, probe, ["sh", "-c", "true"], check=False, timeout=10.0).returncode == 0 + finally: + sh(["ip", "netns", "delete", probe], check=False) + + +def ns_exec_mode() -> str: + """Режим входа в netns: ip | nsenter (issue #56). + + `ip netns exec` (iproute2 6.x) перемонтирует /sys и в контейнере падает с + "mount of /sys failed: Operation not permitted"; nsenter входит в + /var/run/netns/ без перемонтирования. Режим кэшируется на процесс, + AWG_NS_EXEC_MODE=ip|nsenter|auto переопределяет выбор. + """ + global _NS_EXEC_MODE + if _NS_EXEC_MODE is None: + want = os.environ.get("AWG_NS_EXEC_MODE", "auto") + if want in ("ip", "nsenter"): + _NS_EXEC_MODE = want + elif _probe_ns_exec("ip"): + _NS_EXEC_MODE = "ip" + elif shutil.which("nsenter") and _probe_ns_exec("nsenter"): + _NS_EXEC_MODE = "nsenter" + else: + raise StandError( + "ни 'ip netns exec', ни 'nsenter --net' не работают в этом окружении " + "(нет CAP_SYS_ADMIN/NET_ADMIN) — issue #56" + ) + return _NS_EXEC_MODE + + def ns_sh(ns: str, args: Iterable[str], check: bool = True, timeout: float = 20.0) -> subprocess.CompletedProcess: - """Запуск команды внутри указанного netns.""" - return sh(["ip", "netns", "exec", ns, *args], check=check, timeout=timeout) + """Запуск команды внутри указанного netns (ip или nsenter — см. ns_exec_mode).""" + return ns_exec_direct(ns_exec_mode(), ns, args, check=check, timeout=timeout) def ns_exists(ns: str) -> bool: @@ -86,15 +138,15 @@ def ns_exists(ns: str) -> bool: def addrs(ns: str) -> str: - return sh(["ip", "-n", ns, "-br", "addr", "show"]).stdout + return ns_sh(ns, ["ip", "-br", "addr", "show"]).stdout def links(ns: str) -> str: - return sh(["ip", "-n", ns, "-br", "link", "show"]).stdout + return ns_sh(ns, ["ip", "-br", "link", "show"]).stdout def link_is_up(ns: str, ifname: str) -> bool: - cp = sh(["ip", "-n", ns, "-br", "link", "show", ifname], check=False) + cp = ns_sh(ns, ["ip", "-br", "link", "show", ifname], check=False) return cp.returncode == 0 and "UP" in cp.stdout diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh index 53a4198..16aa764 100644 --- a/tests/netns/topology.sh +++ b/tests/netns/topology.sh @@ -15,6 +15,11 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=tests/netns/lib.sh source "$HERE/lib.sh" +# Контракт кодов возврата (issue #56): 0 — ок, 1 — проверка не прошла, +# 3 — заблокировано окружением, 4 — ошибка стенда. Всё остальное, что прилетает +# от утилит (например 255 от `ip netns exec`), нормализуем в 4. +trap 'rc=$?; case "$rc" in 0|1|3|4) exit "$rc" ;; *) warn "topology.sh: непредвиденный код ${rc} — трактую как ошибку стенда (4)"; exit 4 ;; esac' ERR + topology_present() { ns_exists "$AWG_NS_CLIENT" && ns_exists "$AWG_NS_GW" && ns_exists "$AWG_NS_UPLINK" } @@ -38,6 +43,9 @@ topology_up() { require_cmd ip require_cmd python3 require_netns_capability + NS_EXEC_RESOLVED="$(select_ns_exec_mode)" + export AWG_NS_EXEC_MODE="$NS_EXEC_RESOLVED" + log "вход в netns: ${NS_EXEC_RESOLVED}" log "создаю netns: client=$AWG_NS_CLIENT gw=$AWG_NS_GW uplink=$AWG_NS_UPLINK" topology_down quiet @@ -45,44 +53,44 @@ topology_up() { local ns for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do ip netns add "$ns" - ip -n "$ns" link set lo up + ns_exec "$ns" ip link set lo up done # --- client <-> gw (LAN) -------------------------------------------------- ip link add "$AWG_VETH_CLIENT" type veth peer name "$AWG_VETH_GW_LAN" ip link set "$AWG_VETH_CLIENT" netns "$AWG_NS_CLIENT" ip link set "$AWG_VETH_GW_LAN" netns "$AWG_NS_GW" - ip -n "$AWG_NS_CLIENT" addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT" - ip -n "$AWG_NS_CLIENT" link set "$AWG_VETH_CLIENT" up - ip -n "$AWG_NS_GW" addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" - ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_LAN" up + ns_exec "$AWG_NS_CLIENT" ip addr add "${AWG_CLIENT_IP}/24" dev "$AWG_VETH_CLIENT" + ns_exec "$AWG_NS_CLIENT" ip link set "$AWG_VETH_CLIENT" up + ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_LAN_IP}/24" dev "$AWG_VETH_GW_LAN" + ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_LAN" up # --- gw <-> uplink (WAN) -------------------------------------------------- ip link add "$AWG_VETH_GW_WAN" type veth peer name "$AWG_VETH_UPLINK" ip link set "$AWG_VETH_GW_WAN" netns "$AWG_NS_GW" ip link set "$AWG_VETH_UPLINK" netns "$AWG_NS_UPLINK" - ip -n "$AWG_NS_GW" addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN" - ip -n "$AWG_NS_GW" link set "$AWG_VETH_GW_WAN" up - ip -n "$AWG_NS_UPLINK" addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" - ip -n "$AWG_NS_UPLINK" link set "$AWG_VETH_UPLINK" up + ns_exec "$AWG_NS_GW" ip addr add "${AWG_GW_WAN_IP}/24" dev "$AWG_VETH_GW_WAN" + ns_exec "$AWG_NS_GW" ip link set "$AWG_VETH_GW_WAN" up + ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_UPLINK_IP}/24" dev "$AWG_VETH_UPLINK" + ns_exec "$AWG_NS_UPLINK" ip link set "$AWG_VETH_UPLINK" up # --- forwarding и rp_filter в шлюзе --------------------------------------- - ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1 + ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.ip_forward=1 # loose rp_filter: маркированная политика даёт асимметрию reverse-пути - ip netns exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2 - ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2" - ip netns exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2" + ns_exec "$AWG_NS_GW" sysctl -q -w net.ipv4.conf.all.rp_filter=2 + ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_LAN}.rp_filter=2" + ns_exec "$AWG_NS_GW" sysctl -q -w "net.ipv4.conf.${AWG_VETH_GW_WAN}.rp_filter=2" # --- маршруты ------------------------------------------------------------- - ip -n "$AWG_NS_CLIENT" route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" - ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100 - ip -n "$AWG_NS_UPLINK" route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK" + ns_exec "$AWG_NS_CLIENT" ip route add default via "$AWG_GW_LAN_IP" dev "$AWG_VETH_CLIENT" + ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" metric 100 + ns_exec "$AWG_NS_UPLINK" ip route add "$AWG_NET_CLIENT" via "$AWG_GW_WAN_IP" dev "$AWG_VETH_UPLINK" # «публичный» адрес-приманка для детектора утечки (RFC 5737 TEST-NET-2) - ip -n "$AWG_NS_UPLINK" addr add "${AWG_LEAK_IP}/32" dev lo + ns_exec "$AWG_NS_UPLINK" ip addr add "${AWG_LEAK_IP}/32" dev lo # --- policy routing: fwmark 51820 -> table 51820 -> аплинк ---------------- - ip -n "$AWG_NS_GW" route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE" - ip -n "$AWG_NS_GW" rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip route add default via "$AWG_UPLINK_IP" dev "$AWG_VETH_GW_WAN" table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip rule add fwmark "$AWG_FWMARK" table "$AWG_ROUTE_TABLE" # --- netfilter: метки + kill-switch --------------------------------------- fw_setup @@ -102,18 +110,18 @@ topology_status() { local ns for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do printf '\n[%s] links:\n' "$ns" - ip -n "$ns" -br addr show || rc=1 + ns_exec "$ns" ip -br addr show || rc=1 done printf '\n[%s] ip rule:\n' "$AWG_NS_GW" - ip -n "$AWG_NS_GW" rule show + ns_exec "$AWG_NS_GW" ip rule show printf '\n[%s] table %s:\n' "$AWG_NS_GW" "$AWG_ROUTE_TABLE" - ip -n "$AWG_NS_GW" route show table "$AWG_ROUTE_TABLE" + ns_exec "$AWG_NS_GW" ip route show table "$AWG_ROUTE_TABLE" printf '\n[%s] netfilter:\n' "$AWG_NS_GW" if [ "$(fw_kind)" = nft ]; then - ip netns exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" nft list table inet "$NFT_TABLE" 2>/dev/null || rc=1 else - ip netns exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 - ip netns exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" iptables -t mangle -S 2>/dev/null || rc=1 + ns_exec "$AWG_NS_GW" iptables -t filter -S FORWARD 2>/dev/null || rc=1 fi return $rc } From c7f2ea128e09b1ed685a4a25d55fce86f50f6cec Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:30:33 +0300 Subject: [PATCH 15/30] =?UTF-8?q?deploy:=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D0=B0=D1=8F=20health-=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy/api_stub.py: make_server() выделен из main(), main(argv) — тесты поднимают настоящий сервер на порту 0 (покрытие api_stub.py 0 % -> 98 %). - Makefile: make test-deploy (гейт ≥70 % через tests/deploy/pytest.ini), test-netns-ci прокидывает AWG_NS_EXEC_MODE. Refs #21, Refs #15, Refs #56 --- Makefile | 19 +++++++++++++++++-- deploy/api_stub.py | 21 ++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index d13af0e..638ec38 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,12 @@ DASH_PORT ?= 8180 NETNS_ARGS ?= VERIFY_ARGS ?= MANIFEST_ARGS ?= +# Режим входа в netns внутри тестового контейнера: auto|ip|nsenter (issue #56). +NETNS_EXEC_MODE ?= auto .PHONY: help venv lint fmt test build dev-up dev-up-app dev-up-stub dev-down dev-logs dev-verify \ - dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell + dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell \ + test-deploy help: @grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | sed 's/:.*//' | sort -u @@ -106,13 +109,25 @@ test-netns-keep: bash tests/netns/run.sh --keep-topology --keep-logs $(NETNS_ARGS) # Тот же стенд в привилегированном контейнере — путь для CI (#17). +# Внутри контейнера `ip netns exec` падает с "mount of /sys failed: Operation +# not permitted" (iproute2 6.x), поэтому харнесс сам переключается на +# `nsenter --net=/var/run/netns/` (issue #56). AWG_NS_EXEC_MODE форсирует +# режим: ip|nsenter|auto. test-netns-ci: - AWG_DASH_IMAGE=$(IMAGE) $(COMPOSE) -f $(COMPOSE_TEST) run --rm netns + AWG_DASH_IMAGE=$(IMAGE) AWG_NS_EXEC_MODE=$(NETNS_EXEC_MODE) $(COMPOSE) -f $(COMPOSE_TEST) run --rm netns # --- статические проверки манифестов и скриптов ------------------------------ test-manifests: $(PY) tests/deploy/validate_compose.py $(MANIFEST_ARGS) +# Тесты стенда с покрытием: гейт DoD «тесты ≥70 %» для #21/#56. +# Отдельный tests/deploy/pytest.ini обязателен: без него `pytest tests/deploy` +# наследует addopts корневого pyproject.toml (--cov=awg_agent --cov=awg_api), +# покрытие выходит 0.00 % и RC=1 — гейт приходилось гонять с --no-cov. +test-deploy: + @test -d tests/deploy || { echo 'tests/deploy ещё нет — задача #21'; exit 1; } + $(PY) -m pytest tests/deploy -c tests/deploy/pytest.ini + lint-shell: @command -v shellcheck >/dev/null 2>&1 || { echo 'shellcheck не установлен (apt-get install shellcheck)'; exit 1; } shellcheck -x -S style tests/netns/*.sh deploy/*.sh diff --git a/deploy/api_stub.py b/deploy/api_stub.py index 848922d..9366311 100644 --- a/deploy/api_stub.py +++ b/deploy/api_stub.py @@ -25,6 +25,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer STARTED_AT = time.time() STUB_ENV = os.environ.get("AWG_API_STUB", "0") +HEALTH_PATHS = ("/api/v1/health", "/healthz") + class Handler(BaseHTTPRequestHandler): server_version = "awg-dash-stub/0.1" @@ -40,7 +42,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 (HTTP-метод) path = self.path.split("?", 1)[0] - if path in ("/api/v1/health", "/healthz"): + if path in HEALTH_PATHS: self._json( 200, { @@ -56,7 +58,7 @@ class Handler(BaseHTTPRequestHandler): self._json(404, {"error": "not_implemented", "issue": "#31", "path": path}) def do_HEAD(self) -> None: # noqa: N802 - self.send_response(200 if self.path.split("?", 1)[0] in ("/api/v1/health", "/healthz") else 404) + self.send_response(200 if self.path.split("?", 1)[0] in HEALTH_PATHS else 404) self.send_header("Content-Length", "0") self.end_headers() @@ -64,19 +66,28 @@ class Handler(BaseHTTPRequestHandler): print(f"[api-stub] {self.address_string()} {fmt % args}", flush=True) -def main() -> int: +def make_server(host: str, port: int) -> ThreadingHTTPServer: + """HTTP-сервер заглушки. + + Вынесен из main(), чтобы тесты (tests/deploy/test_api_stub.py) поднимали + настоящий HTTP-сервер на свободном порту (port=0) и проверяли коды ответов. + """ + return ThreadingHTTPServer((host, port), Handler) + + +def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="DEV-ONLY заглушка health для dev-стенда (#21)") ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--port", type=int, default=8080) ap.add_argument("--domain", default="/config", help="каталог, доступность которого проверяем (для лога)") - args = ap.parse_args() + args = ap.parse_args(argv) print( f"[api-stub] DEV-ONLY заглушка на {args.host}:{args.port}; каталог {args.domain} " f"{'доступен' if os.path.isdir(args.domain) else 'НЕ доступен'}", flush=True, ) - ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() + make_server(args.host, args.port).serve_forever() return 0 From 8db92df8f201e2e820c05d0783d792a11810197b Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:30:48 +0300 Subject: [PATCH 16/30] =?UTF-8?q?tests:=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20health-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83?= =?UTF-8?q?=D1=88=D0=BA=D0=B8=20=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=20pytest=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/deploy/pytest.ini + .coveragerc: гейт ≥70 % для кода стенда (deploy/ и validate_compose.py), тестовые модули из покрытия исключены. Refs #21, Refs #56 --- tests/deploy/.coveragerc | 10 +++ tests/deploy/pytest.ini | 3 + tests/deploy/test_api_stub.py | 157 ++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 tests/deploy/.coveragerc create mode 100644 tests/deploy/pytest.ini create mode 100644 tests/deploy/test_api_stub.py diff --git a/tests/deploy/.coveragerc b/tests/deploy/.coveragerc new file mode 100644 index 0000000..9b41183 --- /dev/null +++ b/tests/deploy/.coveragerc @@ -0,0 +1,10 @@ +# Конфиг покрытия для тестов dev/тест-стенда (#21, issue #56). +# +# Из покрытия исключаются сами тестовые модули: они исполняются по определению, +# и без omit «покрытие 100 %» означало бы только то, что тесты запустились. +# Измеряется код стенда: deploy/*.py и tests/deploy/validate_compose.py +# (валидатор манифестов, который вызывает `make test-manifests`). +[report] +omit = + tests/deploy/test_*.py + tests/deploy/conftest.py diff --git a/tests/deploy/pytest.ini b/tests/deploy/pytest.ini new file mode 100644 index 0000000..ab6bac0 --- /dev/null +++ b/tests/deploy/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = . +addopts = -q --cov=deploy --cov=tests/deploy --cov-config=tests/deploy/.coveragerc --cov-report=term-missing --cov-fail-under=70 diff --git a/tests/deploy/test_api_stub.py b/tests/deploy/test_api_stub.py new file mode 100644 index 0000000..1d5cec3 --- /dev/null +++ b/tests/deploy/test_api_stub.py @@ -0,0 +1,157 @@ +"""Тесты DEV-ONLY заглушки health-эндпоинта deploy/api_stub.py (задача #21, issue #56). + +Заглушка — единственный код стенда, который реально отдаёт HTTP в контейнере, +поэтому она проверяется настоящим HTTP-сервером на свободном порту (port=0), +а не моками: проверяются коды ответов и тело для /api/v1/health, алиаса +/healthz, HEAD и «всего остального» (404 not_implemented). +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import pathlib +import sys +import threading +import unittest +import urllib.error +import urllib.request +from unittest import mock + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +STUB_PATH = ROOT / "deploy" / "api_stub.py" + + +def _load_stub() -> object: + """Импорт deploy/api_stub.py по пути (deploy/ — не пакет).""" + spec = importlib.util.spec_from_file_location("awg_api_stub", STUB_PATH) + if spec is None or spec.loader is None: # pragma: no cover - защита от битого дерева + raise RuntimeError(f"не удалось загрузить {STUB_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +api_stub = _load_stub() + + +class ApiStubHttpTest(unittest.TestCase): + """Живой HTTP-сервер заглушки: коды ответов и контракт тела.""" + + @classmethod + def setUpClass(cls) -> None: + cls._stdout = contextlib.redirect_stdout(io.StringIO()) + cls._stdout.__enter__() + cls.server = api_stub.make_server("127.0.0.1", 0) + cls.port = cls.server.server_address[1] + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=5) + cls._stdout.__exit__(None, None, None) + + def _url(self, path: str) -> str: + return f"http://127.0.0.1:{self.port}{path}" + + def _get(self, path: str) -> tuple[int, dict]: + try: + with urllib.request.urlopen(self._url(path), timeout=5) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: # 4xx/5xx — тоже ответ + return exc.code, json.loads(exc.read().decode("utf-8")) + + def test_health_returns_200_and_stub_marker(self) -> None: + status, payload = self._get("/api/v1/health") + self.assertEqual(200, status) + self.assertEqual("ok", payload["status"]) + self.assertIs(True, payload["stub"], "health-заглушка обязана помечать ответ stub=true") + self.assertEqual("#31", payload["issue"], "в ответе должна быть ссылка на задачу реального API") + self.assertIsInstance(payload["uptime_s"], float) + + def test_healthz_is_alias_of_health(self) -> None: + status, payload = self._get("/healthz") + self.assertEqual(200, status) + self.assertIs(True, payload["stub"]) + + def test_query_string_is_ignored(self) -> None: + status, _ = self._get("/api/v1/health?verbose=1&x=2") + self.assertEqual(200, status) + + def test_unknown_path_returns_404_not_implemented(self) -> None: + status, payload = self._get("/api/v1/tunnels") + self.assertEqual(404, status) + self.assertEqual("not_implemented", payload["error"]) + self.assertEqual("#31", payload["issue"]) + self.assertEqual("/api/v1/tunnels", payload["path"]) + + def test_head_mirrors_get_status(self) -> None: + for path, expected in (("/api/v1/health", 200), ("/api/v1/tunnels", 404)): + with self.subTest(path=path): + req = urllib.request.Request(self._url(path), method="HEAD") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + self.assertEqual(expected, resp.status) + except urllib.error.HTTPError as exc: + self.assertEqual(expected, exc.code) + + def test_post_is_rejected(self) -> None: + req = urllib.request.Request(self._url("/api/v1/health"), data=b"{}", method="POST") + with self.assertRaises(urllib.error.HTTPError) as ctx: + urllib.request.urlopen(req, timeout=5) + self.assertEqual(501, ctx.exception.code, "заглушка не должна поддерживать POST") + + +class ApiStubMainTest(unittest.TestCase): + """main(): сборка сервера из аргументов командной строки.""" + + class _FakeServer: + instances: list[tuple[tuple, object]] = [] + served = 0 + + def __init__(self, address: tuple, handler: object) -> None: + type(self).instances.append((address, handler)) + + def serve_forever(self) -> None: + type(self).served += 1 + + def setUp(self) -> None: + self._fake = type("Fake", (self._FakeServer,), {"instances": [], "served": 0}) + self._patch = mock.patch.object(api_stub, "ThreadingHTTPServer", self._fake) + self._patch.start() + self.addCleanup(self._patch.stop) + + def _run_main(self, argv: list[str]) -> tuple[int, str]: + out = io.StringIO() + with mock.patch.object(sys, "argv", ["api_stub.py", *argv]), contextlib.redirect_stdout(out): + rc = api_stub.main() + return rc, out.getvalue() + + def test_main_binds_requested_host_and_port(self) -> None: + rc, out = self._run_main(["--host", "127.0.0.1", "--port", "0", "--domain", str(HERE)]) + self.assertEqual(0, rc) + self.assertEqual([(("127.0.0.1", 0), api_stub.Handler)], self._fake.instances) + self.assertEqual(1, self._fake.served) + self.assertIn("DEV-ONLY", out) + self.assertIn("доступен", out) + + def test_main_reports_missing_domain_directory(self) -> None: + rc, out = self._run_main(["--port", "0", "--domain", str(HERE / "нет-такого-каталога")]) + self.assertEqual(0, rc) + self.assertIn("НЕ доступен", out) + + def test_defaults_listen_on_all_interfaces(self) -> None: + rc, _ = self._run_main(["--domain", str(HERE)]) + self.assertEqual(0, rc) + self.assertEqual((("0.0.0.0", 8080), api_stub.Handler), self._fake.instances[0]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 8e76f41f6317f10f16a9c350e103b2808b288e3c Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:31:07 +0300 Subject: [PATCH 17/30] =?UTF-8?q?tests:=20=D0=BD=D0=B5=D0=B3=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20compose-=D0=BC=D0=B0=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждая инварианта AC #21 ломается по отдельности (порт, тома /config /data, healthcheck, NET_ADMIN, /dev/net/tun, ip_forward, privileged netns-сервис, DEV-ONLY-метка, --require-dockerfile) — 39 тестов, реальные манифесты не трогаются. Refs #21, Refs #56 --- tests/deploy/test_validate_compose.py | 291 ++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 tests/deploy/test_validate_compose.py diff --git a/tests/deploy/test_validate_compose.py b/tests/deploy/test_validate_compose.py new file mode 100644 index 0000000..fb04594 --- /dev/null +++ b/tests/deploy/test_validate_compose.py @@ -0,0 +1,291 @@ +"""Негативные и граничные тесты tests/deploy/validate_compose.py (задача #21, issue #56). + +test_compose_manifests.py проверяет, что реальные compose-файлы стенда проходят +валидатор. Здесь — обратная сторона: каждая инварианта AC #21 ломается по +отдельности, и валидатор обязан это поймать (иначе гейт `make test-manifests` +пропускает регресс). Файлы-фикстуры создаются во временном каталоге, реальные +манифесты не трогаются. +""" + +from __future__ import annotations + +import contextlib +import io +import pathlib +import tempfile +import unittest +from unittest import mock + +import yaml + +import validate_compose as vc + +HEALTH_CMD = ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/api/v1/health >/dev/null || exit 1"] + + +def dev_service(**overrides) -> dict: + svc = { + "image": "awg-dash:dev", + "build": {"context": "..", "dockerfile": "deploy/Dockerfile"}, + "ports": ["8180:8080"], + "volumes": [ + "${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config", + "${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/data:/data", + ], + "devices": ["/dev/net/tun:/dev/net/tun"], + "cap_add": ["NET_ADMIN", "NET_RAW", "SYS_MODULE"], + "sysctls": {"net.ipv4.ip_forward": "1"}, + "healthcheck": {"test": HEALTH_CMD}, + "logging": {"driver": "json-file"}, + } + svc.update(overrides) + return svc + + +def dev_doc(**overrides) -> dict: + doc = {"name": "awg-dash-dev", "services": {"awg-dash": dev_service(**overrides)}} + doc.update({k: v for k, v in overrides.items() if k in ("name", "services")}) + return doc + + +def netns_doc(**overrides) -> dict: + svc = { + "image": "awg-dash:dev", + "privileged": True, + "working_dir": "/repo", + "entrypoint": ["/bin/bash", "/repo/tests/netns/run.sh"], + "volumes": ["..:/repo:ro", "/tmp/artifacts:/artifacts"], + "tmpfs": ["/tmp:mode=1777"], + } + svc.update(overrides) + return {"name": "awg-dash-test", "services": {"netns": svc}} + + +def stub_doc(**overrides) -> dict: + svc = { + "entrypoint": ["python3", "/opt/awg-dash-stub/api_stub.py"], + "command": ["--port", "8080"], + "volumes": ["../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro"], + } + svc.update(overrides) + return {"services": {"awg-dash": svc}} + + +@unittest.skipUnless(vc.yaml is not None, "нужен PyYAML (make venv)") +class ValidateComposeTestCase(unittest.TestCase): + """Общая обвязка: временный «репозиторий» и подмена путей модуля.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = pathlib.Path(tmp.name) + (self.root / "deploy").mkdir() + (self.root / "tests" / "netns").mkdir(parents=True) + (self.root / "tests" / "netns" / "run.sh").write_text("#!/bin/bash\n", encoding="utf-8") + self.dev = self.root / "deploy" / "docker-compose.dev.yml" + self.test = self.root / "deploy" / "docker-compose.test.yml" + self.stub = self.root / "deploy" / "docker-compose.stub.yml" + for name, value in (("ROOT", self.root), ("DEV", self.dev), ("TEST", self.test), ("STUB", self.stub)): + patcher = mock.patch.object(vc, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def write(self, path: pathlib.Path, doc: dict | str) -> pathlib.Path: + text = doc if isinstance(doc, str) else yaml.safe_dump(doc, allow_unicode=True) + path.write_text(text, encoding="utf-8") + return path + + def errors(self, **kwargs) -> list[str]: + errors, _warnings = vc.validate(**kwargs) + return errors + + def warnings(self, **kwargs) -> list[str]: + _errors, warnings = vc.validate(**kwargs) + return warnings + + +class MountTargetTest(unittest.TestCase): + def test_env_substitution_with_default_colon(self) -> None: + svc = {"volumes": ["${AWG_DASH_HOST_DIR:-/opt/awg-dash-dev}/config:/config"]} + self.assertEqual(["/config"], vc._mount_targets(svc)) + + def test_mode_suffix_is_stripped(self) -> None: + svc = {"volumes": ["../deploy/api_stub.py:/opt/awg-dash-stub/api_stub.py:ro"]} + self.assertEqual(["/opt/awg-dash-stub/api_stub.py"], vc._mount_targets(svc)) + + def test_missing_volumes_key(self) -> None: + self.assertEqual([], vc._mount_targets({})) + self.assertEqual([], vc._volumes({})) + self.assertEqual([], vc._ports({})) + + def test_jsonish_flattens_lists(self) -> None: + self.assertEqual("a b", vc.jsonish(["a", "b"])) + self.assertEqual("1", vc.jsonish(1)) + + +class LoadTest(unittest.TestCase): + def test_load_without_pyyaml_fails_loudly(self) -> None: + with mock.patch.object(vc, "yaml", None), tempfile.NamedTemporaryFile(suffix=".yml") as tmp: + with self.assertRaises(SystemExit): + vc.load(pathlib.Path(tmp.name)) + + +class ValidateDevTest(ValidateComposeTestCase): + def test_good_manifest_has_no_errors(self) -> None: + self.write(self.dev, dev_doc()) + self.write(self.test, netns_doc()) + self.write(self.stub, stub_doc()) + (self.root / "deploy" / "api_stub.py").write_text("# DEV-ONLY\n", encoding="utf-8") + self.assertEqual([], self.errors()) + + def test_missing_file(self) -> None: + self.assertTrue(any("не найден" in e for e in self.errors())) + + def test_missing_service(self) -> None: + self.write(self.dev, {"name": "x", "services": {"other": {}}}) + self.assertTrue(any("нет сервиса awg-dash" in e for e in self.errors())) + + def test_missing_project_name(self) -> None: + doc = dev_doc() + doc.pop("name") + self.write(self.dev, doc) + self.assertTrue(any("не задан `name`" in e for e in self.errors())) + + def test_port_not_published(self) -> None: + self.write(self.dev, dev_doc(ports=["127.0.0.1:9000:9000"])) + self.assertTrue(any("порт 8080 не публикуется" in e for e in self.errors())) + + def test_state_directories_not_mounted(self) -> None: + self.write(self.dev, dev_doc(volumes=["/tmp:/tmp"])) + errors = " ".join(self.errors()) + self.assertIn("/config", errors) + self.assertIn("/data", errors) + self.assertIn("AC-2", errors) + + def test_healthcheck_must_hit_api_v1_health(self) -> None: + self.write( + self.dev, + dev_doc(healthcheck={"test": ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ || exit 1"]}), + ) + self.assertTrue(any("/api/v1/health" in e for e in self.errors())) + + def test_healthcheck_absent(self) -> None: + self.write(self.dev, dev_doc(healthcheck=None)) + self.assertTrue(any("healthcheck не ходит" in e for e in self.errors())) + + def test_requires_net_admin(self) -> None: + self.write(self.dev, dev_doc(cap_add=["NET_RAW"])) + self.assertTrue(any("NET_ADMIN" in e for e in self.errors())) + + def test_requires_tun_device(self) -> None: + self.write(self.dev, dev_doc(devices=["/dev/null:/dev/null"])) + self.assertTrue(any("/dev/net/tun" in e for e in self.errors())) + + def test_requires_ip_forward(self) -> None: + self.write(self.dev, dev_doc(sysctls={})) + self.assertTrue(any("ip_forward" in e for e in self.errors())) + + def test_ip_forward_accepts_boolean_true(self) -> None: + self.write(self.dev, dev_doc(sysctls={"net.ipv4.ip_forward": True})) + self.assertFalse(any("ip_forward" in e for e in self.errors())) + + def test_unbounded_logging_is_a_warning(self) -> None: + self.write(self.dev, dev_doc(logging=None)) + self.assertTrue(any("json-file" in w for w in self.warnings())) + + def test_missing_dockerfile_is_warning_before_issue_18(self) -> None: + self.write(self.dev, dev_doc()) + self.assertTrue(any("dockerfile" in w for w in self.warnings())) + + def test_missing_dockerfile_is_error_when_required(self) -> None: + self.write(self.dev, dev_doc()) + self.assertTrue(any("dockerfile" in e for e in self.errors(require_dockerfile=True))) + + def test_present_dockerfile_produces_no_dockerfile_message(self) -> None: + (self.root / "deploy" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + self.write(self.dev, dev_doc()) + self.assertFalse(any("dockerfile" in m for m in self.errors(require_dockerfile=True))) + + +class ValidateTestTest(ValidateComposeTestCase): + def test_missing_file(self) -> None: + self.write(self.dev, dev_doc()) + self.assertTrue(any("docker-compose.test.yml: файл не найден" in e for e in self.errors())) + + def test_missing_netns_service(self) -> None: + self.write(self.test, {"services": {"app": {}}}) + self.assertTrue(any("нет сервиса netns" in e for e in self.errors())) + + def test_netns_must_be_privileged(self) -> None: + self.write(self.test, netns_doc(privileged=False)) + self.assertTrue(any("не privileged" in e for e in self.errors())) + + def test_entrypoint_must_point_to_run_sh(self) -> None: + self.write(self.test, netns_doc(entrypoint=["python3", "-m", "http.server"])) + self.assertTrue(any("entrypoint" in e for e in self.errors())) + + def test_missing_run_sh_is_an_error(self) -> None: + (self.root / "tests" / "netns" / "run.sh").unlink() + self.write(self.test, netns_doc()) + self.assertTrue(any("нет файла tests/netns/run.sh" in e for e in self.errors())) + + def test_artifacts_and_tmpfs_are_warnings(self) -> None: + self.write(self.test, netns_doc(volumes=["..:/repo:ro"], tmpfs=[])) + warnings = " ".join(self.warnings()) + self.assertIn("/artifacts", warnings) + self.assertIn("/tmp", warnings) + + +class ValidateStubTest(ValidateComposeTestCase): + def test_missing_stub_is_a_warning(self) -> None: + self.assertTrue(any("health-заглушкой отсутствует" in w for w in self.warnings())) + + def test_missing_service(self) -> None: + self.write(self.stub, {"services": {"other": {}}}) + self.assertTrue(any("stub: нет сервиса awg-dash" in e for e in self.errors())) + + def test_entrypoint_must_point_to_api_stub(self) -> None: + self.write(self.stub, stub_doc(entrypoint=["/bin/sh"])) + self.assertTrue(any("api_stub.py" in e for e in self.errors())) + + def test_missing_api_stub_file_is_an_error(self) -> None: + self.write(self.stub, stub_doc()) + self.assertTrue(any("нет файла deploy/api_stub.py" in e for e in self.errors())) + + def test_dev_only_marker_is_a_warning(self) -> None: + (self.root / "deploy" / "api_stub.py").write_text("# заглушка\n", encoding="utf-8") + self.write(self.stub, stub_doc()) + self.assertTrue(any("DEV-ONLY" in w for w in self.warnings())) + + +class MainTest(ValidateComposeTestCase): + def test_ok_manifest_returns_zero(self) -> None: + self.write(self.dev, dev_doc()) + self.write(self.test, netns_doc()) + self.write(self.stub, stub_doc()) + (self.root / "deploy" / "api_stub.py").write_text("# DEV-ONLY\n", encoding="utf-8") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = vc.main([]) + self.assertEqual(0, rc) + self.assertIn("ok (", out.getvalue()) + + def test_errors_return_one_and_are_printed(self) -> None: + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = vc.main(["--require-dockerfile"]) + self.assertEqual(1, rc) + self.assertIn("[error]", out.getvalue()) + self.assertIn("ошибок", out.getvalue()) + + def test_quiet_suppresses_details_but_keeps_exit_code(self) -> None: + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = vc.main(["--quiet"]) + self.assertEqual(1, rc) + self.assertNotIn("[error]", out.getvalue()) + self.assertIn("ошибок", out.getvalue()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From edf22d7c9381384e4a04025d849eaea806c6f5e7 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:32:07 +0300 Subject: [PATCH 18/30] =?UTF-8?q?tests:=20=D0=BF=D0=BE=D1=8F=D1=81=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8E=20pytest=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21, Refs #56 --- tests/deploy/pytest.ini | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/deploy/pytest.ini b/tests/deploy/pytest.ini index ab6bac0..7299b6b 100644 --- a/tests/deploy/pytest.ini +++ b/tests/deploy/pytest.ini @@ -1,3 +1,12 @@ +# Профиль тестов dev/тест-стенда (#21, issue #56). +# +# Отдельный pytest.ini обязателен: без него `pytest tests/deploy` наследует +# addopts корневого pyproject.toml (--cov=awg_agent --cov=awg_api), покрытие +# выходит 0.00 % и RC=1 — гейт приходилось запускать с --no-cov в обход порога. +# +# Измеряется код стенда: deploy/*.py (api_stub.py — то, что реально работает в +# контейнере) и tests/deploy/validate_compose.py (валидатор манифестов). +# Тестовые модули из покрытия исключены через tests/deploy/.coveragerc. [pytest] testpaths = . addopts = -q --cov=deploy --cov=tests/deploy --cov-config=tests/deploy/.coveragerc --cov-report=term-missing --cov-fail-under=70 From d63b9ceafe2e08d437fbe62ef912a8f49cad02a7 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:32:32 +0300 Subject: [PATCH 19/30] =?UTF-8?q?deploy:=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D1=8B?= =?UTF-8?q?=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4=D0=B0=202=20=D0=B2=20VERIFICA?= =?UTF-8?q?TION-21=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - netns-ci в контейнере: причина (ip netns exec/ip -n перемонтируют /sys), переход на nsenter, контракт кодов 0/1/3/4 — все четыре воспроизведены. - гейт покрытия: make test-deploy = 51 passed, 98.86 %. Refs #21, Refs #15, Refs #56 --- deploy/VERIFICATION-21.md | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index 11349b4..a6415e2 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -125,3 +125,70 @@ make dev-down # остановить стенд, каталоги /opt/ make dev-reset # + удалить осиротевшие контейнеры/сети проекта make test-netns # netns-харнесс сносит топологию сам; аварийно: bash tests/netns/topology.sh down ``` + +## 6. Раунд 2 (issue #56): netns в контейнере и покрытие тестов + +Дата: 2026-10-28 · Хост: `192.168.1.147` · Ветка: `feat/21-dev-stand` · Ref: #21, #15, #17, #56 + +Ревью qa-engineer (раунд 1) нашло два блокера: `make test-netns-ci` падал в контейнере +(`mount of /sys failed: Operation not permitted`, exit 255 вместо 0/1/3/4) и гейт покрытия +`pytest tests/deploy` обходился флагом `--no-cov` (0.00 %, `deploy/api_stub.py` 0 %). + +### 6.1 Причина падения netns в контейнере + +Воспроизведено на образе `awg-dash:dev` (iproute2 6.11.0): + +| команда в контейнере | результат | +|---|---| +| `ip netns add ` | rc=0 | +| `ip netns exec true` | `mount of /sys failed: Operation not permitted`, rc=255 | +| `ip -n link set lo up` | `mount of /sys failed: Operation not permitted`, rc=255 | +| `nsenter --net=/var/run/netns/ true` | rc=0 | + +`ip netns exec` и `ip -n` перемонтируют `/sys` внутри namespace; в контейнере это падает. +Поэтому харнесс больше не использует эти формы: вход в namespace идёт через `ns_exec()` / +`ns_prefix()` (`tests/netns/lib.sh`) и `netns_common.ns_sh()`, которые один раз пробуют +`ip netns exec`, а при отказе переключаются на `nsenter --net=...` (`AWG_NS_EXEC_MODE=auto|ip|nsenter`). +Все `ip -n "$NS"` заменены на `ns_exec "$NS" ip ...`. + +Коды возврата приведены к контракту `0/1/3/4` (`run.sh`, `topology.sh`): неожиданный код +утилиты (255) нормализуется в `4` («ошибка стенда»), нет прав — `3`, упавшие тесты — `1`. + +### 6.2 Реальные прогоны + +| прогон | команда | результат | +|---|---|---| +| стенд на хосте | `make test-netns` | exit 0, 8 ok / 0 fail, 5.46 s, `ip netns list` пуст, мок и sink не остались в процессах | +| стенд в контейнере (путь CI) | `make test-netns-ci` | exit 0, `[netns] вход в netns: nsenter`, backend `iptables`, 8 ok / 0 fail, 5.50 s, `[ ok ] топология снесена`, отчёт `/artifacts/netns-report.json` | +| нет прав | `docker run` без `--privileged` | exit **3**, `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN` | +| форс нерабочего режима | `AWG_NS_EXEC_MODE=ip` в контейнере | exit **4**, `mount of /sys failed` → `[warn] run.sh: непредвиденный код 255 — трактую как ошибку стенда (4)` | +| упавшие тесты | синтетический проваленный тест в контейнере | exit **1**, `[warn] netns-стенд: тесты/очистка упали (код 1)` | +| гейт покрытия | `make test-deploy` | exit 0, 51 passed, coverage **98.86 %** (`deploy/api_stub.py` 98 %, `tests/deploy/validate_compose.py` 99 %) | +| манифесты / shellcheck | `make test-manifests`, `make lint-shell` | exit 0 / exit 0 | + +### 6.3 Что изменено для гейта покрытия + +* `tests/deploy/pytest.ini` — отдельный профиль: `--cov=deploy --cov=tests/deploy + --cov-config=tests/deploy/.coveragerc --cov-fail-under=70`. Раньше `pytest tests/deploy` + наследовал addopts корневого `pyproject.toml` (`--cov=awg_agent --cov=awg_api`), поэтому + покрытие всегда было 0.00 % и RC=1 — гейт гоняли с `--no-cov` в обход порога. +* `tests/deploy/.coveragerc` — из покрытия исключены сами тестовые модули + (`tests/deploy/test_*.py`), иначе «покрытие» измеряет тестовый код. +* `tests/deploy/test_api_stub.py` — 9 тестов на живой HTTP-сервер заглушки: 200 и + `"stub": true` на `/api/v1/health`, алиас `/healthz`, игнор query-string, 404 + `not_implemented` на прочие пути, HEAD, отказ на POST, разбор аргументов `main()`. +* `tests/deploy/test_validate_compose.py` — 39 негативных/граничных тестов валидатора: + каждая инварианта AC #21 ломается по отдельности (порт, тома, healthcheck, NET_ADMIN, + `/dev/net/tun`, `ip_forward`, privileged netns-сервис, DEV-ONLY-метка, `--require-dockerfile`). +* `deploy/api_stub.py` — `make_server()` выделен из `main()` (тесты поднимают сервер на порту 0). +* `make test-deploy` — гейт покрытия; `make test-netns-ci` прокидывает `AWG_NS_EXEC_MODE`. + +### 6.4 Что закрыто и что осталось + +Закрыты оба блокера раунда 1 (`make test-netns-ci` работает в контейнере, покрытие +измеряется и ≥70 %). `CI green` по-прежнему недостижим: Gitea Actions runner не +зарегистрирован (#17), все прогоны в таблице 6.2 — ручные на `192.168.1.147`; команды +`make test-netns-ci` и `make test-deploy` — ровно те, что должны попасть в workflow #17. + +Порт и stub-оверрайд (см. §4) без изменений: `privileged: true` и `cap_add SYS_MODULE` +из `deploy/docker-compose.test.yml` — только DEV/TEST, для staging/prod непригодны. From 156d7910207797cc767db63beb34c35acffaf3b9 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:33:36 +0300 Subject: [PATCH 20/30] =?UTF-8?q?deploy:=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20git-blob-SHA=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B0=20=D0=BA=20=D1=80=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D0=B4=D1=83=202=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21, Refs #56 --- deploy/VERIFICATION-21.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index a6415e2..1fd862d 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -1,7 +1,7 @@ # Проверка dev/staging стенда (#21) — фактические прогоны на Docker-хосте Дата: 2026-10-22 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17) -Ветка: `feat/21-dev-stand`, HEAD `7af9caba` · Ref: #21, #15, #17, #18, #31 +Ветка: `feat/21-dev-stand`, HEAD `d63b9cea` · Ref: #21, #15, #17, #18, #31, #56 ## 0. Стенд запускает именно то, что в ветке @@ -11,14 +11,23 @@ | файл | git blob (HEAD == хост) | |---|---| -| `Makefile` | `d13af0e752cb0dfc28e94ed1ad3a4ea448d58811` | +| `Makefile` | `638ec381f3d1e57a8ab84f194e40ff6a0c51d8cf` | | `deploy/docker-compose.dev.yml` | `6bed89a5ea2a197aa9f6fd6bcda6d8131303114d` | | `deploy/docker-compose.stub.yml` | `936efb38dfff4ba8b942c9757acd4ba9c39c4064` | -| `deploy/docker-compose.test.yml` | `da01e80a459580cd063359c46560adb8285434d1` | +| `deploy/docker-compose.test.yml` | `b628c129ae4de9b33875503b2c8273675b235a84` | | `deploy/verify-dev-stand.sh` | `c4e13f11f9023700be531ebb312ad33d7beea4d8` | -| `deploy/api_stub.py` | `848922dc543a6a2abffa49c02efeba383f02455f` | +| `deploy/api_stub.py` | `93663113f118676a7e896e0e9d32bd20a21f6a0e` | | `tests/deploy/validate_compose.py` | `0fa8b3c02675a364f4f5b447220680a7818faa5a` | -| `tests/netns/*` (10 файлов) | совпадают с деревом (см. `git ls-tree feat/21-dev-stand tests/netns`) | +| `tests/deploy/pytest.ini` | `7299b6b45487dd4f214f0444e2f0f5c237eb2fac` | +| `tests/deploy/.coveragerc` | `9b41183e29a155b5498c3722cf4639e9c69268c6` | +| `tests/deploy/test_api_stub.py` | `1d5cec3a72b02da6cb48a6ca433e7699b768f631` | +| `tests/deploy/test_validate_compose.py` | `fb045949848cb70a1880614201930c2d4ecd9d0a` | +| `tests/netns/lib.sh` | `806375299b7c3932d20b275d98b72f65e48940ec` | +| `tests/netns/run.sh` | `c6797bf9c42abe7c4f72cf5402af82e2192ee867` | +| `tests/netns/topology.sh` | `16aa764264d4697750bf02c53f11d3e2a5a2dcc5` | +| `tests/netns/netns_common.py` | `9d3bfd94cd8e8b321af92274835b3f0ebc48c842` | +| `tests/netns/README.md` | `845863188ffb26c1aae7e475addc0c511e0d8f1f` | +| остальные файлы `tests/netns/` (5) | без изменений с раунда 1 | ## 1. Предварительные пакеты на стенд-хосте From 63e75e4b7f805bf69f66971a89a0f4bc60fa323a Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:35:27 +0300 Subject: [PATCH 21/30] =?UTF-8?q?netns:=20=D1=81=D0=BD=D0=BE=D1=81=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B8=20=D1=81?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B9=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B2=D0=BE=D0=B3=D0=BE=20umount=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В контейнере `ip netns delete` иногда возвращает успех, но namespace остаётся в `ip netns list` — из-за этого run.sh отдавал код 4 на зелёных тестах (флак 1 из 3). Теперь удаление повторяется до 5 раз с проверкой ns_exists. Refs #21, Refs #56 --- tests/netns/topology.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh index 16aa764..9b8607b 100644 --- a/tests/netns/topology.sh +++ b/tests/netns/topology.sh @@ -28,8 +28,18 @@ topology_down() { local quiet="${1:-}" [ "$quiet" = quiet ] || log "удаляю топологию" # netns удаляем первыми: вместе с ними уходят veth-концы и процессы внутри. + # `ip netns delete` в контейнере иногда возвращает успех, но namespace + # остаётся виден в `ip netns list` (issue #56) — поэтому повторяем удаление. + local ns attempt for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do - if ns_exists "$ns"; then ip netns del "$ns" 2>/dev/null || true; fi + for attempt in 1 2 3 4 5; do + if ! ns_exists "$ns"; then break; fi + ip netns delete "$ns" 2>/dev/null || true + sleep 0.2 + done + if ns_exists "$ns"; then + warn "netns ${ns} не удалился за 5 попыток" + fi done # страховка на случай, если veth остался в корневом netns for ifc in "$AWG_VETH_CLIENT" "$AWG_VETH_GW_LAN" "$AWG_VETH_GW_WAN" "$AWG_VETH_UPLINK"; do From 6c5d739cfca29b514741ab2e62aaa6bae463a557 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:37:09 +0300 Subject: [PATCH 22/30] =?UTF-8?q?netns:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20netns=20=D0=B1=D0=B5=D0=B7=D1=83=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=BD=D0=BE=20+=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B0=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Флак: ns_exists мог вернуть false до удаления, и namespace оставался жить. Теперь `ip netns delete` вызывается всегда, результат проверяется до 5 раз. Стабильность в контейнере: 5/5 прогонов rc=0 (было 4/5). Refs #21, Refs #56 --- tests/netns/topology.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh index 9b8607b..4892f6b 100644 --- a/tests/netns/topology.sh +++ b/tests/netns/topology.sh @@ -28,13 +28,14 @@ topology_down() { local quiet="${1:-}" [ "$quiet" = quiet ] || log "удаляю топологию" # netns удаляем первыми: вместе с ними уходят veth-концы и процессы внутри. - # `ip netns delete` в контейнере иногда возвращает успех, но namespace - # остаётся виден в `ip netns list` (issue #56) — поэтому повторяем удаление. + # `ip netns delete` в контейнере иногда «теряется»: команда отработала, а + # namespace остаётся в `ip netns list` (issue #56). Поэтому удаляем + # безусловно (операция идемпотентна) и до 5 раз проверяем результат. local ns attempt for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do for attempt in 1 2 3 4 5; do - if ! ns_exists "$ns"; then break; fi ip netns delete "$ns" 2>/dev/null || true + if ! ns_exists "$ns"; then break; fi sleep 0.2 done if ns_exists "$ns"; then From e6ba503c2056a4c89d76aebdd96cc413b4c4de35 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:38:32 +0300 Subject: [PATCH 23/30] =?UTF-8?q?deploy:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20netns-ci=205/5=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20blob=20topology.sh=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21, Refs #56 --- deploy/VERIFICATION-21.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index 1fd862d..82cfb6d 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -24,7 +24,7 @@ | `tests/deploy/test_validate_compose.py` | `fb045949848cb70a1880614201930c2d4ecd9d0a` | | `tests/netns/lib.sh` | `806375299b7c3932d20b275d98b72f65e48940ec` | | `tests/netns/run.sh` | `c6797bf9c42abe7c4f72cf5402af82e2192ee867` | -| `tests/netns/topology.sh` | `16aa764264d4697750bf02c53f11d3e2a5a2dcc5` | +| `tests/netns/topology.sh` | `4892f6b9f9b92c38ae058df00e7c79a692537eaa` | | `tests/netns/netns_common.py` | `9d3bfd94cd8e8b321af92274835b3f0ebc48c842` | | `tests/netns/README.md` | `845863188ffb26c1aae7e475addc0c511e0d8f1f` | | остальные файлы `tests/netns/` (5) | без изменений с раунда 1 | @@ -174,6 +174,9 @@ make test-netns # netns-харнесс сносит топологию с | упавшие тесты | синтетический проваленный тест в контейнере | exit **1**, `[warn] netns-стенд: тесты/очистка упали (код 1)` | | гейт покрытия | `make test-deploy` | exit 0, 51 passed, coverage **98.86 %** (`deploy/api_stub.py` 98 %, `tests/deploy/validate_compose.py` 99 %) | | манифесты / shellcheck | `make test-manifests`, `make lint-shell` | exit 0 / exit 0 | +| стабильность в контейнере | 5 прогонов `make test-netns-ci` подряд | 5/5 exit 0, 8 ok каждый, ни одного «остался netns» | +| AC-1 после правок | `curl :8180/api/v1/health` | HTTP 200, `"stub": true`, контейнер `healthy` | +| AC-2 после правок | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | exit 0: маркеры `/config` и `/data` пережили restart и пересоздание, видны на хосте (bind mount) | ### 6.3 Что изменено для гейта покрытия @@ -191,6 +194,9 @@ make test-netns # netns-харнесс сносит топологию с `/dev/net/tun`, `ip_forward`, privileged netns-сервис, DEV-ONLY-метка, `--require-dockerfile`). * `deploy/api_stub.py` — `make_server()` выделен из `main()` (тесты поднимают сервер на порту 0). * `make test-deploy` — гейт покрытия; `make test-netns-ci` прокидывает `AWG_NS_EXEC_MODE`. +* `topology.sh`: снос топологии удаляет namespace'ы безусловно и до 5 раз проверяет + результат — в контейнере `ip netns delete` иногда «терялся» и `run.sh` отдавал код 4 + на зелёных тестах (флак 1 из 5 прогонов, после правки — 5/5 зелёных). ### 6.4 Что закрыто и что осталось From 221d8740c90c8bbd05d0576dcee336814bf2c06a Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:39:05 +0300 Subject: [PATCH 24/30] =?UTF-8?q?netns:=20shellcheck=20SC2034=20=D0=B2=20?= =?UTF-8?q?=D1=86=D0=B8=D0=BA=D0=BB=D0=B5=20=D1=81=D0=BD=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BF=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B8=20(#5?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21, Refs #56 --- tests/netns/topology.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/netns/topology.sh b/tests/netns/topology.sh index 4892f6b..6c13a7c 100644 --- a/tests/netns/topology.sh +++ b/tests/netns/topology.sh @@ -31,9 +31,9 @@ topology_down() { # `ip netns delete` в контейнере иногда «теряется»: команда отработала, а # namespace остаётся в `ip netns list` (issue #56). Поэтому удаляем # безусловно (операция идемпотентна) и до 5 раз проверяем результат. - local ns attempt + local ns for ns in "$AWG_NS_CLIENT" "$AWG_NS_GW" "$AWG_NS_UPLINK"; do - for attempt in 1 2 3 4 5; do + for _ in 1 2 3 4 5; do ip netns delete "$ns" 2>/dev/null || true if ! ns_exists "$ns"; then break; fi sleep 0.2 From dbb5d1fe5c908c6709f97bc82c64687dfb944f43 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:39:31 +0300 Subject: [PATCH 25/30] =?UTF-8?q?deploy:=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20blob=20topology.sh=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20shellcheck-=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B0=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #21, Refs #56 --- deploy/VERIFICATION-21.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index 82cfb6d..36d8433 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -24,7 +24,7 @@ | `tests/deploy/test_validate_compose.py` | `fb045949848cb70a1880614201930c2d4ecd9d0a` | | `tests/netns/lib.sh` | `806375299b7c3932d20b275d98b72f65e48940ec` | | `tests/netns/run.sh` | `c6797bf9c42abe7c4f72cf5402af82e2192ee867` | -| `tests/netns/topology.sh` | `4892f6b9f9b92c38ae058df00e7c79a692537eaa` | +| `tests/netns/topology.sh` | `6c13a7cfd4d9368ee7d6cb2efca85d890b803e59` | | `tests/netns/netns_common.py` | `9d3bfd94cd8e8b321af92274835b3f0ebc48c842` | | `tests/netns/README.md` | `845863188ffb26c1aae7e475addc0c511e0d8f1f` | | остальные файлы `tests/netns/` (5) | без изменений с раунда 1 | From e67290174114a917b6fc0170fe6afff38e3b0c80 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:57:44 +0300 Subject: [PATCH 26/30] =?UTF-8?q?deploy(#21):=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2=20net?= =?UTF-8?q?ns=20=D0=B1=D0=B5=D0=B7=20make,=20AWG=5FNS=5FEXEC=5FMODE=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B5,=20DEV=5FSKIP=5FBUILD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализация замечаний ревью PR #53 (issuecomment-664): 1. tests/netns/ci.sh — точка входа для CI #17 без make: docker compose run --rm -T netns, код контейнера возвращается как есть (0/1/3/4; 2 — сломан вызов/нет docker/образа). make test-netns-ci делегирует в неё, в комментарии таргета зафиксировано «make → 2». 2. NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto) — документированный AWG_NS_EXEC_MODE больше не перекрывается make-алиасом. 3. DEV_SKIP_BUILD=1 → up -d --no-build; DEV_UP_BUILD переопределяем (?) — dev-up работает на хосте без DNS/сети для сборки. Refs #21 Refs #56 --- Makefile | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 638ec38..55c3ea5 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,11 @@ DASH_PORT ?= 8180 NETNS_ARGS ?= VERIFY_ARGS ?= MANIFEST_ARGS ?= -# Режим входа в netns внутри тестового контейнера: auto|ip|nsenter (issue #56). -NETNS_EXEC_MODE ?= auto +# Режим входа в netns: auto|ip|nsenter (issue #56). Документированное имя — +# AWG_NS_EXEC_MODE (его читают lib.sh/run.sh и tests/netns/ci.sh); NETNS_EXEC_MODE — +# make-алиас. Раньше здесь стояло `?= auto`, из-за чего AWG_NS_EXEC_MODE=ip на +# `make test-netns-ci` молча игнорировался (замечание ревью #56). +NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto) .PHONY: help venv lint fmt test build dev-up dev-up-app dev-up-stub dev-down dev-logs dev-verify \ dev-verify-stub dev-reset test-netns test-netns-keep test-netns-ci test-manifests lint-shell \ @@ -61,7 +64,14 @@ STUB_MODE := 1 endif # deploy/Dockerfile приходит с задачей #18; до этого образ собирается отдельно, # поэтому --build не форсируем, если Dockerfile отсутствует в дереве. -DEV_UP_BUILD := $(if $(wildcard deploy/Dockerfile),--build,) +# После мержа #18 файл есть → --build обязателен, но не всегда возможен: на +# стенд-хосте нет DNS/сети для pull базового образа. Явные кнобы: +# make dev-up DEV_SKIP_BUILD=1 → up -d --no-build (использовать готовый образ) +# make dev-up DEV_UP_BUILD= → вообще не передавать флаги сборки +DEV_UP_BUILD ?= $(if $(wildcard deploy/Dockerfile),--build,) +ifeq ($(DEV_SKIP_BUILD),1) +DEV_UP_BUILD := --no-build +endif DEV_ENV := AWG_DASH_PORT=$(DASH_PORT) AWG_DASH_HOST_DIR=$(HOST_DIR) DEV_VERIFY_ARGS := $(if $(filter 1,$(STUB_MODE)),--stub,) @@ -111,10 +121,16 @@ test-netns-keep: # Тот же стенд в привилегированном контейнере — путь для CI (#17). # Внутри контейнера `ip netns exec` падает с "mount of /sys failed: Operation # not permitted" (iproute2 6.x), поэтому харнесс сам переключается на -# `nsenter --net=/var/run/netns/` (issue #56). AWG_NS_EXEC_MODE форсирует -# режим: ip|nsenter|auto. +# `nsenter --net=/var/run/netns/` (issue #56). Режим задаётся +# AWG_NS_EXEC_MODE=ip|nsenter|auto (make-алиас — NETNS_EXEC_MODE). +# +# ВАЖНО: make возвращает 2 на любой упавший рецепт, поэтому контракт кодов +# 0/1/3/4 через этот таргет НЕ виден (см. deploy/VERIFICATION-21.md §6.1). +# В пайплайне #17 используйте точку входа без make: +# bash tests/netns/ci.sh # те же 0/1/3/4, что отдаёт контейнер test-netns-ci: - AWG_DASH_IMAGE=$(IMAGE) AWG_NS_EXEC_MODE=$(NETNS_EXEC_MODE) $(COMPOSE) -f $(COMPOSE_TEST) run --rm netns + @test -d tests/netns || { echo 'tests/netns ещё нет — задача #21'; exit 1; } + AWG_DASH_IMAGE=$(IMAGE) AWG_NS_EXEC_MODE=$(NETNS_EXEC_MODE) bash tests/netns/ci.sh $(NETNS_ARGS) # --- статические проверки манифестов и скриптов ------------------------------ test-manifests: From b612a3450c584304fc30c3b8857fb0b53892eef6 Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:58:04 +0300 Subject: [PATCH 27/30] =?UTF-8?q?deploy(#21):=20tests/netns/ci.sh=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20netns-=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20CI=20=D0=B1=D0=B5=D0=B7=20make?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNU make отдаёт 2 на любой упавший рецепт, поэтому контракт 0/1/3/4 через `make test-netns-ci` до пайплайна #17 не доходит. ci.sh запускает тот же контейнер (docker compose -f deploy/docker-compose.test.yml run --rm -T netns) и возвращает его код как есть. Дополнительно: AWG_NS_PRIVILEGED=0 — прогон без --privileged (проверка ветки «код 3»), AWG_DASH_TEST_ARTIFACTS — каталог отчёта. Refs #21 Refs #56 --- tests/netns/ci.sh | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/netns/ci.sh diff --git a/tests/netns/ci.sh b/tests/netns/ci.sh new file mode 100644 index 0000000..561ca39 --- /dev/null +++ b/tests/netns/ci.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# ci.sh — точка входа netns-стенда для CI (#17), минуя make (issue #56). +# +# Зачем отдельный скрипт: GNU make возвращает 2 на любой упавший рецепт, поэтому +# контракт кодов run.sh (0 — тесты прошли, 1 — тесты упали, 3 — нет прав на netns, +# 4 — сломан стенд) через `make test-netns-ci` до пайплайна не доходит. ci.sh +# запускает контейнер напрямую и возвращает его код как есть — workflow отличает +# «раннеру не дали CAP_NET_ADMIN» от «регрессия стенда». +# +# tests/netns/ci.sh # прогон стенда, код 0/1/3/4 +# tests/netns/ci.sh --no-tests # только поднять топологию +# tests/netns/ci.sh --keep-topology # оставить топологию для отладки +# AWG_NS_PRIVILEGED=0 tests/netns/ci.sh # ветка «нет прав» → ожидаем код 3 +# +# Переменные окружения: +# AWG_DASH_IMAGE образ с харнессом (по умолчанию awg-dash:dev) +# AWG_NS_EXEC_MODE auto|ip|nsenter — вход в netns (issue #56) +# NETNS_EXEC_MODE make-алиас того же (Makefile прокидывает его сюда) +# AWG_NS_PRIVILEGED 1 (по умолчанию) — как в compose; 0 — прогон без +# privileged (проверка ветки «код 3») +# AWG_DASH_TEST_ARTIFACTS каталог артефактов (по умолчанию /artifacts) +# COMPOSE_PROJECT_NAME имя compose-проекта (по умолчанию awg-dash-test) +# +# Коды возврата: 0/1/3/4 — как у run.sh; 2 — ошибка вызова или окружения +# (нет docker/compose/образа, неизвестный аргумент). Ветку 2 CI не должен +# путать с 3: 2 — сломан сам пайплайн/раннер, 3 — раннер без прав на netns. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +COMPOSE_FILE="$ROOT/deploy/docker-compose.test.yml" +IMAGE="${AWG_DASH_IMAGE:-awg-dash:dev}" +ARTIFACTS="${AWG_DASH_TEST_ARTIFACTS:-$ROOT/artifacts}" +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-awg-dash-test}" + +# AWG_NS_EXEC_MODE — документированное имя, NETNS_EXEC_MODE — алиас из Makefile. +NS_MODE="${AWG_NS_EXEC_MODE:-${NETNS_EXEC_MODE:-auto}}" +case "$NS_MODE" in + auto|ip|nsenter) ;; + *) + echo "[ci][fail] AWG_NS_EXEC_MODE='${NS_MODE}' — ожидается auto|ip|nsenter" >&2 + exit 2 + ;; +esac +export AWG_NS_EXEC_MODE="$NS_MODE" + +die() { echo "[ci][fail] $*" >&2; exit 2; } + +[ -f "$COMPOSE_FILE" ] || die "нет ${COMPOSE_FILE} (задача #21)" +command -v docker >/dev/null 2>&1 || die "нет docker в PATH — CI-раннер должен быть с docker (#17)" +docker compose version >/dev/null 2>&1 || die "нет docker compose v2 в PATH (#17)" +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || die "нет образа ${IMAGE}: соберите его (make build после мержа #18) или задайте AWG_DASH_IMAGE" + +mkdir -p "$ARTIFACTS" +export AWG_DASH_TEST_ARTIFACTS="$ARTIFACTS" +export AWG_DASH_IMAGE="$IMAGE" + +COMPOSE_ARGS=(-f "$COMPOSE_FILE") +CLEANUP_DIR="" +PRIVILEGED="${AWG_NS_PRIVILEGED:-1}" +if [ "$PRIVILEGED" = "0" ]; then + # Оверрайд снимает privileged у сервиса netns — проверка ветки «код 3». + CLEANUP_DIR="$(mktemp -d -t awg-ci-XXXXXX)" + printf 'services:\n netns:\n privileged: false\n' >"${CLEANUP_DIR}/no-privileged.yml" + COMPOSE_ARGS+=(-f "${CLEANUP_DIR}/no-privileged.yml") + # shellcheck disable=SC2064 # раскрытие $CLEANUP_DIR нужно на момент trap + trap "rm -rf '${CLEANUP_DIR}'" EXIT +fi + +echo "[ci] образ=${IMAGE} режим netns=${NS_MODE} privileged=${PRIVILEGED} артефакты=${ARTIFACTS}" + +rc=0 +docker compose "${COMPOSE_ARGS[@]}" run --rm -T netns "$@" || rc=$? + +case "$rc" in + 0) echo "[ci][ ok ] netns-стенд: тесты прошли (код 0)" ;; + 1) echo "[ci][fail] netns-стенд: тесты упали (код 1)" >&2 ;; + 3) echo "[ci][blocked] netns-стенд: окружение не даёт прав на netns (код 3) — раннеру нужен --privileged (#17)" >&2 ;; + 4) echo "[ci][fail] netns-стенд: сломан стенд (код 4) — см. лог выше" >&2 ;; + *) echo "[ci][fail] netns-стенд: неожиданный код ${rc}" >&2 ;; +esac +exit "$rc" From 94d3a28adf52a12313c718e6368e031cfe6d49ba Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:58:15 +0300 Subject: [PATCH 28/30] =?UTF-8?q?docs(#21):=20README=20netns=20=D0=B8=20ru?= =?UTF-8?q?nbook=20dev-stand=20=E2=80=94=20ci.sh,=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=20AWG=5FNS=5FEXEC=5FMODE,?= =?UTF-8?q?=20DEV=5FSKIP=5FBUILD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приведение документации к фактическому поведению после ревью PR #53 (раунд 2): точка входа для CI без make (контракт 0/1/3/4), приоритет AWG_NS_EXEC_MODE над make-алиасом, сборка без сети (DEV_SKIP_BUILD=1). Refs #21 Refs #56 --- deploy/dev-stand.md | 21 ++++++++++++++++++++- tests/netns/README.md | 21 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/deploy/dev-stand.md b/deploy/dev-stand.md index 175025e..60495f7 100644 --- a/deploy/dev-stand.md +++ b/deploy/dev-stand.md @@ -21,6 +21,15 @@ make dev-logs # логи контейнера make dev-down # остановить (каталоги состояния сохраняются) ``` +`dev-up` добавляет `--build`, когда в дереве есть `deploy/Dockerfile` (после мержа #18). +Если у хоста нет сети/DNS для `docker build` (офлайн-стенд, CI без прокси), сборку +нужно пропустить — образ `awg-dash:dev` уже должен существовать: + +```bash +make dev-up DEV_SKIP_BUILD=1 # up -d --no-build — использовать готовый образ +make dev-up DEV_UP_BUILD= # вообще не передавать флаги сборки +``` + До реализации FastAPI-каркаса (**#31**) `/api/v1/health` не отвечает — контейнер работает как агент и помечается `unhealthy`. Чтобы проверить плумбинг стенда (порт, healthcheck, тома, restart) **до** #31, используйте health-заглушку: @@ -71,6 +80,10 @@ make dev-verify-stub # AC-1 проходит на заглушке, в отч | `AWG_DASH_DOCKERFILE` | `deploy/Dockerfile` | путь к Dockerfile для build | | `COMPOSE_PROJECT_NAME` | `awg-dash-dev` | изоляция от других стендов | | `AWG_LOG_LEVEL` | `info` | уровень логирования приложения | +| `DEV_SKIP_BUILD` | — | `1` → `make dev-up` выполняет `up -d --no-build` (образ уже собран) | +| `DEV_UP_BUILD` | авто | переопределение флагов сборки (`--build`, пусто, `--no-build`) | +| `AWG_NS_EXEC_MODE` | `auto` | режим входа в netns: `auto|ip|nsenter` (issue #56) | +| `NETNS_EXEC_MODE` | `auto` | make-алиас `AWG_NS_EXEC_MODE` (приоритет у `AWG_NS_EXEC_MODE`) | ## Зависимости @@ -79,7 +92,7 @@ make dev-verify-stub # AC-1 проходит на заглушке, в отч | #16 (каркас репо, Makefile) | PR #47 | нужен `Makefile`, `requirements.txt`, `deploy/` | | #18 (образ, entrypoint) | PR #49 | нужен `deploy/Dockerfile` для `--build` | | #31 (каркас FastAPI, `/api/v1/health`) | Sprint 2 | без него AC-1 выполняется только с заглушкой | -| #17 (CI-пайплайн) | Sprint 0 | `make test-netns-ci`, `make test-manifests` — готовые гейты для пайплайна | +| #17 (CI-пайплайн) | Sprint 0 | гейты для пайплайна: `bash tests/netns/ci.sh` (коды 0/1/3/4), `make test-manifests`, `make test-deploy`, `make lint-shell` | ## Откат и уборка @@ -106,3 +119,9 @@ du -sh /opt/awg-dash-dev # состояние стенда маскирует отсутствие API. Не включайте `docker-compose.stub.yml` в staging. * **netns-стенд вне Docker** требует root: `make test-netns` вернёт код 3, если нет CAP_NET_ADMIN/CAP_SYS_ADMIN (например, в непривилегированном CI-раннере). +* **netns в контейнере (`make test-netns-ci`, `bash tests/netns/ci.sh`)**: `ip netns exec` + падает (`mount of /sys failed`), харнесс сам переходит на `nsenter`; контракт кодов + `0/1/3/4` действует на уровне контейнера. **make возвращает 2 на любом провале** — + в CI вызывайте `bash tests/netns/ci.sh`, иначе ветки 3 и 4 неразличимы (#56). +* **`--build` без сети**: после мержа #18 `dev-up` собирает образ; если на хосте нет + DNS/доступа к registry, используйте `DEV_SKIP_BUILD=1` (готовый `awg-dash:dev`). diff --git a/tests/netns/README.md b/tests/netns/README.md index 8458631..a4642f8 100644 --- a/tests/netns/README.md +++ b/tests/netns/README.md @@ -35,7 +35,9 @@ make test-netns # поднять стенд, прогнать т make test-netns-keep # оставить топологию и логи для отладки make test-netns NETNS_ARGS="--no-tests" # только поднять стенд make test-netns-ci # то же в привилегированном контейнере (путь для CI) - # в контейнере вход в netns идёт через nsenter (issue #56) + +bash tests/netns/ci.sh # точка входа для CI #17: контейнер без make + # (make отдаёт 2 на любом провале — см. ниже) bash tests/netns/topology.sh status # состояние топологии bash tests/netns/topology.sh down # снести топологию вручную @@ -49,15 +51,26 @@ backend netfilter — `nft` или `iptables` (в alpine-образе прило Коды возврата: `0` — тесты прошли, `1` — тесты упали, `3` — «заблокировано окружением» (нет прав на создание netns), `4` — ошибка самого стенда (сюда же нормализуются неожиданные коды утилит, например `255` от `ip netns exec` в контейнере). + +**В CI (#17) используйте `bash tests/netns/ci.sh`, а не `make test-netns-ci`:** +GNU make возвращает `2` на любой упавший рецепт, поэтому через make контракт +`0/1/3/4` не виден и пайплайн не отличит «раннеру не дали прав» (`3`) от +«регрессия стенда» (`4`). `ci.sh` запускает тот же контейнер напрямую и отдаёт +его код как есть (`2` у него означает «сломан вызов/нет docker/нет образа»). + Вход в namespace выполняется через `ns_exec()`: `ip netns exec` в контейнере падает (`mount of /sys failed`, issue #56), поэтому харнесс сам переключается на -`nsenter --net=/var/run/netns/`; режим форсируется `AWG_NS_EXEC_MODE`. +`nsenter --net=/var/run/netns/`; режим форсируется `AWG_NS_EXEC_MODE` +(`auto|ip|nsenter`). Тот же режим через make — `NETNS_EXEC_MODE` (алиас; +`AWG_NS_EXEC_MODE` имеет приоритет), например: +`AWG_NS_EXEC_MODE=ip make test-netns-ci`. ## Состав | Файл | Назначение | |---|---| | `run.sh` | точка входа: preflight → топология → мок → тесты → снос | +| `ci.sh` | точка входа для CI #17: тот же прогон в контейнере без make, коды `0/1/3/4` доходят до пайплайна | | `lib.sh` | конфигурация стенда + правила netfilter (nft/iptables) | | `topology.sh` | `up`/`down`/`status`/`killswitch on|off` | | `mock_wg_endpoint.py` | мок WireGuard-endpoint: отвечает на handshake initiation | @@ -92,7 +105,9 @@ backend netfilter — `nft` или `iptables` (в alpine-образе прило | `AWG_FWMARK` / `AWG_ROUTE_TABLE` | `51820` / `51820` | метка и таблица policy routing | | `AWG_FW_BACKEND` | `auto` | `nft`, `iptables` или автоопределение | | `AWG_NETNS_WORKDIR` | `mktemp -d` | каталог логов и отчёта стенда | -| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56 | +| `AWG_NS_EXEC_MODE` | `auto` | вход в netns: `ip`, `nsenter` или `auto` (проба) — issue #56; make-алиас — `NETNS_EXEC_MODE` | +| `AWG_NS_PRIVILEGED` | `1` | только для `ci.sh`: `0` — прогон без `privileged` (проверка ветки «код 3») | +| `AWG_DASH_TEST_ARTIFACTS` | `/artifacts` | каталог для `netns-report.json` (`ci.sh`) | ## Pitfalls From 9d2222455a13216ba33eb4138945730244d32cdb Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:58:23 +0300 Subject: [PATCH 29/30] =?UTF-8?q?docs(#21):=20VERIFICATION-21=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20mak?= =?UTF-8?q?e=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=B2=20=C2=A76.1,=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=C2=A76.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #53 (раунд 2) требовало явной фиксации «make → 2, контракт действует на уровне контейнера» и снятия неверного утверждения про прокидывание AWG_NS_EXEC_MODE. Прогоны раунда 3 добавляются отдельным коммитом. Refs #21 Refs #56 --- deploy/VERIFICATION-21.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index 36d8433..6036225 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -5,6 +5,8 @@ ## 0. Стенд запускает именно то, что в ветке +Актуальные блобы для раунда 3 — в §7.3 (эта таблица фиксирует состояние раунда 2). + Каталог стенда `(/opt/awg-dash-dev/repo)` не является git-чекаутом (`git clone` по HTTP обрывается на этом Gitea — `fetch-pack: invalid index-pack output`), поэтому файлы сверены побайтово через git-blob-SHA с деревом HEAD: @@ -163,6 +165,12 @@ make test-netns # netns-харнесс сносит топологию с Коды возврата приведены к контракту `0/1/3/4` (`run.sh`, `topology.sh`): неожиданный код утилиты (255) нормализуется в `4` («ошибка стенда»), нет прав — `3`, упавшие тесты — `1`. +**Контракт действует на уровне контейнера, а не через make.** GNU make возвращает `2` +на любой упавший рецепт, поэтому `make test-netns-ci` при коде контейнера `3` или `4` +сам отдаёт `2` (в выводе остаётся только `make: *** [Makefile:…] Error 4`). Для CI #17 +добавлена точка входа без make — `bash tests/netns/ci.sh`: она запускает тот же контейнер +(`docker compose … run --rm -T netns`) и возвращает его код как есть. Проверено в §7.2. + ### 6.2 Реальные прогоны | прогон | команда | результат | @@ -193,7 +201,9 @@ make test-netns # netns-харнесс сносит топологию с каждая инварианта AC #21 ломается по отдельности (порт, тома, healthcheck, NET_ADMIN, `/dev/net/tun`, `ip_forward`, privileged netns-сервис, DEV-ONLY-метка, `--require-dockerfile`). * `deploy/api_stub.py` — `make_server()` выделен из `main()` (тесты поднимают сервер на порту 0). -* `make test-deploy` — гейт покрытия; `make test-netns-ci` прокидывает `AWG_NS_EXEC_MODE`. +* `make test-deploy` — гейт покрытия. Утверждение раунда 2 «`make test-netns-ci` прокидывает + `AWG_NS_EXEC_MODE`» было **неверным**: make-алиас `NETNS_EXEC_MODE ?= auto` перекрывал + переменную. Исправлено в §7.1 (проверено прогоном в §7.2). * `topology.sh`: снос топологии удаляет namespace'ы безусловно и до 5 раз проверяет результат — в контейнере `ip netns delete` иногда «терялся» и `run.sh` отдавал код 4 на зелёных тестах (флак 1 из 5 прогонов, после правки — 5/5 зелёных). From a3396b2be68681c92501f8769e158ff8e89ae6fd Mon Sep 17 00:00:00 2001 From: embeddevio Date: Wed, 28 Oct 2026 21:58:58 +0300 Subject: [PATCH 30/30] =?UTF-8?q?docs(#21):=20VERIFICATION-21=20=C2=A77=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D1=8B=20=D1=80=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=203=20(=D0=BA=D0=BE=D0=B4=D1=8B=200/1/3/4,?= =?UTF-8?q?=20make=E2=86=922,=20env-=D0=B0=D0=BB=D0=B8=D0=B0=D1=81,=20DEV?= =?UTF-8?q?=5FSKIP=5FBUILD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гоняно на 192.168.1.147 на дереве, сверенном по git-blob-SHA: ci.sh 0/4/3/1, make при коде 4 и 1 отдаёт 2 (контейнерные коды видны в логе), AWG_NS_EXEC_MODE больше не перекрывается, DEV_SKIP_BUILD=1 даёт AC-1 (200 + healthy), test-deploy 51 passed / 98.86 %, manifests и shellcheck — 0. Refs #21 Refs #56 --- deploy/VERIFICATION-21.md | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/deploy/VERIFICATION-21.md b/deploy/VERIFICATION-21.md index 6036225..6835ed2 100644 --- a/deploy/VERIFICATION-21.md +++ b/deploy/VERIFICATION-21.md @@ -217,3 +217,67 @@ make test-netns # netns-харнесс сносит топологию с Порт и stub-оверрайд (см. §4) без изменений: `privileged: true` и `cap_add SYS_MODULE` из `deploy/docker-compose.test.yml` — только DEV/TEST, для staging/prod непригодны. + +## 7. Раунд 3 (ревью PR #53, раунд 2): контракт кодов, env-алиас, сборка без сети + +Дата: 2026-10-28 · Хост: `192.168.1.147` (Debian 13, Docker 29.4.1, compose v5.1.3, kernel 6.17) +Ветка: `feat/21-dev-stand` · Ref: #21, #15, #17, #56 + +Ревью qa-engineer (раунд 2, PR #53 `issuecomment-664`) дало три воспроизводимых замечания: +контракт `0/1/3/4` теряется на уровне make, документированный `AWG_NS_EXEC_MODE` перекрыт +make-алиасом, `make dev-up` падает при сборке без DNS. Все три закрыты правками ниже. + +### 7.1 Что изменено + +| замечание | правка | +|---|---| +| `make test-netns-ci` отдаёт `2` на любом провале → CI #17 не отличит 3/4/1 | новая точка входа `tests/netns/ci.sh`: `docker compose -f deploy/docker-compose.test.yml run --rm -T netns`, код контейнера возвращается как есть (`2` у неё = сломан вызов/нет docker/образа). `make test-netns-ci` делегирует в неё, в комментарии таргета и в §6.1 зафиксировано «make → 2» | +| `AWG_NS_EXEC_MODE=ip make test-netns-ci` игнорировался (`Makefile:19` `NETNS_EXEC_MODE ?= auto` + `:117`) | `NETNS_EXEC_MODE ?= $(if $(AWG_NS_EXEC_MODE),$(AWG_NS_EXEC_MODE),auto)` — документированное имя имеет приоритет, make-алиас остался для совместимости; README и `dev-stand.md` приведены к факту | +| `make dev-up` форсит `--build` и падает без DNS | `DEV_SKIP_BUILD=1` → `up -d --no-build`; `DEV_UP_BUILD` переопределяем (`?=`) и по-прежнему принимается из командной строки (`DEV_UP_BUILD=`); строка в `dev-stand.md` (быстрый старт + pitfalls + таблица переменных) | + +### 7.2 Реальные прогоны (дерево сверено по git-blob-SHA, см. §7.3) + +| прогон | команда | код | что видно в выводе | +|---|---|---|---| +| CI-точка входа, зелёный | `bash tests/netns/ci.sh` | **0** | `[ci] образ=awg-dash:dev режим netns=auto`, `вход в netns: nsenter`, `Ran 8 tests`, `8 ok`, `[ ok ] топология снесена`, `[ci][ ok ]` | +| make-путь, зелёный | `make test-netns-ci` | **0** | те же 8 ok через `ci.sh` | +| код 4 (форс нерабочего режима) | `AWG_NS_EXEC_MODE=ip bash tests/netns/ci.sh` | **4** | `[ci] … режим netns=ip`, `mount of /sys failed` → `[warn] код 255 → 4` | +| код 3 (нет прав на netns) | `AWG_NS_PRIVILEGED=0 bash tests/netns/ci.sh` | **3** | `[blocked] не удалось создать netns — нет CAP_NET_ADMIN/SYS_ADMIN`, `[ci][blocked]` | +| код 1 (упавшие тесты) | синтетический падающий тест в `tests/netns/` → `bash tests/netns/ci.sh` | **1** | `Ran 9 tests`, `FAILED (failures=1)`, `8 ok, 1 fail` (тест удалён после прогона) | +| контракт make при коде 4 | `AWG_NS_EXEC_MODE=ip make test-netns-ci` | **2** (контейнер 4) | `[ci] … режим netns=ip` — **env больше не перекрывается**; `make: *** [Makefile:133: test-netns-ci] Error 4` | +| контракт make при коде 1 | синтетический падающий тест → `make test-netns-ci` | **2** (контейнер 1) | `make: *** [Makefile:133: test-netns-ci] Error 1` | +| AC-1 | `make dev-up DEV_SKIP_BUILD=1` + `curl :8180/api/v1/health` | **0**, HTTP **200** | `up -d --no-build`, контейнер `Up (healthy)`, ответ `"stub": true` | +| AC-1 со сборкой (кеш тёплый) | `make dev-up` (без `DEV_SKIP_BUILD`) | **0** | `222` шагов `CACHED`, `Image awg-dash:dev Built`, контейнер пересоздан и `healthy` — на холодном кеше этот путь требует DNS, поэтому кноб нужен | +| AC-2 | `make dev-verify VERIFY_ARGS="--no-up --skip-build"` | **0** | маркеры `/config` и `/data` пережили `restart` и `--force-recreate`, видны на хосте (bind mount) | +| гейт покрытия | `make test-deploy` | **0** | `51 passed`, `Required test coverage of 70% reached. Total coverage: 98.86%` (`TOTAL 176 строк, 2 miss`) | +| манифесты | `make test-manifests` | **0** | `validate-compose: ok (1 предупреждений)` — предупреждение про `deploy/Dockerfile` (до мержа #18) | +| shellcheck | `make lint-shell` | **0** | проверен и новый `tests/netns/ci.sh` | +| уборка | `ip netns list`; `docker ps -a --filter name=awg-dash-test` | — | пусто, контейнеров после прогонов не осталось | + +Логи прогонов на стенд-хосте: `/tmp/r3_a.log` … `/tmp/r3_m.log` +(`a` — зелёный `ci.sh`, `b` — код 4, `c` — код 3, `d` — make при коде 4, `e`/`f` — код 1 через `ci.sh`/make, +`g` — `make test-netns-ci`, `h` — `dev-up DEV_SKIP_BUILD=1` + health, `i` — AC-2, `j` — `test-deploy`, +`k` — `test-manifests`, `l` — `lint-shell`, `m` — `dev-up` со сборкой). + +### 7.3 Блобы: файлы на стенд-хосте == дерево ветки + +| файл | git blob | +|---|---| +| `Makefile` | `55c3ea5c98c7980c9bc3fcec8483d1140e670b2e` | +| `tests/netns/ci.sh` | `561ca3976ad3749fbde79b8d163e007bace145de` | +| `tests/netns/README.md` | `a4642f845f38fca1da0019af7e91d95be9e4d97c` | +| `deploy/dev-stand.md` | `60495f7cd8eba10b40dc9aef469bea210b50e8e1` | +| `deploy/VERIFICATION-21.md` | этот файл (блоб меняется этим же коммитом) | +| остальные файлы стенда | без изменений с раунда 2 (§0) | + +### 7.4 Что осталось + +* **CI green (DoD)** — за задачей #17: у репозитория появился runner (контейнер + `gitea-runner-awg-repo`, запуски runs 22–31), но workflow, покрывающего ветку #21, в `main` + ещё нет, поэтому все гейты выше прогнаны вручную. Команды для workflow: + `bash tests/netns/ci.sh`, `make test-deploy`, `make test-manifests`, `make lint-shell` — + они уже отдают нужные коды без make-обёртки. +* Merge в `main` и любой prod/staging-деплой — за TechLead (ADVISORY): автор ревью не мержит. +* `verify-dev-stand.sh` после `--force-recreate` печатает статус до перехода в `healthy`, + но AC-проверки при этом ждут health (`wait_health 20`) и прогон возвращает `0` — на вердикт + не влияет.