Blueforce-Linux/tests/check-ansible-static.sh
emirhanmamak-blueforce ef102b9657 feat(field-os): provisioning layer, real ISO build, offline contracts
Adds the Blueforce Field OS provisioning stack on top of the existing fleet
architecture, without rewriting the working systems (installer, diagnostics,
Ansible, WireGuard/SSH/xRDP/RustDesk, Docker policies, monitoring, Docusaurus).

ISO build (the previous builder was a skeleton that produced no medium):
- boot layout is read from the base ISO at build time via
  `xorriso -report_el_torito as_mkisofs` and rebuilt with `-as mkisofs`;
  `-boot_image any replay` cannot work on Ubuntu live ISOs because the GRUB2
  MBR lives in the source image's first 16 sectors and the EFI system partition
  is an appended partition, neither of which is a file in the ISO9660 tree
- preflight now asserts El Torito boot metadata positively: xorriso exits 0 on
  a non-ISO input, which previously let an arbitrary file pass --check
- artifact produced and verified: dist/Blueforce-Field-OS-1.0.0-amd64.iso,
  6 481 917 952 bytes, SHA256 cb0cc56f...d431296, BIOS+UEFI entries preserved,
  /autoinstall.yaml at the media root, casper/minimal.squashfs byte-identical
  to the base
- recorded baseline deviation: the verified medium on the build host is the
  Desktop flavour, so the manifest carries a baseline_deviation block instead
  of hiding it; Server remains one flag away

Offline and identity:
- offline APT contract with concrete package requirements, pin format and an
  audit table; builds fail closed while pins are UNPINNED
- autoinstall keeps interactive-sections: [storage]: the operator confirms the
  target disk, and no automatic wipe is ever performed
- firstboot terminal wizard for the 8-digit dealer id, three-state lifecycle
  (PROVISIONED_OFFLINE -> ENROLLED -> READY) with fail-closed gates, so a
  missing internet link or WireGuard handshake cannot fail provisioning but can
  never produce a false READY

Fleet and migration:
- five read-only Ansible playbooks for Field OS version, provisioning,
  enrollment, remote channels and offline readiness, all wave-scoped with a
  separate production override
- Windows pre-migration inventory and data export extended to the full brief
  scope, read-only, never collecting credentials

Tests: tests/run-all.sh -> 8/8 pass, exit 0. Boot acceptance (UEFI, Legacy
BIOS, Secure Boot), air-gapped install, real enrollment endpoint and Windows
rollback remain LAB work and are listed in IMPLEMENTATION-REPORT.md.
2026-09-16 11:14:42 +03:00

