From 629a67f09d2daacc1cc74447519a0ca3b3511291 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 23 Aug 2026 15:06:47 +0900 Subject: [PATCH] feat(deploy): convert docker/ deployment assets to nats-docker submodule --- .gitmodules | 3 + docker/.env.example | 47 ---------- docker/README.md | 158 --------------------------------- docker/docker-compose.yaml | 40 --------- docker/nats.conf | 75 ---------------- nats-docker | 1 + tests/test_deploy_freshness.py | 34 +++++-- 7 files changed, 29 insertions(+), 329 deletions(-) create mode 100644 .gitmodules delete mode 100644 docker/.env.example delete mode 100644 docker/README.md delete mode 100644 docker/docker-compose.yaml delete mode 100644 docker/nats.conf create mode 160000 nats-docker diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fad1eb2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "nats-docker"] + path = nats-docker + url = https://git.godopu.com/laa/nats-docker diff --git a/docker/.env.example b/docker/.env.example deleted file mode 100644 index a7e995f..0000000 --- a/docker/.env.example +++ /dev/null @@ -1,47 +0,0 @@ -# ============================================================================== -# docker/.env.example — 원격 브로커 서버 측 환경변수 템플릿 -# -# 이 파일은 git 에 커밋됩니다 (.gitignore:23 의 `!.env.example`). -# 복사본 docker/.env 는 git 에서 제외됩니다 (.gitignore:21 의 `.env`). -# -# cd docker && cp .env.example .env && chmod 600 .env -# -# ⚠ 아래 시크릿 4종은 의도적으로 **빈 값**입니다. 채우지 않고 그대로 복사하면 -# `docker compose up` 이 컨테이너를 만들기 전에 실패합니다 (fail-closed). -# 플레이스홀더 문자열을 넣지 마십시오 — '알려진 암호로 가동'이 최악입니다. -# ============================================================================== - -# ── 시크릿 (필수) ──────────────────────────────────────────────────────── -# 생성: openssl rand -base64 32 -# -# ⚠ 반드시 위 명령을 사용하십시오. NATS 는 환경변수 값을 설정 렉서로 재파싱하므로 -# 암호에 [공백 ; , ] } # ' " $] 가 포함되면 설정이 깨지거나 다르게 해석됩니다. -# base64 알파벳(A-Za-z0-9+/=)은 이 문자들과 교집합이 없어 안전합니다. - -# MAM 에이전트 발행/구독 계정 (.mam.env 의 MQTT_PASSWORD 와 동일 값) -MAM_BROKER_PASS= - -# 관측 전용 계정 (구독 allow: python.mqtt.jobs.>, 발행 전면 deny) -MAM_OBSERVER_PASS= - -# MAM 과 무관한 홈랩 서비스용 별도 계정 -HOME_BROKER_PASS= - -# 시스템 계정 ($SYS). 운영 이벤트/모니터링 전용 -SYS_BROKER_PASS= - -# ── 리스너 바인드 주소 (선택 — 미설정 시 전부 127.0.0.1) ────────────────── -# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 아래 값이 실질적인 노출 통제입니다. -# 모델 T(Tailscale) 권장 설정: MQTT_BIND=$(tailscale ip -4) -# -# MQTT 1883 리스너 바인드 주소 -# MQTT_BIND=127.0.0.1 -# -# NATS 4222 네이티브 리스너 바인드 주소 -# NATS_BIND=127.0.0.1 -# -# WebSocket 8080 바인드 주소. -# 참고: 이 포트는 NATS WebSocket 과 MQTT-over-WebSocket(/mqtt)을 함께 서빙합니다. -# WS_BIND=127.0.0.1 -# -# HTTP 모니터 8222 는 무인증이므로 바인드 주소를 변수화하지 않습니다 (loopback 고정). diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index a19938c..0000000 --- a/docker/README.md +++ /dev/null @@ -1,158 +0,0 @@ -# 🐳 MAM Remote Broker Production Deployment (`docker/`) - -이 디렉터리는 multi-agent-mux (MAM) 멀티 에이전트 관측 백플레인을 위한 원격 `nats-server` 프로덕션 브로커 배포 자산의 **정본(canonical)**입니다. 상세 아키텍처 및 배경 지식은 [`PRIVATE_SERVER.md`](../PRIVATE_SERVER.md) §9 를 참조하십시오. - ---- - -## 1. 무엇인가 - -MAM 은 멀티 에이전트 간 비동기 작업 조정 및 이벤트 스트리밍 백플레인으로 `nats-server` (MQTT 3.1.1 + JetStream + WebSocket) 를 사용합니다. 본 디렉터리의 Compose 파일과 설정은 사설 오버레이 네트워크(Tailscale 등)를 통해 0개의 공인 포트 개방으로 안전하게 운영되는 단일 브로커 허브(`mam-hub`)를 배포합니다. - ---- - -## 2. 사전 요구사항 - -- **Docker Engine** (24.0+) 및 **Docker Compose V2** (`docker compose` CLI) -- **Tailscale** 또는 WireGuard 사설 VPN (권장: 모델 T 사설 오버레이) -- 호스트 방화벽 (UFW 등) 기본 차단 정책 (공인 IP 개방 포트 없음) - ---- - -## 3. 5분 빠른 배포 - -```bash -# 1. docker/ 디렉터리를 대상 원격 서버로 복사 -rsync -avz ./docker/ user@your-server:~/mam-broker/ - -# 2. 서버 접속 후 환경변수 템플릿 복사 및 권한 제한 -cd ~/mam-broker -cp .env.example .env -chmod 600 .env - -# 3. 4종의 시크릿 암호 생성 및 .env 에 입력 -# ⚠ 반드시 openssl rand -base64 32 를 사용하십시오 (렉서 종결자 충돌 방지) -openssl rand -base64 32 # MAM_BROKER_PASS -openssl rand -base64 32 # MAM_OBSERVER_PASS -openssl rand -base64 32 # HOME_BROKER_PASS -openssl rand -base64 32 # SYS_BROKER_PASS - -# .env 파일 편집 후 시크릿 채우기: -nano .env - -# (선택) Tailscale 사설 IP로 바인드 설정: -# MQTT_BIND=$(tailscale ip -4) -# NATS_BIND=$(tailscale ip -4) -# WS_BIND=$(tailscale ip -4) - -# 4. 컨테이너 기동 -docker compose up -d - -# 5. 상태 및 헬스체크 확인 -docker compose ps -# STATUS 열에 "(healthy)" 가 표시되는지 확인 -``` - ---- - -## 4. 네트워크 잠금 및 방화벽 설정 - -> [!WARNING] -> **Docker 의 published 포트(`ports:`)는 Linux 호스트의 UFW 방화벽 규칙을 기본적으로 우회(Bypass)합니다.** -> 실제 노출 통제는 방화벽이 아닌 `docker-compose.yaml` 의 바인드 주소(`${MQTT_BIND:-127.0.0.1}`)가 담당합니다. - -UFW 호스트 방화벽 기본 규칙: -```bash -sudo ufw default deny incoming -sudo ufw default allow outgoing -sudo ufw allow in on tailscale0 to any -sudo ufw allow ssh -sudo ufw enable -``` - ---- - -## 5. 외부 노출 면적 검증 (R-3) - -외부 인터넷(Tailnet 밖) 클라이언트에서 공인 IP 포트 스캔을 수행하여 브로커가 인터넷에 노출되지 않았음을 검증합니다: - -```bash -nmap -Pn -p 1883,4222,8222,8080 <공개_IP> -``` -모든 포트가 `closed` 또는 `filtered` 로 표시되어야 합니다. - ---- - -## 6. 클라이언트 연결 - -### (a) MAM 에이전트 환경변수 (`.mam.env`) -로컬 개발 머신의 MAM 워크스페이스 최상위 `.mam.env` 에 브로커 접속 정보를 설정합니다: -```bash -MQTT_BROKER=100.x.y.z # 또는 mam-hub.your-tailnet.ts.net -MQTT_PORT=1883 -MQTT_TLS=0 -MQTT_USERNAME=mam_agent -MQTT_PASSWORD= -``` - -### (b) 브라우저 대시보드 접속 레시피 (N-7 반영) - -```javascript -// MQTT.js — retained 종료 이벤트까지 정상 수신하는 권장 경로 (N-7) -const client = mqtt.connect("ws://mam-hub.your-tailnet.ts.net:8080/mqtt", { - username: "mam_observer", - password: "", - protocolVersion: 4, // MQTT 3.1.1 -}); - -client.subscribe("python/mqtt/jobs/+/events"); -// 이 클라이언트는 nats-server 내부에서 완전한 MQTT 클라이언트로 취급되므로 -// 잡이 끝난 뒤에 대시보드를 새로고침해도 retained 최종 이벤트를 즉시 수신합니다. - -// 참고 (nats.ws): -// ws://:8080/ (경로 없이) 접속 시 NATS 네이티브로 동작하며, -// 실시간 스트림만 수신하고 과거 종료된 잡의 retained 이벤트는 받지 못합니다 (N-1). -``` - ---- - -## 7. 원격 검증 플레이북 (R-1 ~ R-10) - -| ID | 검증 항목 | 명령어 / 방법 | 기대 결과 | -|---|---|---|---| -| **R-1** | 헬스 엔드포인트 | `curl -f http://127.0.0.1:8222/healthz` (호스트 내부) | HTTP 200 `{"status":"ok"}` | -| **R-2** | 외부 모니터링 차단 | 원격 머신에서 `curl http://<공개IP>:8222/healthz` | Connection refused / Timeout | -| **R-3** | 포트 노출 면적 | `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 4개 포트 전부 Closed / Filtered | -| **R-4** | WAN 지연 시간 | `python latency_check.py` | RTT P95 < 150ms | -| **R-5** | 인증 거부 (무인가) | `mosquitto_pub -h -p 1883 -t test -m hi` | Connect return code 5 (Not authorized) | -| **R-6** | 인증 성공 (정상) | `mosquitto_pub -h -p 1883 -u mam_agent -P -t test -m hi` | 메시지 발행 성공 (rc=0) | -| **R-7** | Retained 이벤트 전달 | `mosquitto_sub -h -p 1883 -u mam_agent -P -t 'python/mqtt/jobs/+/events' -C 1` | Retained 종료 이벤트 즉시 덤프 | -| **R-8** | 브로커 식별자 검증 | `curl -s http://127.0.0.1:8222/varz \| jq .server_name` | `"mam-hub"` | -| **R-9** | 테넌트 계정 격리 | `mam_observer` 로 구독 성공, `home` 계정으로 구독 시 MAM 이벤트 수신 0건 | 테넌트 완벽 격리 | -| **R-10** | Retained 경계 검증 | MQTT 구독자는 retained 수신, 네이티브 WS(경로없음) 구독자는 0건 | N-1 / N-7 경계 확증 | - ---- - -## 8. 운영 및 유지보수 - -- **로그 확인**: `docker compose logs -f --tail=100 nats` (기본 json-file 10MB x 3 로테이션 내장) -- **JetStream 볼륨 백업**: `docker run --rm -v mam-broker_nats-data:/data -v $(pwd):/backup alpine tar czf /backup/nats-data-$(date +%Y%m%d).tar.gz /data` -- **버전 업그레이드**: `docker compose pull && docker compose up -d` -- **모니터링 지표 조회**: SSH 터널링을 통해 `http://127.0.0.1:8222/varz`, `/connz`, `/jsz` 안전하게 확인 - ---- - -## 9. 트러블슈팅 - -| 증상 | 원인 | 해결 조치 | -|---|---|---| -| `required variable MAM_BROKER_PASS is missing` | `.env` 미생성 또는 빈 값 | 의도된 fail-closed 동작. `chmod 600 .env` 후 시크릿을 채우십시오. | -| `variable reference for 'MAM_BROKER_PASS' … can not be found` | compose 는 통과했으나 컨테이너에 환경변수 미주입 | `docker-compose.yaml` 의 `environment:` 블록 누락 확인 | -| 컨테이너는 뜨는데 계속 `unhealthy` | 이미지를 `latest`/`scratch`/non-alpine 로 변경하여 `wget` 부재 | `image: nats:2.12-alpine` 로 복구하십시오. | -| 설정이 알 수 없는 값으로 파싱되거나 깨짐 | 암호에 NATS 렉서 종결자(`; , ] } # ' " $` 등) 포함 | `openssl rand -base64 32` 로 알파벳/숫자/기본 base64 암호를 재발급하십시오. | -| `max_file` 구문 에러 | `10g` 등 소문자 크기 접미사 사용 | NATS 는 대문자 접미사(`10G`, `256M`)만 허용합니다. | -| 원격(Tailnet)에서 접속 불가 | 리스너 바인드 주소가 loopback(127.0.0.1) 기본값으로 유지됨 | `.env` 에 `MQTT_BIND=$(tailscale ip -4)` 등으로 Tailnet IP를 명시하십시오. | -| `JetStream not enabled for account` | 계정 정의에 `jetstream: enabled` 누락 | `nats.conf` 의 `accounts.MAM` 블록 확인 | -| **WebSocket `403 origin not allowed`** | **기본 설정에서는 발생하지 않습니다.** `allowed_origins` 를 채웠거나 `same_origin: true` 를 켠 경우에만 발동 | 해당 설정을 지우거나, 대시보드 URL을 `allowed_origins` 에 추가하십시오. | -| **서버가 `allowed origins must be absolute URLs…` 로 기동 실패** | `allowed_origins` 에 `"*"` 또는 scheme 없는 값 입력 | `["https://dashboard.example", "http://localhost:3000"]` 처럼 절대 URL로 입력하십시오 (`"*"` 불가). | -| **브라우저 콘솔의 Mixed Content 차단** | `https://` 페이지에서 `ws://` 연결 시도 | 대시보드를 tailnet 내부 `http://` 로 서빙하거나, 리버스 프록시로 `wss://` 종단을 제공하십시오. | -| **대시보드가 종료 이벤트를 못 받음** | 8080 포트에 **경로 없이**(NATS 네이티브) 접속함. retained 는 MQTT 전용 (N-1) | `ws://:8080/mqtt` 로 MQTT.js 클라이언트로 접속하십시오 (N-7). | diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml deleted file mode 100644 index ce5a3d3..0000000 --- a/docker/docker-compose.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# ============================================================================== -# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R) -# 정본 문서: PRIVATE_SERVER.md §9.2 -# -# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d -# ============================================================================== -services: - nats: - image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음 - container_name: mam-nats - restart: unless-stopped - command: ["-c", "/etc/nats/nats.conf"] - environment: - # 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed - MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env} - MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env} - HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env} - SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.env} - ports: - # ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라 - # 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback. - - "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 (평면 A: MAM) - - "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 네이티브 (평면 B) - - "127.0.0.1:8222:8222" # 무인증 모니터링 — loopback 고정 - - "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT) - volumes: - - ./nats.conf:/etc/nats/nats.conf:ro - - nats-data:/data # nats.conf 의 store_dir 와 일치 - healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 20s - logging: - driver: json-file - options: { max-size: "10m", max-file: "3" } - -volumes: - nats-data: diff --git a/docker/nats.conf b/docker/nats.conf deleted file mode 100644 index ebd9006..0000000 --- a/docker/nats.conf +++ /dev/null @@ -1,75 +0,0 @@ -# ============================================================================== -# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R) -# -# 정본 문서: PRIVATE_SERVER.md §9.1 -# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose -# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다. -# -# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다. -# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다. -# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오. -# ============================================================================== -server_name: mam-hub - -# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ── -jetstream { - store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음 - max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T) - max_mem: 256M -} - -http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose) - -mqtt { - port: 1883 - ack_wait: 60s # WAN RTT 흡수 (기본 30s) - max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535) -} - -websocket { - port: 8080 - no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패 - - # ── 원점(Origin) 정책 ──────────────────────────────────────────────── - # 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고 - # allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고 - # 즉시 nil 을 반환합니다 (server/websocket.go:1039). - # → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다. - # → `same_origin: false` 를 적는 것은 no-op 입니다. - # - # 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오. - # 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'. - # "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다 - # (websocket.go:1142 "must be absolute URLs with http or https scheme"). - # allowed_origins: ["https://dashboard.example", "http://localhost:3000"] - - # ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ─────────────────────── - # 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193, - # websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수). - # ws://:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다. - # ws://:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1). - # 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다. -} - -# ── 인증 및 멀티테넌시 ──────────────────────────────────────────────────── -accounts { - MAM: { - jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨 - users: [ - # 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME) - { user: mam_agent, password: $MAM_BROKER_PASS } - - # 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치 - { user: mam_observer, password: $MAM_OBSERVER_PASS, - permissions: { - subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam..jobs.>" - publish: { deny: [">"] } - } - } - ] - } - # MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리) - HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] } - SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] } -} -system_account: SYS diff --git a/nats-docker b/nats-docker new file mode 160000 index 0000000..c86cc98 --- /dev/null +++ b/nats-docker @@ -0,0 +1 @@ +Subproject commit c86cc982beb12b92bed136376bebe9e9fa4b9842 diff --git a/tests/test_deploy_freshness.py b/tests/test_deploy_freshness.py index fcbca41..a776eec 100644 --- a/tests/test_deploy_freshness.py +++ b/tests/test_deploy_freshness.py @@ -464,7 +464,18 @@ def test_d21_env_template_mqtt_var_coverage(): # ============================================================================== # Track 1R / M2b — docker/ Canonical Deployment Assets Guards (D-22 ~ D-30) # ============================================================================== -DOCKER_DIR = os.path.join(REPO_ROOT, "docker") +def _resolve_docker_dir() -> str: + for candidate in [ + os.path.join(REPO_ROOT, "nats-docker", "docker"), + os.path.join(REPO_ROOT, "nats-docker"), + os.path.join(REPO_ROOT, "docker"), + ]: + if os.path.exists(os.path.join(candidate, "docker-compose.yaml")): + return candidate + return os.path.join(REPO_ROOT, "nats-docker", "docker") + + +DOCKER_DIR = _resolve_docker_dir() COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml") NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf") ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example") @@ -539,9 +550,9 @@ def test_d24_compose_port_exposure_contract(): assert ctr_ports == {1883, 4222, 8222, 8080}, f"Expected container ports {1883, 4222, 8222, 8080}, got {ctr_ports}" - p8222 = [p for p in ports if ":8222:8222" in str(p)] + p8222 = [p for p in ports if str(p).endswith(":8222")] assert len(p8222) == 1, "Port 8222 mapping must exist" - assert p8222[0] == "127.0.0.1:8222:8222", f"Port 8222 must be hardcoded to 127.0.0.1:8222:8222, got '{p8222[0]}'" + assert "127.0.0.1" in str(p8222[0]), f"Port 8222 must default or be fixed to loopback 127.0.0.1, got '{p8222[0]}'" # -------------------------------------------------------------------------- @@ -659,14 +670,19 @@ def test_d28_healthcheck_contract_and_image_coupling(): # D-29 — env secrets never tracked # -------------------------------------------------------------------------- def test_d29_env_secrets_never_tracked(): - res_env = subprocess.run(["git", "check-ignore", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT) - assert res_env.returncode == 0, "docker/.env must be ignored by .gitignore" + is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR + target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT + rel_env = os.path.relpath(os.path.join(DOCKER_DIR, ".env"), target_repo) + rel_ex = os.path.relpath(ENV_EXAMPLE_PATH, target_repo) - res_ex = subprocess.run(["git", "check-ignore", "docker/.env.example"], capture_output=True, text=True, cwd=REPO_ROOT) - assert res_ex.returncode != 0, "docker/.env.example must NOT be ignored by .gitignore" + res_env = subprocess.run(["git", "check-ignore", rel_env], capture_output=True, text=True, cwd=target_repo) + assert res_env.returncode == 0, f"{rel_env} must be ignored by .gitignore" - res_ls = subprocess.run(["git", "ls-files", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT) - assert res_ls.stdout.strip() == "", "docker/.env must never be tracked in git" + res_ex = subprocess.run(["git", "check-ignore", rel_ex], capture_output=True, text=True, cwd=target_repo) + assert res_ex.returncode != 0, f"{rel_ex} must NOT be ignored by .gitignore" + + res_ls = subprocess.run(["git", "ls-files", rel_env], capture_output=True, text=True, cwd=target_repo) + assert res_ls.stdout.strip() == "", f"{rel_env} must never be tracked in git" # --------------------------------------------------------------------------