227 lines
12 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Ansible playbook statik güvenlik kapısı (read-only; ansible-playbook kurulumu GEREKTİRMEZ).
# Denetledikleri:
# 1) 21 playbook envanteri (5 dalga işlemi + 5 rapor + 11 dokümante edilmiş tek-hedef/legacy) ve
# sınıflandırılamayan yeni playbook'a kapalı kapı (fail-closed). Dalga işlemi ve rapor
# playbook'ları TAM dalga kapısına tabidir ve hosts: blueforce kullanamaz; tek cihaz
# teşhis/bakım playbook'ları (docs/09 hedefleme seviyeleri) hedef sınırlama talimatı
# taşımak zorundadır -- bunları dalga kapısına taşımak ayrı bir değişikliktir, bu kapı onları
# sessizce genişletmeye izin vermez.
# 2) dalga kapsamı kapısı: dinamik hosts dalgası, alt çizgili approved dalga listesi, onay
# değişkeni, ansible_play_hosts_all kesişimi ve AYRI production_override (production için şart),
# 3) rapor playbook'larının read-only kalması (mutasyon modülü yok, shell/raw yok, her command
# changed_when: false) ve operation_wave_gate ön kapısı,
# 4) moving tag (:latest) ve Watchtower yasağı,
# 5) bf-security-updates-check.yml'in report-only kalması,
# 6) tüm playbook + inventory YAML'larının parse ve yapı doğrulaması.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PB="$REPO_ROOT/ansible/playbooks"
INV="$REPO_ROOT/ansible/inventory/hosts.example.yml"
GV="$REPO_ROOT/ansible/inventory/group_vars/all.yml"
FAIL=0
fail() { printf 'FAIL: %s\n' "$*" >&2; FAIL=1; }
pass() { printf 'OK: %s\n' "$*"; }
# --- 1) Envanter: 16 mevcut + 5 yeni rapor playbook = 21 -------------------------------------
WAVE_PLAYBOOKS=(bf-collect-logs.yml bf-deploy-update.yml bf-gui-off.yml bf-gui-on.yml bf-run-script.yml)
REPORT_PLAYBOOKS=(bf-fieldos-version.yml bf-provisioning-status.yml bf-enrollment-status.yml bf-remote-channels-check.yml bf-offline-ready-check.yml)
LEGACY_PLAYBOOKS=(bf-ping.yml bf-uptime.yml bf-reboot.yml bf-disk-check.yml bf-package-check.yml bf-security-updates-check.yml bf-docker-status.yml bf-service-restart.yml bf-wireguard-restart.yml bf-rdp-restart.yml bf-rustdesk-restart.yml)
ALL_PLAYBOOKS=("${WAVE_PLAYBOOKS[@]}" "${REPORT_PLAYBOOKS[@]}" "${LEGACY_PLAYBOOKS[@]}")
GATED_PLAYBOOKS=("${WAVE_PLAYBOOKS[@]}" "${REPORT_PLAYBOOKS[@]}")
if [[ ${#ALL_PLAYBOOKS[@]} -ne 21 ]]; then
fail "playbook envanteri 21 olmalı, tanım ${#ALL_PLAYBOOKS[@]}"
fi
for pb in "${ALL_PLAYBOOKS[@]}"; do
if [[ -f "$PB/$pb" ]]; then pass "present: $pb"; else fail "missing playbook: $pb"; fi
done
# Sınıflandırılamayan YENİ playbook kabul edilmez: dalga kapısı veya dokümante edilmiş istisna şart.
unclassified=0
while IFS= read -r -d '' asset; do
base="$(basename "$asset")"
known=0
for pb in "${ALL_PLAYBOOKS[@]}"; do [[ "$base" == "$pb" ]] && known=1; done
if [[ "$known" -ne 1 ]]; then
fail "unclassified playbook (fail-closed): $base -- ya dalga kapısı (operation_wave/release_wave) taşımalı ya da dokümante edilmiş tek-hedef istisnası olmalı"
unclassified=$((unclassified + 1))
fi
done < <(find "$PB" -maxdepth 1 -type f -name '*.yml' -print0)
[[ "$unclassified" -eq 0 ]] && pass "playbook sınıflandırması tam: 21/21 (dalga kapılı veya dokümante edilmiş istisna)"
# --- 2) Dalga kapsamı kapısı + ayrı production_override --------------------------------------
APPROVED_LITERAL='[lab, pilot_1, pilot_2, wave_1, wave_2, production]'
for group in lab pilot_1 pilot_2 wave_1 wave_2 production; do
grep -q "^[[:space:]]*$group:" "$INV" || fail "inventory dalga grubu eksik: $group"
done
if grep -nE '^[[:space:]]*(lab|pilot|wave|production)-[0-9A-Za-z]' "$INV"; then
fail "inventory dalga grup adları ALT ÇİZGİLİ olmalı; tireli varyant yasak"
else
pass "inventory dalga grupları alt çizgili (lab/pilot_1/pilot_2/wave_1/wave_2/production)"
fi
for pb in "${GATED_PLAYBOOKS[@]}"; do
file="$PB/$pb"
[[ -f "$file" ]] || continue
if grep -qE '^ hosts: "\{\{ (operation_wave|release_wave) \| default\(' "$file"; then
pass "$pb: dinamik dalga hosts deseni"
else
fail "$pb: hosts dalga desenini ({{ operation_wave|release_wave | default(...) }}) korumalı"
fi
grep -Fq "$APPROVED_LITERAL" "$file" || fail "$pb: approved dalga listesi birebir olmalı: $APPROVED_LITERAL"
if grep -q -- 'operation_confirmed | bool' "$file" || grep -q -- 'wave_gate_confirmed | bool' "$file"; then
pass "$pb: onay kapısı (operation_confirmed/wave_gate_confirmed) mevcut"
else
fail "$pb: onay kapısı eksik (operation_confirmed | bool)"
fi
grep -q 'ansible_play_hosts_all' "$file" || fail "$pb: seçilen hostlar ansible_play_hosts_all ile denetlenmeli"
grep -q 'difference(groups\[' "$file" || fail "$pb: seçilen hostlar dalga grubundan fark olarak denetlenmeli (difference(groups[...]))"
grep -q 'production_override: false' "$file" || fail "$pb: production_override varsayılan false olmalı"
grep -q "'production' or production_override | bool" "$file" || fail "$pb: production dalgası AYRI production_override isteğini denetlemeli"
if grep -qE '^ hosts: blueforce$' "$file"; then
fail "$pb: hosts: blueforce (tüm filo) yasak; dalga kapsamı zorunlu"
else
pass "$pb: tüm-filo hedefi (hosts: blueforce) yok"
fi
done
if grep -nE '(lab|pilot|wave|production)-[0-9]' "$PB"/*.yml; then
fail "playbook'larda tireli dalga adı bulundu; alt çizgili grup adları zorunlu"
else
pass "playbook'larda tireli dalga adı yok"
fi
# Rapor playbook'ları: operation_wave_gate ön kapısı (--limit mutabakatı dahil) zorunlu.
for pb in "${REPORT_PLAYBOOKS[@]}"; do
file="$PB/$pb"
[[ -f "$file" ]] || continue
grep -Fq 'operation_wave_gate' "$file" || fail "$pb: operation_wave_gate ön kapısı eksik"
grep -Fq 'hosts: "{{ ansible_limit | default(' "$file" || fail "$pb: dalga ön kapısı --limit ile mutabakat için ansible_limit deseni kullanmalı"
grep -q 'does not intersect the' "$file" || fail "$pb: dalga/limit kesişim reddi (fail-closed mesajı) eksik"
done
# --- 3) Rapor playbook'ları read-only olmalı -------------------------------------------------
MUTATING_MODULES='ansible\.builtin\.(shell|raw|script|copy|template|file|lineinfile|replace|blockinfile|user|group|cron|mount|apt|apt_repository|apt_key|apt_repository_key|dpkg_selections|debconf|systemd|systemd_service|service|reboot|package|yum|dnf|pip|get_url|uri|unarchive|archive|git|hostname|sysctl|redhat_subscription|ufw|firewalld|nmcli|command_shell):'
for pb in "${REPORT_PLAYBOOKS[@]}"; do
file="$PB/$pb"
[[ -f "$file" ]] || continue
if grep -nE "$MUTATING_MODULES" "$file"; then
fail "$pb: read-only rapor playbook'unda mutasyon modülü bulundu"
else
pass "$pb: mutasyon modülü yok (stat/slurp/command/debug/assert/set_fact)"
fi
cmd_count="$(grep -c 'ansible\.builtin\.command:' "$file" || true)"
cw_count="$(grep -c 'changed_when: false' "$file" || true)"
if [[ "$cw_count" -ge "$cmd_count" ]]; then
pass "$pb: command görevleri changed_when: false (${cmd_count} command / ${cw_count} changed_when)"
else
fail "$pb: her command görevi changed_when: false taşımalı (${cmd_count} command / ${cw_count} changed_when)"
fi
grep -qi 'read-only' "$file" || fail "$pb: başlıkta read-only sözleşmesi belirtilmeli"
if grep -nE 'changed_when: true|state: (restarted|started|stopped|absent)|--force|git pull|docker pull|rm -rf|mkfs|dd if=' "$file"; then
fail "$pb: read-only sözleşmesini bozan ifade bulundu"
else
pass "$pb: mutasyon/force ifadesi yok"
fi
done
# --- 4) security-updates-check report-only kalmalı ------------------------------------------
SU="$PB/bf-security-updates-check.yml"
if [[ -f "$SU" ]]; then
if grep -nE 'state:[[:space:]]*(present|latest)|^[[:space:]]*upgrade:|apt(-get)?[[:space:]].*[[:space:]]install|(apt-get|apt)[[:space:]].*dist-upgrade[[:space:]]*(-y|--assume-yes)|dpkg[[:space:]]+-i|unattended-upgrade[[:space:]]+--' "$SU"; then
fail 'bf-security-updates-check.yml kurulum/yükseltme içeriyor; report-only kalmalı'
else
pass 'bf-security-updates-check.yml report-only (kurulum/yükseltme komutu yok)'
fi
grep -q 'REPORT ONLY' "$SU" && pass 'bf-security-updates-check.yml REPORT ONLY uyarısını koruyor' || fail 'bf-security-updates-check.yml REPORT ONLY uyarısı kaybolmuş'
if grep -qE 'dist-upgrade' "$SU" && ! grep -q 'apt-get -s dist-upgrade' "$SU"; then
fail 'bf-security-updates-check.yml dist-upgrade yalnız simülasyon (-s) olarak koşmalı'
fi
fi
# Dalga kapısı taşımayan playbook'lar (tek cihaz teşhis/bakım; docs/09 hedefleme seviyeleri)
# en azından hedef sınırlama talimatı veya kendi kapsam assert'ini korumalı. Yeni ve
# sınıflandırılmamış bir playbook bu kapıdan geçemez (bkz. bölüm 1, fail-closed).
for pb in "${LEGACY_PLAYBOOKS[@]}"; do
file="$PB/$pb"
[[ -f "$file" ]] || continue
if grep -q -- '--limit' "$file" || grep -q 'ansible.builtin.assert' "$file"; then
pass "$pb: dalga dışı kullanım hedef sınırlama talimatı taşıyor (--limit veya kapsam assert'i)"
else
fail "$pb: dalga kapısı yok ve hedef sınırlama talimatı da yok (fail-closed)"
fi
done
# --- 5) Moving tag / Watchtower yasağı -------------------------------------------------------
if grep -RniE --include='*.yml' --include='*.yaml' --include='*.json' \
'^[[:space:]]*[^#].*(image:[^#]*:latest([[:space:]#]|$)|watchtower)' \
"$REPO_ROOT/ansible" "$REPO_ROOT/config" "$REPO_ROOT/monitoring" "$REPO_ROOT/provisioning"; then
fail 'moving tag (:latest) veya Watchtower (otomatik güncelleyici) yapılandırması bulundu'
else
pass 'moving tag (:latest) ve Watchtower yasağı korunuyor'
fi
if grep -RniE --include='*.yml' 'image:[^#]*:latest' "$PB"; then
fail "playbook'larda :latest etiketi bulundu"
else
pass "playbook'larda sabitlenmemiş imaj etiketi yok"
fi
# --- 6) YAML parse + yapı doğrulaması -------------------------------------------------------
if python3 - "$REPO_ROOT" <<'PY'
import pathlib
import sys
import yaml
root = pathlib.Path(sys.argv[1])
playbooks = root / "ansible" / "playbooks"
problems = []
files = sorted(playbooks.glob("*.yml"))
if len(files) != 21:
problems.append(f"expected 21 playbook YAML files, found {len(files)}")
for path in files:
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - rapor amaçlı
problems.append(f"{path.name}: YAML parse hatası: {exc}")
continue
if not isinstance(data, list) or not data:
problems.append(f"{path.name}: en üst düzey play listesi olmalı")
continue
for index, play in enumerate(data):
if not isinstance(play, dict):
problems.append(f"{path.name}: play {index} mapping değil")
continue
if "hosts" not in play:
problems.append(f"{path.name}: play {index} hosts anahtarı yok")
if "tasks" not in play and "pre_tasks" not in play:
problems.append(f"{path.name}: play {index} tasks/pre_tasks içermiyor")
if not isinstance(play.get("hosts"), str) or not play.get("hosts", "").strip():
problems.append(f"{path.name}: play {index} hosts boş veya metin değil")
for extra in (root / "ansible" / "inventory" / "hosts.example.yml", root / "ansible" / "inventory" / "group_vars" / "all.yml"):
try:
yaml.safe_load(extra.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 - rapor amaçlı
problems.append(f"{extra.name}: YAML parse hatası: {exc}")
if problems:
print("\n".join(problems))
sys.exit(1)
PY
then
pass '21 playbook + inventory YAML parse ve yapı doğrulaması'
else
fail 'YAML parse/yapı doğrulaması başarısız (yukarıdaki satırlar)'
fi
if [[ "$FAIL" -ne 0 ]]; then
printf 'check-ansible-static: FAILED\n' >&2
exit 1
fi
printf 'check-ansible-static: all passed\n'