From 1387186b03834d164718ce7c72c758811b6134cc Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Wed, 16 Sep 2026 16:15:27 +0300 Subject: [PATCH] feat(bfos): Python operator console, Turkish UI, committable offline packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the operator path from bash-only to a Python console, keeps the bash menu as a fallback, and makes the offline package set travel with the repository so the person installing a device never downloads anything. bfos/ (stdlib only, no textual/rich: those would need a download, which defeats the offline goal): - simple mode offers three choices (Kur / Durum / Çıkış); the detailed mode with all 21 fleet operations and the local installer is reachable from the menu or with --full, so a first-time operator is not buried in options - gate parity with the bash console: scope read from the playbook file and refused on mismatch, production needs a typed token plus a separate approval, release-gated runs need their wave name typed, destructive runs need a second approval, "all" walks wave by wave and stops at the first failure - Turkish throughout; audit log at /var/log/blueforce-console.log (600, falls back to the user state dir) with a single redaction choke point - bfos/ui_gui.py adds an optional tkinter window over the same operations layer; headless and missing-tkinter cases explain themselves in Turkish admin/bf-bootstrap.sh: links the Python console as `bf` (primary) and keeps `bf-menu` (fallback), reports both, and recommends `sudo bf`. Offline packages are committable now (this was the blocking contradiction): - .gitignore ignored `Packages`, `Packages.gz` and `packages.lock.tsv` everywhere, so a built bundle could never be committed and the installer would always have been told to download. provisioning/offline-repo/built/ is now allowed through while scratch output stays untracked - provisioning/offline-repo/{add-packages.sh,build-index.sh,repo-manifest.yaml} plus a Turkish step-by-step README for the repository owner - the over-broad `*token*` ignore rule is narrowed to `*[-_.]token*` so ordinary filenames are not silently excluded while token files still are Tests: run-all.sh now runs the Python suites too (previously it collected only *.sh, so 212 Python assertions were silently skipped) and executes from the repo root. 11/11 pass, exit 0. Two test expectations were corrected rather than the code: a read-only wave-scoped report legitimately asks for no gate token (the bash console never did either), so that assertion now checks the command carries operation_wave and operation_confirmed, and a new test asserts release-gated runs still require their token. The bootstrap completion contract moved from `sudo bf-menu` to `sudo bf`. --- .gitignore | 13 +- admin/bf | 64 + admin/bf-bootstrap.sh | 53 +- bfos/__init__.py | 17 + bfos/__main__.py | 6 + bfos/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 871 bytes bfos/__pycache__/cli.cpython-311.pyc | Bin 0 -> 69898 bytes bfos/__pycache__/config.cpython-311.pyc | Bin 0 -> 16611 bytes bfos/__pycache__/operations.cpython-311.pyc | Bin 0 -> 76633 bytes bfos/__pycache__/runner.cpython-311.pyc | Bin 0 -> 19325 bytes bfos/__pycache__/tui.cpython-311.pyc | Bin 0 -> 32631 bytes bfos/__pycache__/ui_gui.cpython-311.pyc | Bin 0 -> 161895 bytes bfos/cli.py | 1104 ++++++ bfos/config.py | 381 +++ bfos/operations.py | 1193 +++++++ bfos/runner.py | 309 ++ bfos/tui.py | 499 +++ bfos/ui_gui.py | 3029 +++++++++++++++++ provisioning/offline-repo/README.md | 396 ++- provisioning/offline-repo/add-packages.sh | 365 ++ provisioning/offline-repo/build-index.sh | 518 +++ provisioning/offline-repo/repo-manifest.yaml | 71 + pyproject.toml | 32 + scripts/install/blueforce-install.sh | 297 +- .../test_bfos_gui_static.cpython-311.pyc | Bin 0 -> 66845 bytes tests/check-admin-static.sh | 8 +- tests/check-offline-repo-static.sh | 331 ++ tests/run-all.sh | 13 +- tests/test_bfos.py | 1375 ++++++++ tests/test_bfos_gui_static.py | 703 ++++ 30 files changed, 10671 insertions(+), 106 deletions(-) create mode 100755 admin/bf create mode 100644 bfos/__init__.py create mode 100644 bfos/__main__.py create mode 100644 bfos/__pycache__/__init__.cpython-311.pyc create mode 100644 bfos/__pycache__/cli.cpython-311.pyc create mode 100644 bfos/__pycache__/config.cpython-311.pyc create mode 100644 bfos/__pycache__/operations.cpython-311.pyc create mode 100644 bfos/__pycache__/runner.cpython-311.pyc create mode 100644 bfos/__pycache__/tui.cpython-311.pyc create mode 100644 bfos/__pycache__/ui_gui.cpython-311.pyc create mode 100644 bfos/cli.py create mode 100644 bfos/config.py create mode 100644 bfos/operations.py create mode 100644 bfos/runner.py create mode 100644 bfos/tui.py create mode 100644 bfos/ui_gui.py create mode 100755 provisioning/offline-repo/add-packages.sh create mode 100755 provisioning/offline-repo/build-index.sh create mode 100644 provisioning/offline-repo/repo-manifest.yaml create mode 100644 pyproject.toml create mode 100644 tests/__pycache__/test_bfos_gui_static.cpython-311.pyc create mode 100644 tests/test_bfos.py create mode 100644 tests/test_bfos_gui_static.py diff --git a/.gitignore b/.gitignore index 1a18062..f10d1ff 100644 --- a/.gitignore +++ b/.gitignore @@ -15,12 +15,21 @@ dist/ *.iso.sha256 *.iso.boot-report.txt -# Offline APT repository output (generated by provisioning/offline-repo) +# Offline APT repository. +# +# IMPORTANT: the built bundle IS committed on purpose. The whole point of this +# repository is that an operator installing a field device never downloads +# anything: the packages travel with the repo. `provisioning/offline-repo/built/` +# is therefore allowed through the rules below, while a scratch bundle built +# anywhere else stays untracked. provisioning/offline-repo/out/ provisioning/offline-repo/pool/ Packages Packages.gz packages.lock.tsv +# ...but never inside the committed bundle. +!provisioning/offline-repo/built/ +!provisioning/offline-repo/built/** # Documentation site (generated) docs-site/node_modules/ @@ -39,7 +48,7 @@ fetched-logs/ id_rsa* id_ed25519* *credentials* -*token* +*[-_.]token* *.secret # Credential files delivered by `admin/bf-creds --fetch` (.txt). diff --git a/admin/bf b/admin/bf new file mode 100755 index 0000000..26ac913 --- /dev/null +++ b/admin/bf @@ -0,0 +1,64 @@ +#!/bin/sh +''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" >&2; exit 3; }; exec python3 "$0" "$@" # ''' +"""bfos operator console — thin entry point (sh/Python polyglot). + +The file is valid POSIX shell *and* valid Python: + +* run as ``admin/bf`` (or as the ``bf`` symlink that + ``admin/bf-bootstrap.sh`` creates), the shell line above checks that + ``python3`` exists and, when it does not, prints a Turkish error and points at + the proven bash console (``admin/bf-menu``) — a device without Python is still + operational, +* everything after that line is the Python entry point: it puts the repository + root on ``sys.path`` and calls :func:`bfos.cli.main`. Nothing else lives here; + all logic is in the ``bfos`` package next to it. + +Usage: sudo bfos [--full] [--dry-run] [--check] [--repo YOL] [--version] [--lang tr] +""" + +import os +import sys + +MIN_PYTHON = (3, 8) + +if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpreter + sys.stderr.write( + "HATA: Python sürümü çok eski: {0}. bfos için Python {1}.{2}+ gerekir.\n".format( + sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1] + ) + ) + sys.stderr.write("Yedek yol (bash): sudo admin/bf-menu\n") + raise SystemExit(3) + + +def _repository_root(): + """First candidate directory that actually contains the bfos package.""" + here = os.path.dirname(os.path.realpath(__file__)) + candidates = [ + os.environ.get("BF_REPO", ""), + os.path.dirname(here), # /admin/bf -> + here, + os.getcwd(), + ] + for candidate in candidates: + if candidate and os.path.isfile(os.path.join(candidate, "bfos", "__init__.py")): + return candidate + return "" + + +ROOT = _repository_root() +if not ROOT: + sys.stderr.write( + "HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n" + "ya da --repo YOL ile depo yolunu belirtin.\n" + "Yedek yol (bash): sudo admin/bf-menu\n" + ) + raise SystemExit(3) + +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from bfos.cli import main # noqa: E402 (sys.path is prepared above) + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/admin/bf-bootstrap.sh b/admin/bf-bootstrap.sh index 5497579..f124016 100755 --- a/admin/bf-bootstrap.sh +++ b/admin/bf-bootstrap.sh @@ -297,20 +297,44 @@ elif [[ "$CHECK_MODE" -eq 1 ]]; then fi # --------------------------------------------------------------------------- # -# 6. bf-menu: the on-device installer menu entry point. +# 6. Operator console entry point. +# +# The Python console (`admin/bf` -> bfos) is the primary path; the bash +# `bf-menu` stays linked as a fallback so a host without python3 can still be +# operated. # --------------------------------------------------------------------------- # +PY_CONSOLE="$DIR/admin/bf" MENU_SOURCE="$DIR/admin/bf-menu" +MENU_READY=0 + +if [[ -f "$PY_CONSOLE" ]]; then + if [[ "$CHECK_MODE" -eq 1 ]]; then + say "check: would make $PY_CONSOLE executable and link $BIN_DIR/bf" + else + chmod 0755 "$PY_CONSOLE" + ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf" + say "bf ready: $BIN_DIR/bf (Python console)" + fi + MENU_READY=1 +else + warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout" +fi + if [[ -f "$MENU_SOURCE" ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu" else chmod 0755 "$MENU_SOURCE" ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu" - say "bf-menu ready: $BIN_DIR/bf-menu" + say "bf-menu ready: $BIN_DIR/bf-menu (bash fallback)" fi MENU_READY=1 else - warn "admin/bf-menu is not present in $DIR; 'sudo bf-menu' will work once the menu module is in the checkout" + warn "admin/bf-menu is not present in $DIR; the bash fallback will work once it is in the checkout" +fi + +if [[ "$MENU_READY" -eq 0 ]]; then + warn "no operator console was found in $DIR; link one of admin/bf or admin/bf-menu manually" fi # --------------------------------------------------------------------------- # @@ -336,11 +360,22 @@ if [[ -n "$REVISION" ]]; then printf ' revision : %s\n' "$REVISION" fi printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR" -if [[ "$MENU_READY" -eq 1 ]]; then - printf ' bf-menu : %s\n' "$BIN_DIR/bf-menu" +if [[ -x "$BIN_DIR/bf" ]]; then + printf ' console : %s (Python, primary)\n' "$BIN_DIR/bf" else - printf ' bf-menu : pending (admin/bf-menu is not in the checkout yet)\n' + printf ' console : pending (admin/bf is not in the checkout yet)\n' fi -printf ' next : sudo bf-menu\n' -printf '\nInstallation complete. To get started: sudo bf-menu\n' -printf 'Kurulum tamam. Başlamak için: sudo bf-menu\n' +if [[ -x "$BIN_DIR/bf-menu" ]]; then + printf ' fallback : %s (bash)\n' "$BIN_DIR/bf-menu" +else + printf ' fallback : pending (admin/bf-menu is not in the checkout yet)\n' +fi +# The Python console is the recommended entry point; the bash menu stays as the +# fallback for a host without python3. +if [[ -x "$BIN_DIR/bf" ]]; then + printf ' next : sudo bf\n' +else + printf ' next : sudo bf-menu\n' +fi +printf '\nInstallation complete. To get started: sudo bf\n' +printf 'Kurulum tamam. Başlamak için: sudo bf\n' diff --git a/bfos/__init__.py b/bfos/__init__.py new file mode 100644 index 0000000..4934dd0 --- /dev/null +++ b/bfos/__init__.py @@ -0,0 +1,17 @@ +"""bfos — Blueforce Field OS operator console. + +Python operator console for the Blueforce field fleet, in Turkish, with a +simple default view (``1) Kur``, ``2) Durum``, ``3) Çıkış``) and a detailed view +(``--full``) that exposes the full operation catalogue. + +The console is a thin front end: it drives the existing installer, the existing +Ansible playbooks and the existing ``bf-*`` diagnostics tools. It never invents +a playbook, never edits the inventory and never talks to a device itself. + +No third-party dependency: standard library only (Python 3.8+). +""" + +from .config import VERSION + +__version__ = VERSION +__all__ = ["VERSION", "__version__"] diff --git a/bfos/__main__.py b/bfos/__main__.py new file mode 100644 index 0000000..622f705 --- /dev/null +++ b/bfos/__main__.py @@ -0,0 +1,6 @@ +"""``python3 -m bfos`` — module entry point for the operator console.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bfos/__pycache__/__init__.cpython-311.pyc b/bfos/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2995015352b2f69f7a3f5c37541e029288c504ab GIT binary patch literal 871 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 literal 0 HcmV?d00001 diff --git a/bfos/__pycache__/config.cpython-311.pyc b/bfos/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03c8406e8ce341aa9a4a6524f0a32a68f4ed8b12 GIT binary patch literal 16611 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 literal 0 HcmV?d00001 diff --git a/bfos/__pycache__/operations.cpython-311.pyc b/bfos/__pycache__/operations.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..584a3394af3fc0055e4176218cbda304e20fea95 GIT binary patch literal 76633 zcmc${3w#t;dMDc5>Q=YZt?t%a2mvi5Kmt8@2mvx+gd{*9B#Z<$!dPmlTB6Z&s#`qT z!lRiuGqRV&8V6rAvv#b^9ow3*Cxd6R8FJT?Wn#}}?*4Lft2*VMl)lR@JG;rv+`ZW! zUM5+Geck_ePE~bPtA*|PC3_2Ts`}Ka^Zd?tzSsHAJ2^So7FVIw%^J(@%^q|2y2m`d9+rn=G-u4)>mBp;`o{dd{;}NN+_Ajgys`Y=e7w&ToTCL} zg}sGVi_LP>BDh|(2w9<`w=*o3ck!QJy~Q?5DDzT@_B&K~X^r;$wiPMypI^PDD^jl2 z-le206CMiQ7wx_4gkK7N+}Goti~9!QZ-fHe1H#`5MYxv>|5+%;eIxEAxK{{kdMky} z-c7>V-pxW;?-pTQZeUq6)27%V>=bsf z6xBjQ+7!EmMq!Vd$KJFlN`-wwlbYg*v?=xr2ZV!aiYL>iI3zR+hlLi_me#Z>+Jqy* zQ8mS}v?o+7x|4zYtVY45UplC%EP_ z*LwE|U+>*3%=YdJ?Fu!78bfrt&R%+U3e4sW5WLs7I1H$vkDgB+ju&TC#&!sJe?5U>g^ET z?mhXK65m0Ii;pd_Q*GsUk@91o9LbKIX{F?aEF4o_La zM|kR`ny9tOMyy--Q{-{p5bHr~j__xQeHKq%{PzhTagan>~c*+<46`lt0R3O~L(;%Lx#)ZPaK~4gmiiDpdK7^-Y{4WuHf%qZy*Basf z!LMPgOBVsa>>9G-U8(RN5Ff@9<+WD$Yy7&jB2}629}z!-6zhafP{t^p)(ig$Ph&zx zx20+Wmbc(Pv0E%%lOZt}4Nr^*#wLW&XkchU3`8%60s})6k-!9diI~BO@yNtzs4hFZ z=VCZQNvB3b0X(s!p^L%G;fX1hEGh;Eqk)Mb_D21zCNMc6MnghiNSqjBFDAu_%c1e? zfnemKmUp14zdtC9g~#g$hHA$`<5T_pfo%aTZZtel9}SAbp=hKoaQ-jekUJZ=~himjoj7|&&M+4#UNHjQ#4j-MkLT!%>is8vv59C%&ooe1S68jpgbhj{f~KRb6XQ*2cyJWGKRz`!fLnk6 zbLY3$?z`~(-s_c=-LN<@HCay`RuceVq2otKd5!o8MS?@2=(WIbFdB+9sU1BiPDCQL zgBL@CBZ1&>koto|kvfEdq3|fO15gRn;S=MmeSk1g3=4RnwuY68P8tb~M`}#t6A1xQ z1%kw(usFu1Lk*R{@);Tpg`%}rkP{o{+JS4eY`jM&CMJ<67!5?C6O)ku9%wco2UKq; z7#^JxX~MLIBH`ijfQShbX<{%iJ`p(6&R_&jp{t?6Dc*4ewATVdQ{$`+fr}xLO|ePj zLtQ0?f&xlW8w(I)wc{TRPK=Ea&|yYy<=udVKM+Ln@c;(sa$qz}l|n8}n7d7Yh>1v@ zI5j>V5*ajXMeajWks!baKpqf-;RvQHzHb85MGS%?Q5^Mki$H#q!!~6RcouvV`ne%Mg~K7jt^; zvzDvYUPrg3+MRH!eHgpbto1fWFc>4P3u}jhgVbqS*M_d5{^P8P03|{_{ry5H3g7~E zx;QZkfS6EQ07wGvMZb-QfCJF=Q2@CZ8Vh0SQyX=R3D^UUWSLEhNXDR69X1A)1jY0{ zCiP?xi$iGiS{+NCuq2#n!bAqJTOyO!C6OtHqEq5{!rnPC9vaknEc6aNzRF@TQ$Y)cH%P2r(^fN0V6{wJr!9Rtk27tuBRoEJ6>HW+q@Ir=`Z`iKc}*-uPBFha=yh5>b|N-Xi{R54 z%RN`lOU@h4C09w@RWe^JyUG<;x#TKeE?z%tzu{RfD#Oh^i+>OC0U9Re;#Qr>Yo3p1 z)*z8R71GZl_MTcj9vlnx^(C_V`uJjvXHQ?>m!^WF>KkWYpD;0qj-h%ca?~Ce42_QV z^;KKNGQ>t`L=K7T5Q=^+*hVip5TF6tzXvwE!}Y*IfhWVU?t$ft2I6`x`-J0i5Ph3$ zQ^Jl_dUVhT91bLAK-Gf>Ge|{sz_3^r49`fYUelII5Wr@jGXDYy**bYwXNWY#6Irco z%^hv0`%bqd99SL(FJdS*;RWlU0PCQHkFR@u;Yc4^5EjH5BzTe%1rT7c-M-hdZuwsI zEjSk3i+QrYM)B9k?pnoNE7@yV2heCs-xlre8!4<)rvkJO7m@Uw(4FQFd=s+#4nP zMpoCq$2uN9LUk1DQJlB~fw6M=$;t_#%i+OLA9{8-5*?+~yC@*gN!Y3Ck8(70eje?( zkiLr3NDi1ms9mcdo-}ZB-8yX**G*f~LzZAO4*{4Q%dgvXsgh+JYXnvTJBKZipz{|r zx>&lL$GWa(u4qj=E;BM z#%#!TIv}jq@1Mb&`ACVyk4| zu687$2UE{c(QLL+N8hqcoBHmOJ})n$Z6b-&P;W183CGjTXWP1gN)F(caZH_e-VwHm5Qe3ydSBW5^4`~u-0YCu<%+vpvX}qE z*EnXwicV8gY@>kTOhV2HFRv(3h(z3o#}N4p{_C~%y|VA+eXpIp`K);oC+y=`uoJGy z;NS>|wn)Mm35hhz5}BMGAy5SUlyHHU8tIz^1(h{AG2F+9jD#Q535foX(1)>%VA&%& zfVhc-dkjQ+G&CLrJ=H@*6d1o4h$~b{zi2pdSs(HVsgY2S850p@3)zJX0&MnV*Pk5v zQzm^Hi`a2Bx47uQ(c1~!G8Ivz;|)#COStnZ{WKV16GHTu-l`*E z!31742fLPU+8k&>W>>exlyhb>zS1+h0k{J&4w~!% z9Z*;86we_ej#YBHTe?oQ0abRiHFviqJSV$alfSb0OF}$}EcS*yy4YiL=Q*r?B)T^u zBR7{S5S0{)73y$MXNbN=pBzFkV|kEi@%R<@`nNVK8yn;9M#|tTr+H(>K8b4unU;;%Imz0B%1T4pdQs>X;i;3q93c8yf{C zVp5hPQ`PvcyZcxm#J(L12Sz3!_n84&e-jC%nIZI@OODb5tM6P=)wcW@va1)DyG zp$bgWhxEZXnhaUSlqnNG1SnHtIC>3z3MM=hnIc#|92yx7T?>r|hCloO(monyJYh)~ z9R*v;N?(U|rG9Iu22F-@;8@VPkV#-^c>;{-hwltvv{{9yE@Q!1EFaV3a+sDNK#kf% zQ$)U=V09gXI3|WJW?4+dN>ON<4;ZxBX(gT7fKOY(7lX0DK=7w;e)Rr000)AGP~D{n zq!28#SZ-(>z&8p>WmGGXNWBxmYahKIrJ)!Li6fyH;0e`_q6H-=cEvXgN{FRu>pb1n z(b3jgf4Z%?^;{q+zQmd}JQ^m;O2uVBwUESKiXss|@z7|5m47A{ z9MR?`H5zg~g6mir%1=pe7-4l%*Izx|daC~PR3r*<*+~79VTNTY!de;+VE@zXbHri+gXnRMv9zEISVEc8Mm_jKE-uG2kzSes5a_q2C) zcE{YJFo}u+aKtD>dT1gvnOH<*&kwaI8x3As=s)A3YSxpuA=yog&j1wSKp`pjr9;ca+@+~W#ke`Ni!aN(E zMIb0JmLLIZ0^mq>%!^Xh zFIZ_bR;|I&VZs}Mv8mB$xE7LDQ6)gOgaWj7iLp%f%{T+36@>&pd?!#f5FEieRLzz) zo|xrtsMQ37bXZMNL65Tc3<8&dn6CwSABnB$m>34N3(6H*4I)rOBOKv|WBI7QF)b^D z=20&sWa9xLI5tcb4qSph2si-kzlOO<_|TkBHl)wBoXDLb2~^j3@EUzL02G7O@g2Tm z18)MxP-HSSE0AOu+7LnP7ENkP>IX1!QCj^}M673;mih>yh&6#6DLPe#FHdm7VHCrY z0uxwFh%(@5sU#91Go{WH71_=4ArT3aAH9$2)&zi~Mb_XnISG(HPiSi*TU4}G^3}_* z8e;XvTE^FT<5#S;L`AUGSi|j9b8SUN6;f-JC_35P-r3XE+1%OEWVATP5%B>CQJ))N*h;~3R`LmaBEB{}Fw-^5Veel{H=2kZ0{{}(=tklgRnzq0%ldQWL^hURrM#>N`nm8XM;|jPT>LuT zBq#|9bU!6=te=<0!#Bopm;J{SgzjUC`7mcMZOUn_dUE7b()FLyxB9Z(Ru z2Nn0AWFKTho3KZrGc)xd^$Z(evbi7`g3=arXDt!EP*0B$w-He_fvq9vNXd8u`Z@3mcoQbO0tl)4 z`(gGl8K}M77TEIK`SVQ?FnFP+3m3LlZ0YZZ;Q@?0n1w}CAjtH3&>Mn-h9FBVf#fkG zp~)zg7H(t#Oi$(?x^~cHx1k$cu)M$?Goy<@5Hd5gg7o9TWYbg;7}@|OTfo%!4s<4i z-hMpXvJ9i=FKi>EzJT8=FLVE=?`t7NDRR4d+WHOKjT-d1^^vs#7$kEPnu73TBAb!v z5i8oTj+wpCBZLDZU!N@hnUN5P7dW>;)Q zZdkJBa7)-nLZM01YUNL@mH0Xm{w4lL2<=0F<+XUuSW(D)&zpVU>XX;iDeLO+Q+D?# z?w*-e2(d_1JlFHmlQ*7}?4`UG8JC0NppGq&Q6~Pgc`}cO$H?N5M!S;OCRL?tUb(K@ zjHvXIp2M^)nx*As0c@DRG9y^QHf3e=>PU|`1+$Hd;Iqy)pAG~kCqqFI1_}5>EP?bD zVA=p6HA40sr1{on;TD(@X%jBye0BgTs%Hl}s~sXC*Mv=kj2uGR;3Ub)k?G(?*402= zG&JHHl*kFvpLk1kfmGEuxmpQ*pz}#tD2o3pQh$Q~kpl>3Ecd-7OWqA}?}oQ*-^p66 z_#N+eyt21m@z&2Az30xkv2X6_kJr~NM&)k%MkK41GRwdsojfB?p?`>_QI4oC%cF zc*uyGTOnft`A$@3(xM7as;yPhf=spj!7#yuB!FHFljkX%O$!37I?j^$f{!=U z%g*hJbNd5})lmuAui~qaTow2DyB)G~r{dg6@5IEZMn)co2E%7lw{5 z>q8=PQY=l@3z;zJvkMzwYhs5HeL%>@JyR$bJh(e?hspy+^c$HmdX`WjZi%1EgWFqBFy@)E@2nJh9%Ye%w)lYTd2kOZ++OwT1@TMt|WP% zUIUwoSyD|yIPq@>T& zZ$MBM6x}>}tLxRSg;u$sS}CZ8?MBut{;4Pwtd*&mGzVBSJMduUWkv{jWa|_qx zbvu-GJ7nKZ#kW)P_5M@w_pbct`VX()?fK(#e{}9=*W|WyO4~VkPp`74S9108K_UIG z3E=cKdm3+uJ6OPd;BA@HFEQ^tp znTC0wV0$maybo!&<$A_+M*8l38wDp>j6oYL`0>v?-$}AjVcB7c*ss{GW}LHJv05$X zELQv@X-9>{I7F2evH7~)NJd4oQG|M3(g$;aTD;<#w!LRJ4`q~8O6oP8^3Gh#Y5P}E z`|I`>>{qN;EWDoIb#x}I;v>gYbf|VOI6oH*1SSNqA&@cNaGq-2gm6IKP=lsCbSli#U?UBR|g|R zl?3Qgg$6q45J{gNsOXKKfa*WuTLx51zVf)Qe4&b?#aTNvpjpNDatr5#`If~kQtnPU zcc+p|YktnA+e6F6>ux>&>hs^Ye)IZ+48*YLWbg^4`XvR)xO7jSe)?&;=w#fdf8i^F zC0GPsww=AOSu@o!u5?~MC*-my#mowEOc;y>`?$lYCkaP@<$pJdqby}z^|xSwD2 zz+uZNMme88$Uru@NA{t~xAK~cE#EC@&d&I;2TwoAwl{m7Kk;VJbFP)1^VoB7_Th~g zKPlgTcwNRHt#ja6e1XP*G{Gjkq))H=l=-T7$tc`2gCRHuowx*Nkb=Z%df{85D%i!P zAL71Wc^VjnjD~!tGv2eBr?$D4U$stS<3+VCw%lScXJ^kKWfyjLjrsCLpAx>fA0+~{Tu_J=$W7VEM7>E*;jO4*6%&;io4yhl^ml{|&^2yZ z6Du|>2F4eqG;OcqYcRqdi@!jfxYf$W2HCe;@$H_q-^<;wV3%_@DY=_wGnd`DbLW)8 z+Bj@9-L=~6sax{zj{A2nuiLDwt7qZv<+bGxoR-|iPb~J_ykAoANk%43Bm^*!$nk>j z_7~0O@THlULz9&>fiI(%j^V*9SHv=nO0T?9R~vwRS~}3ixFOd67#~L7GE_))F!QC! z1(o>H^tg}S)KDh|@Hvr%frWyl@?G)rUAK?PU5hqSFysBN{8G98l4r_bcVSWqJ26;)u6RSA)wVRQ?KcCLc*RcEJXy$wCKR-B)Pw>-e6UV1qzm5RRcq?gPs^=pkG}V%U5|aV@gPI zK1M~VJxMl=GsJA=@$__cb@Ux>?<9UBk>A?he6$m$*zGOd8UsR$*dR3b3HvjBT_;2t zdGXomr`gIrH%?S-wW2Z;*^rH6gEd)1C9;yw1kAKxa+)(sZ$YfUFnRUZMw+*Oice-N zf0Jp+@!dH3O2^9`Z(#1$EDEw`m*UyA?D40njlMeiR-;mJ;7(L7YE_C_ zWlx*pX-i2VRqVY}DHlDd6g??>4k?~P51jU*?Ah!G*_P5u=rXg)Suop9SmE0Inzs&r z=cw$fQG7K-UzY#Vyx%MS(fS{*zndlRIIiqC&LWqK1Cn3;`}Cf-@Ub^(QoM&G*CD zMeHUKBZn*o8GnXaoA9%b+47T=H^LEgsbP#$-W{fq=IR2CRmCXR$-a8UR}TzbyyoVW zTQ9u&!h#?dZ&Ql5Ng!CFv(eiHl502X&PP6Oodlf*w@Wg0ErA`w#W(Qqb?n9ZTp|D+ zo15qF%hsrlZ7h~q>ku~8m=)AXfk3Kq%$yTjElm|A6pU`LJ|s%TpsKFKqK_Eu`YbWo zOBg{jB2*RHo05hA2H_1?tzHv(hHE_G`qBOz?7v_YTX#_v(P zd`kb>r)?Suu`1zY>=&JgFt_(QHikfeWh_yzReBNH3SuT;>VJ=x)hhi@JiQ5yQ2J_P zsmHI9*D8#hsXlrim`jDw&{PWu+c0WLRohps)%Mu(=@69kP@9n~K=X2-PjR-}9`qGB zIKJ!Hur=5qSLpAj&1I1~*qxAX12RABhsi)hb*aF&`GY{CE#0(H9u^0q*P0+^U>n23 zp@=S3P;-FUA8jZKw6{X%iaj(;$iUP`12BDOyUZbc5Cddhtl2X_Z8WKFIc8+iU5pan zj|uw_$k(fV23n3SyMK-p2|ILpC&V96;-qMyg!5Dyx;)cK_SMaYJKDI2@UQ5@!X(;k zd#0IGf3&J6O3j~Yuev%r&S|WgmaRqs{w1;lre>_#FQ_8-D1WEg7i_m~s>spH49xU# ziWwO=^Q#kpfk+zyCcW?!kX-L6Ym#rfDEqc3zAZ$I?fCxT_u6k?{Hg6Dr(APPsX4|X zIRWwISHHY)RW9DH6mOUO+g1K<-%{zJcR+f)-A2s z8DFzgUelngX-K6Gue?G$V_5c8DZVO32!8+cd*^On`{~Ayw#c=|mD)7a;c{Nlt?E~+ z-#DU_?UnQPDS7*5oy)oTvzesc-t)@Amk-Ww;XBokVOEFrUg27$uyLvIK)mq4-91v_ z0lDymQg~wa2*}KPo;<}m&3mOS;c=NZNG%(5r% z6Gw(G`NI?Y!q{rhF z#P8D+N!Gbg?oa6HPbp9+AR8+2-%>zRL;Oz^v{A60f^`&pPYa-AutX{tK+XUt>EE)w zU9z`7uv;BlF?6QDWpxnuUV0h&B$WFZ*t^K~Ka#Jckn=ri@hIUwA@f4gjBZDLIjG02vMV;6si)*!kgKjEn|yg>6D9?s-CuP=7#{JMa5!jhw#eymvA z>N-W5APl^*+Y~$Thk`Jq?}IZ9X0uR0zCUP7cuj!3ez48q7ZRDQya>0~(dW=3_ZGcM zTTDV)og(ePpxv^Bp|~4{N=vX_cYrF%gnG*gH^$`l|Itv8+#GN-dITk&+ks5zO=HiM)2 z-9lRWSa6!ZG5?Bzp1tltdo%Tl8yEb|Gtx-Wnl0`8@3zVSOc5P21C}U(Kb%4T@dN`|SK~S4}%a z*R*Fka|C!Aa|BB0>yBx^;8xA!cTML;b_gECc{mIsUvEmjW*-IwP_> z3P`_k1+@@ZEf~TRpPP2h*ifPqaZa@+Jy3(E=Ym|rE0o^TerWYv$^HXmt~JN3Rzveh zQ$Mbja+S8+Lyr}I;jccmLEHjoH5u>am}z&QL2u^~<_&_;xZ-bNm=f07N7klQif-jn}+}~0?n@NFVxv+FLd%381*1cS@1^Y7! zi)XX%=NI34^3^Bh{7NMs!p<#OHJ?y0+p?TrbgSvrrf=-Oxqr5Gxp?iZ*sHN`Oy8WI zJ+_>`7F0^kn)$0s8+OJw?7Y28-mpj6u!lw7*}L>aXZ#7W<9(uAd7_&|F8fQCeR=bh zvtOS5GM?`h!ZPq1=Wd>RD}SNqo%7#5e>-2^v|HJvt`$+c=kf-U3{n`0v@7VAjC3;@<%nvR!&R>xI+hCF) z`M3SKzl4?G%2_u*wD5d<{Z489PTED2U%4FEJlDRQzh?ei9GmU)H!l~IE){Hw7i?O% zbbF&*uv;nEEfwruE-qczyjTi#u2f8a%O&gQV@k=^rIH=-k{!1z9dZnOVDyaYSg0lO(?KSBu_;+aOY-HZPa8~lwaCx}os$ry^Nq9v| z7ZxV#!}suzqR)k#G_4g7{8*(Y1*UDEXPJS9B}FLktJn*P{|ps|p7HtJpdv0&0 z`L^%Xee-AEJpZlp-@Neph1&&l`{evaCBG4tFiVZ@8wsSDv(hQ|g&T77_QshFZ246>=A1DYH5w zcn%}jfsthLq_9S&t8yfA_eRbILvUBZy`hW+vE7~d44fLru4j@&2?oJZ3j{^*62mM{ zOqM3tP#+A8g(KJ$4QGK`F)-1GQwRvMXgIj(u`XGH1PD7-VSrrKG!}|pBm23+ZMQ%GYD8kVx%1zqJbc$AVFGNL*!DiyBVwz$f0zeYkXUPQ`Gjs!w z&!%1py(EB4xU|kqv4HW>tm{j5S8R=WLKfAJfok`K%67tJw3GKa4gmRRPCcV@hA-I?+G1#)aDA@%WCuNrK`skVRn;V@y+l@As2jX_lBE~h>Ov+aa9KniaYqa=2t?0N`et+u zqa{-fm&LV!lvrL8kTlr)-w1*N_>Ao+@)z8=wqVETO1^#Hw=ed*cV4dAr&R5eT>JRi zh}8N;&kTtc;K4jLRnn<|i+Dq7bfacWQ-q_M$dzB|N5Ra` ze^C`Vf$J`!FavlnsK{N{t)%w8Y*AI+S-PrQ8_-|F2ciX)W8MYRXt%49y~OxA-brb6 z+7k8K7G$UCwDVe|yq1MCa}D#m=MLa8<&X6z_Ks%+$FFga%4ED>1X6AWRn*md`1(1g z#~Dp^^bB*yP_O@!D$zidAmGX~7P%>vonNU6O6<2UyHRemtyrl}=19lRVV=w} zMcS&vrYh(K)|E8C8tP%wkdL+TqT6t}~vbLV|C{Q;cm_4yvLXHq}@DstDeL25)ehPdWv2H4_RlvDYsbKS` zWLUlh#!UXgxic?K-p$Gdl6%=ke~4 z+%5arm?3tsC5U~$Z1*>F1PPJ=yRO-^0l~s3g|J3g*H8>OuA(h^%*%k;hX8drLo7$| zU_dNu39&37ZsnBdaC=3COBO3;(X%NuXBrLM#z+p%z0#Q>M z6!dRsFbHjED5#)QD(IXSZVO9|N8^o0e`c2(Pb!Tk5jod`AmvZRNPLm=Qk9;hKtRB@ zZ>eY~$5f42@hbJZN0q6f%7Eu3E0|<)Zw-BC6u;$yP6eT_Q}K05u1*doMzoNLuNk-x z;Q`=|P`i@r39LCPfM5tQ>EABktCbgRIWn*Gmn|^o)NC;-nGz-=wyg(@76gKHD1*Sa z-eTh>yo{4OwE(%MWn5n;oL<(kWrWnG$?jrHZv`J*9gmAN!e%)EmlRT88YdRR47M`}*aIs)gNm3Z#mIa>YTV;@}J% zR37Aivi%?=m6^E@Y&#v`JWT=lm&|?fJb!i3%2L@JkP>Nuo`51;?7fe2F$H=eimiml zO7d6PL|!a;K<`yAmQ4@*RtE{IOaXM$8!65hAZ`c683V)-yK4#%m!gqI0>oryIsoeC z0ACl@C!E;ko&_OE?fsj8Cnnt#1js-q>4`?cfuzD z+zpV;;5h}@4iW%T@Cp7$4&Vbbu;t9O&$NG~-QeMLWsK?tm3*P{exdvlc5?rk$kJCy zli1IRg3v|Qhc^+X(jaE~0{cqT&crunKL>jJf}P%_UelITQ!{hUw6DbEH-Y9lyHxer znpI2A)Kad<(RPX2#;(Z8Rlj#>Y0}r+xF;ahEeB-R+1l5}``h@Dv`=`vf26NkA6Eh> zY}+b(4pM}pkwd>~3!zy2dua!9(G3oby^+cTKn{{|-9BxP97q{+-NaDc#JD=NdA0o0 zj~UdEDaHV)upv0oe;FmqBlTJ_+8JhI14?DPSTgMpGSlakx@S0s_FZ>G%eDM5gJ_jg zFN}g+a85hkbG;4Ne^-ZpLO7k`HlzX{$=1~tOlc;~hg^UnV!lD<%oQ$N0#(oNxL(ct z_Xd7Ga})jw;dwEz1#h-64eNIu;*<3CAqvLvt=LRYgrutT*pUtf+rl+$2T1ejqisEX z>Io6BBy8?zZxy%Gr~L>L#zd;uY9v0}-qOZaU(Lux6S_7{k(b67XA=26Qcgdqa#;*6oEPj!Dy z#F^NE&mQ4_gkS_*kGoVm41$)YlU8-FN8?h_fq2n@yGNv=19H&`rRaq0?NGcOlB;9c z>Hdw&if?1wxlwX%)Y8k|J&Jdaw5uub+>~?n=d7DY>^U z+L+%Y%JH~$uJw&0uXn`@wn+usw1%o3)4;MjrW75Mz3qyZoDD2{)-8D|Vgb}E6}d&4R&Bd(t-e~@qe(-R6t9h`O54;{M2Vr&YGhl`(Yh8#_tcu2^B@tF z;82N#+Ev>?@=tcH)`s^|EU(O^V3B@I--$(nbG3Z2_?gCWyijm`-n6jBe_W|hsbdFM z)?=0w%d~@*mG`n&EGh0z@xP%z#Q%Z->k8xTn7IvqPB@rMfSK(;v_Y?9#fHU%tuq0( z>{Q2!hQ+B#zQ`Ekxw+2~AXO|aq-o)-7gGk(Kqr{(dJkV;_>MDeWnjxoE7h-@z8j#Y zehLmDFfA$kyUwm2zC0YGL`?QYydE6I(jG=*32i4&^_=4iNH$wQSO!=@SPvvukY*~E z77xzor>-D5+6ux1@M1Sodoj$BaXdhrs3LraBYWZ8(xxZko1VDSCT}{dY&uL_Otxm$ zy=}>}Gw#{BT(owf<(*^SKDKynsrGQZ_OM*rs?@g1m2FC8n^f8HvwSQEIlDfA=#hL^ z4b0vP*d(XK|9=e115AE0v^c1ylS$w_}dzH)c!4_^4-1w5vp>b8%c>XV-e zDo+I&&K^*T24wG`;vJM+gZDhXxvVAs#<+ju!fx4Lt@x{D&sN2=l@K=04gG3tCZ=Y$ zYpJLyUet8AP%3JYi;gNqM`iCZ#d}P09lLk1?QZA~#y=PbhR6cE0T!oi{tN_{{E}-AxM+Ed{Z~ z!rMJ}3x5{U9r{W39=;Ty@AXSx3R*E3q=8E>X43fzozen5re^|_s&h1{r~vLZ^yL8b zpEdn-HY0e!`kCeF=zL{)pQi-43DJ=pT0+xIy#$*83SY3VVt$xn&AO6MC!_Y$e=vzTk#Lk6o*`W~getCx&} zcnqw$xEVB(PQMsimnLVDWU5#CoTCNWdy8ci#yN%R6StdNgLBR7#&gZg(@|VwZe42X zbgf;LG1uAU1)cRx)}APz)a?)^5s6MMrT-disR1D*^F?nPkV7)w3`OQv1NNL?OfjF{ z#eaU8=^E_O%ZmLC4)(-ppK#{OeLcfN4|6~Ywbw)-2rrg6I1t_fVBNuvN>c5on&E%| zM+1||$X=Y}JB;0Sg7J{u8hqMMr~K~c(FgnKaL`DUYz_id5anZwa+uCGWoPyVkeaQ^ z{5)Jr#Kimk{3zZ!>>b2ut0eh{VFf#ZmTwz`W`K@E4r+&!GMf};1jP?1H68g1-_#Lo z+Tg|}oX}y&aUZ&dGwVt6O5B1jtIicOa5He%+`j^+O*pnAbj5jaVo41o6Ok8U6SC&# z=j9ow8Y9J&V19;FB+6LqK1SViv#sBZlmhl7ySF3?HDgb&Y5=1mQSmvX-AV|QP%g8D z$;q8PK6iM@y)N!vC%ZQ&?hQI~Sg+#8TG?BtcL;dWf+R zdCyli-?PxLh&*>GzFm@Q*FC5A>({?}eXd7#mMYFtO10;WFE2)KH{U*d`|x{LZz1pXV++D!yX5{7|?y6dhO0Fu|)u6ZL0D;P7DmL3@|+|q;Y6Kgh>YRw9nyTHLdDPtC-cAXY=RKvL1dKN~#D> znP$q!6^d0pJ7A236GzlNuS~!VBQ9uM(`Z_W>)l1Q@5h5d(^^G7uZ=b((7OVi>L=9r%}JQ66ha9%{29^|AOSd zaHnCe@{Nr7_NB7Acp3RAE^AQA8sw7QO37|1w-G5){@AdC$@&^{VyY%^RIEzWY@-fV zuZuWziu;0Ozrbk<2RjQq z;o_>#;3`UyXHkM#De{@;6K290FP&h>RU(;i$xFZZIfYqp%$tp0>|8>@1L!gUl# zzZ+X9Vu$GXLCqloxzE)PBxh=Tebu-KyFM%#!{jS@fX<1B0+&3t@goSi4D&arJI_-< zB&nE3!65pL>+pV#p9$yGIPK`b@!76a1xy7cA2xqDtba}NB@QV=6h}cP4aWfDXAJ-D z+mB23iv4xLmj zuQfl}%(Z_I0LIUa9|UPJPDjbJ>;^)J4+ZGFBIe?cj7^b%%GR?5jKT<8_2Ykpnc#|7 z(d}Z4g69z=ve_}3eOKUF9Vc!By4i>^HTxs)7GpndNpE1+fm{f!Ysa5}uS5*LQV}*; zT!}eGLh>aX!X5@8{T=IX!jaEQFsO%FBf-CZW#S3+vh^W}!|8BxB)42BgqImZs+C@3 zevk(XO3QEAZWJL)E1hrZ#B_wKcTq#Cq?%l5-GcK2NHveX#rXl7jqR^vqh~;{db%)8 zs;$TlI8Msz5^C| zOmP(SQhl%Er4b7~usi5{atgc_oPaR*G{{~oq)7#evp)Q^)}{ z2d6Vl?~y;hRCf1{>8)NTY~Q3!s7}F3BVLu7rxlzm6 zHSILT>-9uET+t#e71~9vyGX~0dc3RG#Y`Y!>*wdGRY@{e!C@+0XGu-5dhbNbl3zv% zQq^n722navB{i6-^&0=j)0yHoQ$|uBGj&W}GL@<`tP2EhE3WCx_fj@p{{xPl>6ofZ z`lx>1noT>WGk?YLv|R!^=U22!d$MRJfn7@JQC)SQ_Gs_tf(N}lFp9}Cje!n-rr2Vw z9;>w9c1edYs3CyC-o<}@32wpjnJ{d*q$`)iFGY82-=cOoIK0S_x=xx)h&=PT+N{?Q zxq3~lqlJZCrdS>FS8225(mL(C_k89Wz2|=$v-(|qW}DyAoW-J>8=L}?BdbNmS3zDv z2lJ4_NF&7@(RbT0SI9}J3wA*>S~;6hJEx~W=me@D59Pd_NJfylvkBu-N7YU2gFs*_ zr-k$*?zqjQy0?cYE%jWP{{G}yccAUIZUv3Eb!#9Ph=j1?2S{GcFMyJ@l>hG`7s})3(*>Q!n!^jOfwJwS< zPtl~)3XniZuNQa>_-VR=vwd-#JDroGa~qnF2(%~Bve75dEczaYI-*no8LM08^t{;4 zN{Fr|(A~5bs*3u_*uS*tk6wYZ89`~>e{~=PsZ&T)MOW1KL9_v82~&en;(n4%)cc?M z7l0ttBsnRK#WQvlBTsPzDGvToPnRK=*Lp67Q3P!C_p7q3Zvt`?e#2m}$})NcS~6xl zl4QJ+JQq=83FE1VT8*#=Fx^L#9&9k#Xe6?!U*YH!osw{KqMH4{d!~jX$r_%CI5N?< zv}Eief}sWv`hkBwc2FW>_$8?5QI!_>`4%kk8Zzq?Zy*gA^IyY_QEtY~HC#qeX`FF= zU=PL(WUS`>@JB?H5f!T*^H+62s=DyA=wiWpCDQKW@#^FFnexXds6>Hbr2F(9vvPYJ zd%BLRUeC>TNcJlRDPSl5_)eYfYCY3JC-X8zXd<6vgh$Ecc=Anx2@Y8#3yyR&ALUys zhR_>{?CzenQ}FnDqOFrl5>r0!YwkXgD5j%@=(r)Cc}rL4k@nLk+gcMj?Ca)JC=Mn< ziLx~1_vvq0F3F1Zb)9WHeY(B1EwPrehpMORX+yo?tqezI^cdR2D!^*WbV7#eCf`tX zc@I8~)`{<+ueiopZF?K6nphUBT}=O!`i6sFFZJ>^>LJ#tt!>>sr>U#a2i<25bIp}8 zl5*iA=;H3OB2+%{Tzu%JC@Z*~z}aRAZ)j);d?qG3JCl?NF(RZ338)u0Sjt@ih2k7? z86&v+O#~3lR$A6=fQGhkjZ|1QfA;nBGoAOHp4W<$+>LJ?Q7ZRJ{(X{jpX}VX>|XQ6 zS!La>xO2M$Pq1K4&`ybf(zD%uk-+H>bQsc4T})S(o0e1S$*@{xjI#C&TD@Fa^@W>UWN~?4eEzkIap!u;xqe~y_m3zwPsS^rlq#Nt>C-Eo zmp%791@{U!EEkmDL*0uT@0F}qN_H)kG{sAr?iPJiCY3bFC7nu1CoBb)OSYhgVFO@6 zfF34mfUGQ710X;TBS3MvOt9{DWWUv|;+?0T#e-aRK|#3bg0ktt0}Ecmiag7!S776E z!B$p*9i|Gb`Dg>Hz*9=eQxCEnR5XHLQt(NJlZr<0fCYYwCwHcU;kH=$bLX3DdxN#H z?X~+ZBp#*m|IDTjirH-CdOluMDQ%F%@Gv8Al5}2fy zE5D8Glg7DZKLr0prAE!#00G;Z`N?g6KlGbIUF<5bZj=r*f zKC@%ysmVKSg>HPcano7e72`o0x1F6_(tH?|WCpp9tOtGp5QTFhoNy1Ct=bE`PG?y% zU0)*e6#TBAZevP2gW}IeOXH~%bQFTYnAX8o1)Lup61j6Dua>NXyd3wig*w5MhV<>} zwx`atpKfafc{N8hu;a**2=*LYoEjg23nZKf?u58A0#;y!Lr%6C9}bBIM}iQPi3AXT zf>uN-@BkrLQ3*79c|~(u=Ql3pR>X5F7Fy)oDkT@rFS8tLKA`}6CB6BtjJ!PZ(%6l$ z*$g-W!s3xtxC|`jD`dy0VGn9R@dYTlRXby0`%>wicN&Cb183cJP*z^o0PmJh~v2Lh8FAm zmN#p^Rr}5Q*Xx%`8{(x6x1W(q4=SYxso)l^V7zOLmp0z%mP#At(q^T!S~qs{xYL6N`n@7CFDEN%HBGSk9@$9Oq$o zbU~!qXzF_6tr=Tz2oE}2um(pKTNV(r@=8@Ss;ojOXBA?OjVNbhr&y0KHD=+rG5jl; zh0WTe>Vx_KdQ_TzY1oB-N3*RCxvA{J#-q~4qjz>K?0M(lw-3ULQw7d9Xhc-X9|Js8 zU^~8K8h!9Y1cpRjS^}I<%uiDT@jFOxkILMEgdi0?7EeBNLR;}>)wim6DR(Pzmp2|& z5SAWQN{^--6HaAl7tyC|eGVY@3t1+S<`)&wuB7-DTgb!4P2r|gG@$HCZw9yW@D0<~ z$+cJ)U26H!UC2nkMw{}`>ujELD>j)R-Me~PveCw@bf~Ka?ciWWAyBB$&h&{ZB-DXo zG6TJ+`n~UNJ54($I0w|x4y;dtPtJE}?~JPkUk|i5$J#r4IAHJtl$n=*Ba|j`IPQ;x zL~>d!eh(%8CF&a?eHKu^$DhWy!|8qv9;>!VPWpq12XjcgdC5~7_tf5Ql{~ewXP@HP z$D%8iJX_`zhZ2w6Yt1innq<#VJmTL4SJu8cO|LYN{QEL6Jv=j4(7Rh~>2Yp^ z4unSH@bTz9a$?o8bwVjUk@yk}*wWj2rmu&&1u!`?fwB4RSFJ+kOBqA9VJmIXUY#g6 z6NzFqh;ajfjFDC^?0B*+Xg^GMa}F!rZcdYjU2HNZg}@_gy=e)cL1Rn6F+3P-Ps$#* z%U`gNGRNFpbtzb_t%9ZGDK47rm-{Gpw2pWplX`+;FmN7 zNwTe(nRw4i-m|yEvzM2AfjG_}TeH~xI~!!*4#l?vFH-(EJwnF(SQ|FsMmwa%NW#qz z3&MhqbAYuUG`p(Z9OY0W3}h)2ZgjVtZa>x2*L=9UtK$sB7)m%tsS#ag zdb%OCNv=qYNQD-Zrn2}hIx7KZmZ%Kx8JaQ>YaHTBmB-jC;vFP@K>hIlBGIS7Mc%yG zOLGHDIUC|R8|0jFC8vDm2+pfpa+k&3W%J#9;}el-J5{r@?Xq`=;@u&+cHDD&jeYdf zMy2HpB4qDb1)=+_;yz2}Sh^X$YMXw$@ovT+yZ*@ae-tV0=jB7sDumTCbi{Q1%5~F>9k)d79mYqbU3pyr4 zar$MReZYeTtY*qt$86?_!xulX3K`nwQH2i1b3^lfJ92Y6Rn^Rn5&I2|9kPX@3Yn`tJltCy@+r1cSxixzkIY^>NR7 z*%MGa0m;+)R?GJ@m8urGvQ?>U#rV1XuN-*!z)Mfwc#_6#y9!Zt{8W0$RlmweQQO%_ zk-g>@87b=bh;0})8|Z(0yYe#kKTMPjqm&#?BO{wi#SYr8+D2I3qTlqjzM}EBk=Ajm z)qI4Dv8>NnYfL#4|d-n;*rcZbFoow&qoA8Zs&ChVi;{10@j|Nv9ef=2un4rqb zp^6oX^2C3E1P-)N+f1+$uoxf_0Oc+Fa^Z~E3{_z(om;l&$Gh%K{Xx?QO;W`L{KZ*F z7)O2N(JBDeEEVmF7w!6Srn0x=oF& z#Rppq(qof2MBCG3R0$66R>+brSdF zYPhGP>u6s`duLm>$UumEHW)Aud!s|h3gGZ@;2514?jw#gBK}K!?j`6rk5X4bJIx&J z9Fi-WmCELoXy=W=g-zeQ^!la69p4(CAOE~a2)K?Tp+k81A_GiFNQ5CLpLF9^LPuKX zB}#_Ep;2KXLYyoV6d|Ec6(@by%IDpRE-B(&lA2!pQ}j$B^$hW?X?q4e(XdZ)H%mEN z77KBtiR9jnzdNF2Z|1}e^(i}<9>TH6BX9D!17ZtIT!Fs#>kvw$&M@xiQtco(-Zk0t zsBd-&Ruj#w!xE}$savJ!b}$vj z?Z~UvKKt?cv)Y$xrRjNZ7Ti(Yw&O8S;eLGXdhHE%Q)>TI82v-6T6@pq^T&~F70J2_ zISU4>o@9LL_9`>1VndHNcId3SWrxWCxh=wk2&-RM_R?frX?cQoT#^_dw>WhPUy3kN zJdO%51fB4xa)>@M$q-LaJaJIsKfx#`3b;W>AEWAE7*7Td5s`>Gv6F%>3Qi%Iszq=D za&8=$3X76p7<+0E;m{~+7#Hm2^!w#Fk;ZYZCz6S%LltFtXZ z%5(a0s=4Jv^HD4aBrs!gaj}A`;#J?Pauy~wN#tmejALT_OTweZF?yC!qI%wpHaXSO z!^nt)Urm@?gJ}KG3xYPh8=8EEI@y}UNEEPlga}3E5|XgKX5H%N#@6twD+bv~oFchu zDIZf#uwdA6M@AvG?wkKYsp?o|n2WNPR*1 z=zwx`K;Ap3>>ZRV1*KAu{2|FVB)Nu`@#XrsbA#mEu$;T$t#yl6e(?PFp1-s2?ton1 zuGF{7<;RusgreK9Pzki#yjz&UMSVTfV>c_R~K)|HJcli+{FJ-hEQpeNwLORH{2Af0yJt zCAm&faQP%KCk>uSxMWNv z|6t^MBX_pl73I3)O5JgJ{Rw6L3CZ6f`A$l%lZM9L^KDuferM#{Bht=Rv~_d4vKgC( zjw`<7lIu7&4M};;OL@oQdB=WM#19sev!7D3pOWlP@fEC%2CjsSvF?P4apsTwU6XWd zMR4>y_bGKDk)0gKM{9HqL#i+=;UE*J$fF|td$Ow4EccNMMCXGeedPFQ(4g+J(3%Fw zus%Ejy8P>wuY0jdIwy0CCgEn@gDfd$ewFQ{w&X79m%V~-)(bm#n~?dU=X#dl6mXCb zwl3+s0cxdRFL}+FNXVMDCsnG$nK&-U0lQ#((!0}jH%^MS>lql_VW?R3_ov;FovU)k zdjF)CQ5j25wP7^Z6kKmMSv985$7sPj?KZEL_$p_WuMF0S=F*1k%%f2DwA<8nqI=n; zw!LxMc}b@R#Cka8&obKUn)4JluokRV7sGmG{%+XD>oR8@-=8uTB*He-2lm+8aEz1B zxI&>fFgKE``(w5*>noZ0i_hr!%~~C>W~cPLR&(@{zI9mbrLD=Y%%y!sFRl8WRu&tB znqM*ZRln16{|tB-{htXBC`-2xR(qBB0QLGpy}Igmdb=~>IyCSsmd5Lz>p72`i8z)i z<(MKZj~nIFE;tOzUP#(iXqClXNWo07*Ubjg(TLv%d89cKkkxR^HX1G|`jzd!;u?kMor0gF{%FZd#Q6g|?m`Ggg z%U|-9$9?4sRg17aF#21M`BO(EW~&XyoVB&wV`CdI4`MF06!Nxdo3g3B!{yH2&8 zZe~`Tx@DzEG#uY{_BDDUdr$EY1;^;;Tl935f?vbWL;)F4a(l_XWEP~~6TeQ$od|Hg zERiDOzoy^?dX-|Kc%NSUoPvK(!T*hbDdsMVuh8!U^vNa)XbhQa`F?^n{Zb|Tg=q&- z{A=W~lg5s0XFkQ?JqW-iQ1Vo~_Ut#FyZIcX0nVJ67gX^_z3kntc*%MGvabYZ2Fx6z z!#9n7NFi@q@e^ikYe^_Sj$p}E8h4e>@0DGZimP(jl{?pXb3g3ZJef1CP=yzjnhYl4 zMMt9$eQ?=k%{r@Dku)upHpfew?_U0BT7m^hX^&FcBj=q_^3F(}Gbm@SVD8#d@s@b; zmXE8N?sUqP4<{~9LnCO zV3)aEP%_v0@tW;&!49Qh2fX5V8t!}ib3OA-OKWT5YimA+d!V&#%Gx%m;E3!wGTZuh z%URynHY<6X<5`>Ktj){3^84Rsern5p^)%cjIqwN2?+MBCL`uE)-l>y| z+m+&W$$wmS9iO#Bh6yKd^zBjWV!>j;$8~LT^%14|2#{;yjcxQX^#N89tWq-kZ!F+6K z!}j=w?H}(rCU0n0HndA6$7TODIa&p8%`=4 zPD&-6vcGfAe!sXJ$>?ZQ1TVQ~?Xw|MPc7OPL;NTi$-f((i;%$n|26k5z;PUBdOQ07 zW`TX+wFDLsV=E}f6wmh&g?EF z-=!)UEPu}Qbocah_x%0${|u#PQ}$wHWGY4N5?>_qMIv8>eu&7p$ptIo%~8ygaQejX zF!2r%+0GvbUV{xOg^eCc!zUFzh&slH!u@79J{g^7R$bukzk_F1?$bQ8f<*{5i5c#) z8TNPZ;yjaqEWS`ajU>X;$4ChZ zX5TO|QG46N7U42xjd4s(J?^QjR9uQwj`%##hbpgPdVOItWt`58yE|>Itc(oUG+$Y% z4B2!V5;17Pf5G^`I)W&(*lHl0B4o)BJj1%_!mlZT*cHNG)7b_A^7X5r+5e8S0Bz4A zcvM*rBS^&kp^ z&300_C*JL4jiIt$PC&wX@{Wrcl1iIH_l?ke#0)|37dBqTu$eS)qB3i050hEPjRrM| zMnf5j9A!P5oR}C>$KiJvhcD7NK-~c2Kq@qcUTZ-t#HVgQC1t`f_-8>2iQ#$(3$VV( zL;chjcae~IXq1%H+sPwwz*Ro#itVwqSF@be5U+scGrnwy-!I4Z^7?UjycWx>aXcHL z;o|t+2-9Zf3ni05B+c! zisn2ZI}dDFDBYI5VTxvoWsjZ3*jk#tp$~@j4Q+wdzHyMK$p;sy@ou#usx?k>--2)Q zSO1Hmz_y}Ttz=m5Y<0&W09*g`wMST@aW`zCIj1-sP|<}geozDhh~2MH+h|b1 zlFixjdiht&-)ws0)SZrRbcp30(zZ@{Tc_f1VD2<|?az6Xg%EYoQFv6mTK%tuB{PHtA(Y05N`rc>%4#peA>P%1OrMZJlKKIWwG6oey**^mOAYP2cwm@#U^&W2R}dbNmAcZHUJ zS8cua9zq*=Q?|*Js!iHDv{$I{%M2YFlIy7c310HM>Wk`8CRw%!H&eDKk^D;4pE}RD zw2We`PQ!(JsuHP2!2Fp;;IXdTwWu_qzdBO3>9fCYB4KGru4t+6{|kHJvLaQV@F`}1 zu3ZUlv@d0ix{?@|RL{1ic(x(AnyB`@v@OzJJ?5DiDW4ff45*V==QmpW6zvr~6H7*B zS8yAOw={N~YV2t2^^{F{r&iw?yXN&k0*HX5tM8P5k_Tk`Vom+A{Rd_};Or<)N%7YGA`}PbIl0RU3J?Nw$NjjQWm)aW7;c7aKb;Rtny*1Jc_2{kH>FmA7m2L1gQXE*A$Oj@88$`D_>ZEake19i9v@DdfPDyb zLa95Hux)8LLsM`lgtlGNB|_MB{kHYoi*j21qy?moKaKDyi@xf^A7qhxE6 zZDdnKW<_k6$cyZuHAFw(Q77-H!w^aKZrR=qJyzOKnJt4{R6(>?H9v}^h_2ldzencx zi2RQh_~W7beL3Uq| zY!_wQMUnj?9k-Apne$|Go@maCrfikW+hy~1(Y!sB13-CYz6m=40xxmab>-Tg%>euYDb+vK3EC6^(L5qm=k^{>~@)~suGeiE#b0%Aw zlIKh$E>b-`aapEifR9@SJf~V$URhmw|C^rEjXmvW+C44(z5Sj2f)T?jm)pxL@jIX}qfm05s ztBm@mA-1?RY5Nvw(i0%3^Lc%01pG zVfCFMuV>0Tjb#gU?1|mo6RDb>6-(9nMZq%#|LEk1FoLHXp;z)wjeAGy;ecc)P`$r~ z#eccH@-E&7{6_e@LtQ8`;_=cN`N9OuR7XZ>nXJOr$AR*3h@ByOEq2GYhcLNdp>8BR z9x-%?pllcO;X#t8K`=N06@+g^?jK^{1HVKGQvTZqm3sKe{{IM7D+@^?J`}TGl*}`- zc}6tPsAy?YG6!XIP&5bCqz=j4DVsY*b7ver!rQ6`xE+e@0IQE#={N+KCw_ugrU*&lQQMOM7 zy@JnkC3vac6I^*4a>CI`Hjlkmuzq6l(THQE^^oV}OWvuG68Oip8jck z!I!ge+O^t9cOsr^2(2(XEqLXvuF!z z@D}tz)>3UlN^OU#Jg58uY8XX1FC1P%RSphLeHgRVcc#X?a8b?tW_qI3=?D$w6mS!| zU}(g1=1dzEq+wP&x~8_mQ*#LasKZRMCn%vAfoG zo^OG;WO&AsT#d-5TRtJmNlWe@-^U{_#v#V%@L5UBt{lmIEogU)tZrMhMEw6goeQ`j zBZrv))*$}0&*Z=sy!FRcb|>-Pc;-&SenY@~!+6s)V7S2%Lz%-LV>ZXB?W)XX%!eBW zB`>q(L;M?}1*W%LJnwmK^1@#1dz6#wa3;!FS z+@UZGzfTDg0b=jL3n^^r@i8eoq`Q>+=a750Z}$oWG8|zFvU***!c?-ibkXl@effqR$axPLC$N!?HjMZ^wpQ{`lS4Q za{fNX9AtdKM~F#CGUM1P9WP23?ds?fO;L4{6E=cJT0P@#@;ydyY_nwxvRm}Laj7uZY0gDD@&K) zoW`CIJNx+K-#R{(<@#Z!eZDr(j6r-*l+#jdcu>>Cx8#~u>TI}JwQx9JO=su1=9c{Q z)qE4B7jksIm7N!7v{af_D>y(6r$R-aWS^Q^TBQ3(5l%$0;+;E{%2f`KB~A5&BNdZ& zlB}{L$5j>0YSGl=uCSwkRj;NN6%_+zHq4qd2o`Q~v`B-ZN-|qx^h4qSQ~%^|gAiHT zdbDi>8}@k09!lI3rEojsOWYHY1#?*T%|& zyYXV6ND^K(rT{FJO3Sp7`VzF^!3 zr`Qvt!SNs{giF{j`MjPhpde|_gxdh#QP6*&6BN2i*~k#t0IRE?<0%Tyh;1Vneq@`B z4{;;xYHsc7>up4spRSgkZn!NNJl#5QhT$zZv|-9gtOVO^Fnq{xAI&JdQj|@!B}#~Z zK4!Xd5jrFrqx^G1e?LKfhW^$wi`O!X6`Ns*WbJjL?Fi`^WzNm043~y=SIe5K<$m8k zA9!!zr`P3fpVaD?Tm6!2SauCd{AHPE5fYtw^ESA-k)6PwCJQ{x0L+^nWr4?Z&m?74 z$XONekCT~2+?wMmtEPP2U9;w{S;}4VOYUQ``NR891}5Hkjw$u91zWc z*xn-SjA@X=SI~r@5u&T8;Adu)`2p39tLO>k5E@g8%Ts;qT+HAUtO3-UcxX|}<3FbH zwv;WraJR%$-2ST>O zt3I3$W4dW*Jd2M{=AL#oEjg`|lAg(d@LW}SHW)z9z)=)jW2X%m#?4WR2JV#mQ}(Gf zInvV@VdtpjZIkh?& zMk3=50?_(8>RNarJp|9^a0ZR@Wa9;3LErOiuo4ohWr*Yp-WuSDVSP4B>>aK8KW1&>CO5g&~* zNGk~C>L0+;8q0`{{K}7}SKhq_E(oX+>|&6dF7@Q571jPXhqwDLJ`G=f_|}} zf62VK_3otP-iLGDPmu&^_c5ARBSg;RTgPz{k#7|$FLvGV=kTl{A2xoTxTb@_L!=QHE?%Kt(_O#U zOb}e4K@`SGLO1-r1J7ZgGy~r%7PQ68p1}7{Lh|vRb7k%Yz5C zZAn!dYDyF)-=I!FwY@Hp*c;O1yE#kz;`@!4(5%o|-*22{Y1> z@hSvxV=-)FtZ_!6l8`f;28%t=pu#usIEC_kKxZoin0WMiI3v5h7hs?P(*;3sSm9`F zfujl{eFsmrceFD8L70c?sh_loKtwY_xS77C9GXsy8fH&@pX#$gNIT@eGBM_ZG&RhR z`lpyhVG{SjD#uQTWdskZg>tze@J*Z$dw!Q_+f9VA5`4{KIU5yGimtkK*OP0mC+}M! za_$*e?-^R_8IpSZa*v)uMi}uA6)B-vDfq=J4K(AF>8m|lIRQ%Ac~%y65fpW85+twoQZUhi9MzPCrru9dQDhA{72GGoJ$XKBM-5`@*{0x90YJ;J+e{1SS7<*?(Pf-;mumB->5d zc2l(7RJ)75@nsu;RM08|+FE5>tH^$;NE#h?R<@lL*-ssWGZNn?^L--UN4kDD6hj#b z6?|B7*2&Jgxwc2?)|YR*^3vyDTDT&m7t86zw5u75js1mnmv7C5W{%4KY034X?0Qk+ zXJmdxWIy#u9qX<$YpydN^qrIYMkLo&*>zRoM`eCg!<)mVZZh>&E(? z!)tpEFI(SrNPAAndrpak^i%g^0}_8u=Ff@zIkxu+9KxJ`Z(mMxiQ&Bld-E34N`?*R zD_hLXh3PAWCOY5F(RmR&FR?Z6H?7ogfMGtmLHxuWXGW_U&Qkcj3mRN+!L#sLaKCwo z?}8XApViTs;@HQBjXf#MgT~p0WfUK9;z7J&?LNt8j&WJ3o)qOQ3FXps<6bXeZn?ZLF?Vc;m!=WW4cD zxbvTI7lglIx6`joPQFlIUHz*bTu$?J6~N^Px!sBS=V6@i*KnVYN41{zSlEZUvWUL= z0&0O)uH7|%aWQAzR1trV*v8}lJ)JCYum}m^7dMJJ3P}Bvgf;*-V{07g9(G2$yT$nP;UO4xr+l| zFM6X$%-ti}_Q1kw!8iY+$QOpvo$Kj^Yw3lHh%m5APG=fJZn}SIehNO7WB2Q&^xbm$ zZdwKk>Hd(?qRgy?()pvJc?+Xm|1*Xa2Y*5+$MAe&*C6U}Iy5Q#FnbMKhQ}tnlb>Y8 zq4Y~I&A6c9hozw+hRH8tfC-Z;vO#cha2-8j3PBW8S|fQ>aVeaap`I9zXV%1IO;ms6 zNG5B7Z+S%)Ns4!oW5(n&#V~d}){jrue$BwW+#HzOzBKxxb*)!%(hPD9!|y%#QD}Ee zes|D-9W*NoS(}CawD(EYmRz3ZLpUOEptNrZ5*W4d!^R=w07^!=P>BEB(CddA7RajG zy3h1MM2!$NH?BaS-qYRNM`p;Q;}bW=D?G4aV5ZM~%{_#M#wh{@3^D#xK`1A&o3udd z1=|ej{a?|N)71yRQ&qS;S)LY73m~Sae=?kT**}SRk`UStPlPkuDfCDm%x;@|6v`WW zTSGW|2sAI$QDTn)gPGwCnp>>Ou!SlJF!DapS-LQB$sfZPM^UJp@I`}yFBHidrh38r z)P~VyaYM63&MHA*;=CUs9`GqKt4+#kle5~GUjCy@rjXCV8Y_SmL{VtvyBUE(v!okJt+jWbLw~sFVz1znnXNl}AnQK${*3SIJy!n?zemf{(OYtLnu590R_vTWb zxckukbkW`>+1q4$8&RVa+QV8B%H6uyw{VL#Kn5q=h-G1C3jO<-TrewO zZwUGkmXHhpYc9IxKL;~N4s%mZ`g=JhI(Kn&zJ;A{bu?9&-rL6kYSaiw`ouo=lNhGYEC<5}3ON`o$>#D? zveR@SCKQI0kHq-@W7Zt+GYmwAj;sUWVL^ZiSG~;&ok^eDo3hpvBoJcS@mi#kQ191ftnmDi z%2qEuRcrD7y!P!AYF_1BE)XzdD;@IAtJRONG|T-e&x+Nr2%1-x*_poq%K zgC)og-qWWrNXkuN=Z(vQ(B4w9OE~x1#P|s5LXBUJB}N@+QFyVzpfDyJ#N-YV3X5ZK zfs0k2z{!M4JS2RR!O7rI&@(aSC8tji{IB)(qenIkEZ0fw9%h(2U_9vlUN5CP_v-tD{hgp^w? z=T^`2KzxKWSyT+y&bw`rt5SAVeklVKmJh_3datPBUfzcduzVBg+*!wu0_r`U1+nT%WVcx?@u|HWelDjlk zsjx4i!zm6iGdIO~^nk7u=QAQIjt0#^SIRhvl;{z&71OI`&tf-Ce_9j-2}vqFA-9&Q ziB~xoHY!rK8BR;zXl$2ElX3b@+NTEKQ4>lGB<+qIS5>tLEEtWTJ|OY>aIWL~sZ_$tRdj!}66cdL3*b+!YUlx~rk;bzRl*1d(DFIe`L=`Zv`=-^@M zx>Z-9whZ?*&?LHi#p4ClHZtiaK0F1T9G@7kgabb4FnhpcjK=tG^b7;DATT?N)#qXU zLOny#K--9}4P8vOpQLCY{p|>4skp}p`mp_k8ug zoP{%>u}U_-P-zzG=@#{IIOj|s#kpfq<62u3O@f_tcQ@`bmo~sTjBQ#$sf>4OMt~GA z(?euC$pB4IhBYE+!=@-PGj*zfa0>l=mheRmDu7~RhM~wacarF?Ey>iOIP0#Z-H&qf zZy#AaE9I8Txux?hM0PP37z!YYYZp;macP7R^p_EeD>3<(w%pgwiJ25($AYgp3PMiT zyq*4t%-Vv|zsv-O^v#x-yYwwA3sYb~L?5$d2LlM=la=>6|5bi5_f`9CJ5ykoZ+P>m zyTR}Iq-{^g+n$(jpzuCh+>5(jy?XoVeA;{felarPk$tW+P=VfiIj7NN_?qjm>AN3>Bh4@|j@ZqtJT4lv9Ui-DNj8m^m4zEMza#&yd}zDxTPJTGSV zNW3{1c&NJ|%zT}x`>c`Nv?k|v>GNI&rwdvK7x9f^#1jvnIYm7>5>fjl6Tz6GK1=fY zG++H=>()PsNzIeDUB`)fOsmpUvQJ#_>2M zZKzU54km4i=4pb0y=Odl4T2*mM@6%EG_bJ(ZO0=~R^~S91o{Nbw4z0Ty)?5wC!;-62 zcC||UNtr(>@+Vc9LnOlo$DbB^pOuae$j1jH*E!jBPU6qY{CSc6RB1;nRW$~wUC%zU zmVM;MWpe##slHRL@07B;lXz2m@NDv4()3LC7 zzJc&;#5(cdy8GCg``GfC`@0|JuI`iEU9!7NvUSV0Zqe5LaZDvFITn2x9jKbW#eKJW zZF!267}%Rq-jGTwOcyCd|8+4Mh?dpJ5!1kD-#B#OXN>F{PbKF_t*mLBiQ}Jr>NlRM zejTj^T$n1Sj*fav&P4o5lvXmc!$vFsdlfHqIhg^9s`IIs{D7Mmu{FV6gs_HriYlA03ijeLt4q)%9bw|~ zF;wJ3E3ZY2TF6ugY`L>A#)GHjmcj0mC&{xu?KxY!db>M1T3hJ6x3#flAcDlmVhB7s zqPgM}NQu%DT9FowE~7&$wBDg3Bjj7ALcS@xy{A!XhA_%Q#eUd$rP z(0AxkF~KwIu8V7~i{jvr+M1%V&Nt zD!H3vcN6@a$+l+1JbawqXHYUaL_Y6<%@bOE6P=yRhF5|88#IW(#2Hcb1$fvifb4kr6T}+X({rV1fwq<;z6Cr(`QYQ-<%?UM)LgeV!)WU;>W$wWq#J8>34PO zs7Y1D3ByL9(;KsCR&4iD82)4?ZyPvvhU5l72^>Pqp4c{1I|ASrp1@YRz@Q%3JnsXF zOw}4&s@7=h)n1W#&#lR*pkW3=iE?{a*aE3SW9M`UKKqFOcftU12P1ZffSE2rY6}=> zaW^BUEDyUMRUOpkVfUj%RSR?k$&h!{I|U_sHDjy}e1(i&6RsYljdnjmfU&d=3V`1B%wTg{ zYx8LZQ~x`vi}XMtrDaUKUQ{VGgZ2>4(Aj^aGlsZ7K&C|LY&Nv9MuZ7C?jMiZ6cVTV z9}x2HMpal(v+1|E9$E$nY>9 zt$KN{Skp^)9+pY`8JRz$-uWRf*PN1S+T|K>@Sc+Sr#7;!_O!Xy-{u)|3gqm)6!p7R z%sNT??IR4#n;|l?9#QGNz3cA%YwrC^rOW2Oa{Lp={Yj~=1Cf6ucc<*`lx$tHtxIIT zj~^+zMyQ6fDj=~E$$eOMAC_!&vJK`Aksq-kj({!jA@S(~q*%UEbF8J(xLR4;T4H&B z2M4I3CP_mAsGs}IPjK?OPV$(XHy1DHh?lHtKj~DnQrJFf-g?b3x46+2#eKEX9eEY_{5~= zlAi+8`l^K6cxIPy1pV>}_awGRrime_AQ0sdPCql)-r0@Fc;Ca_a5^kx;S0(0hCXXE`HlCwp2wupSo#}TO`a|osY1ZssK`r;Ddd5o8BcW9iYhx2yrj%!h z=zmgnmh(6@nO7_^S(6{1GfjB>eO?$)pY-^gW(6h2S~5rlYccBJe!fKQ>nA|7Luoj1 z!31iq;ys!*Hb|GOf7#1i$0a&B)-5dcRV9_81Pk9Hz*1RxViQF9qAYe#d6H=(orFd1uPGBN9h(JzO=+ymhI+DeLH8pdbcCH}6<`BXgXcN4V36=`mkXpQR2JYG8{g>p_;vN=4LzS!(PF zIhaLpqsVfa6_qLWrmTDkO*Kxap#OMHdq!UnKAfK-h7Lq%Uwg-`?2N zf#6A>qjIkh_#*;u5cr1#?h#X9SiB{3U^ZM&Mr%_!$8Ti^SZ|Gxy2D@95G%V@@8%1oBlQWD;-? zAS+U)ARi~7Cc$wyLyYPI>-J(2In06i%YT3vA<9(^M28g z@)t5RiNDY9LqndZ{cSihIQYm<1oI=Cl=@NZ7Cijt&o|wAYT>$^kC3{RvaNE?8p_C< zFS)gM;jHW`lQPQXjPkkkjWmvfCBGK%9JiCw;z80hBex$+84bwbI1-X+0LqdIlm;ZL z&tY)7<}yP$+vc31?7}%mD63%39&&D(vngr0MWP`ql)ZD#@nKFO^5mDIhY%asm^tTO zC>6PUiOZL{{0(C+2W6;4ut{kj5!f^tj0I}e8iU0#*FKMEp9hy_?_Uvn`o(81g&aqg zpWFn=)wad)@x;>Lrp0JEiA)61LG*f}W2d<5sMs(dp7(|vxeMpTlIHt^RNGlr z+u3MsJH(yG#S`bm=g3_k5%~CdA@Kv3XGRUXDDqSL_?2r}|koKetist}u#*>&n0Oo|pA#>l^=@%1-lQ+dJ5rB{ZRm$Bi{`~qabIu9Q9?~GrKXpvO-EO> zg&bw~%9dJ|_hCYaN4qK4(`XVwH6LCP)jh9NH!fD4OIF?di0DS^y2KugCHf4lds1xd zr`A2g-tn18S73@AUM_yPTkKT^eZTm`DazH(a6^r>Uw zwmPxC^Wk;z8O+zM;`WZD+3?Uq%g%>x*!qPWh2pl;VyBlLI>a726dR=zViTT-xtO!i zE$%ua_K#4uQI>6#+t^>YAEN{jc_EEb6l}7yIzu^TOs`bdJLeD$y~KU6t{-V1q>E%*wb^2bB>KXGY5T6EpTvL zJ9x$kkT*h%O~^|Jvv>~fAGKhMiEBdM1ZYbmJfj6UgB%B6-^t-7OaCbT?o+@-nH$Cv zMlNr|kQ{EZ^p6-M#FhE7J5r#-Y_R77vD@?JGKfvhqjwUJp@nn8M@$0Pq_mF+#E>E7 z%F5=t@v;f9%*nwD%#Z*})44{Z>)~dei%Z)uXuxKTk-Lbz2{6mxaN!NI3g_&}-hii- zBN1#;+D8OpZ$P=WupV`C4b)ma*rc?N2&i32nJNs~xgpQ4&CIhLm%V8qxRD5N8Vy)O zb8Z!id3BQYh-^JFm$sSapjoE@nvP%xL%Av0z5|VUr`k*Eeqvu+sv)!vNjC>8#-{pXmAz6n@}h# zhw|C;C?CLv7Hn4GBiz(|iFCOM5(eak25fp<^qv~9S(Dfwo7*yIMrpuip@VZ!=f{D~ z5*yX00h>kJsgnUVv|#fDw*?dfsKGd}S(^Fa@YbYv}x8v1v zxSUM_>?{U0_d2K~0JSzAlo+<}l($u~25)Lxt)vxcpF|JdthI1pEu{d=sN??tR{|jg literal 0 HcmV?d00001 diff --git a/bfos/__pycache__/runner.cpython-311.pyc b/bfos/__pycache__/runner.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..028ad85de45c402f1fb4dea8fc0171d33d5b5834 GIT binary patch literal 19325 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 X;pYthMSkoX4yO(O8sI5_QQ`jq{Um9t literal 0 HcmV?d00001 diff --git a/bfos/__pycache__/tui.cpython-311.pyc b/bfos/__pycache__/tui.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d20537d94a5fd114cb626e549f00adbd2183711b GIT binary patch literal 32631 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 IW&Gp+0Atp*-v9sr literal 0 HcmV?d00001 diff --git a/bfos/__pycache__/ui_gui.cpython-311.pyc b/bfos/__pycache__/ui_gui.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d0c7608ed00210a02ae028711475a1cc27df858 GIT binary patch literal 161895 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

3~6#-&2 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 literal 0 HcmV?d00001 diff --git a/bfos/cli.py b/bfos/cli.py new file mode 100644 index 0000000..de62250 --- /dev/null +++ b/bfos/cli.py @@ -0,0 +1,1104 @@ +"""Command line interface and operator flows for the bfos console. + +Responsibilities: + + * argument parsing (``--full``, ``--dry-run``, ``--check``, ``--repo``, + ``--inventory``, ``--log-file``, ``--version``, ``--lang``, ``--list``, + ``--list-targets``, ``--no-color``), + * repository discovery and the startup checks (inventory, Ansible, audit log), + * the two presentations: the simple default view and ``--full`` detailed mode, + * every operator flow: local install, fleet operations with their gates, the + central preparation screens, the tools and the read-only information. + +Everything that decides *what may run* lives in :mod:`bfos.operations`, and +everything that *runs* lives in :mod:`bfos.runner`; this module only wires the +two together and talks to the operator. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import Dict, List, Optional, Sequence, Tuple + +from . import config, operations, runner, tui +from .operations import ( + GateStep, + GatesGranted, + Inventory, + Operation, + Refused, + Repository, + Target, +) + + +class Quit(Exception): + """The operator asked to leave the console.""" + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=config.PROGRAM_NAME, + description="{0} — {1}".format(config.PROGRAM_DESCRIPTION, config.VERSION), + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, + epilog=( + "Örnekler:\n" + " sudo bfos basit mod: 1) Kur 2) Durum 3) Çıkış\n" + " sudo bfos --full detaylı mod: 21 playbook, merkez hazırlığı, araçlar\n" + " bfos --dry-run --full komutları yalnız göster, hiçbir şey çalıştırma\n" + " bfos --list işlem kataloğu (etkileşimsiz)\n" + " bfos --list-targets envanterdeki dalgalar, gruplar ve cihazlar\n" + "\n" + "Güvenlik: her komut önce ekranda gösterilir ve onay ister; production dalgası\n" + "ayrı onay (+ production_override=true), sürüm kapılı işlemler yazılı dalga onayı,\n" + "yıkıcı işlemler ikinci onay ister. Denetim kaydı: {log} (mod 600).\n" + ).format(log=config.DEFAULT_LOG_FILE), + ) + parser.add_argument("-h", "--help", action="help", help="bu yardımı göster ve çık") + parser.add_argument("-V", "--version", action="store_true", help="sürümü göster ve çık") + parser.add_argument("--full", action="store_true", + help="detaylı mod: tüm kategoriler ve 21 playbook görünür") + parser.add_argument("--dry-run", action="store_true", + help="komutları yaz, hiçbir şey çalıştırma") + parser.add_argument("--check", action="store_true", + help="komutları --check ile üret (salt okunur; değişiklik uygulanmaz)") + parser.add_argument("--repo", metavar="YOL", default="", + help="Blueforce deposu (varsayılan: konsolun bulunduğu depo)") + parser.add_argument("--inventory", metavar="YOL", default="", + help="Ansible envanteri (varsayılan: /ansible/inventory/hosts.yml)") + parser.add_argument("--log-file", metavar="YOL", default=config.DEFAULT_LOG_FILE, + help="denetim kaydı (varsayılan: {0}, mod 600)".format(config.DEFAULT_LOG_FILE)) + parser.add_argument("--lang", metavar="DİL", default=config.DEFAULT_LANG, + help="arayüz dili (yalnız: {0})".format(", ".join(config.SUPPORTED_LANGS))) + parser.add_argument("--no-color", action="store_true", help="ANSI renklerini kapat (NO_COLOR da geçerli)") + parser.add_argument("--list", action="store_true", + help="işlem kataloğunu yaz ve çık (etkileşimsiz)") + parser.add_argument("--list-targets", action="store_true", + help="envanter hedeflerini yaz ve çık (etkileşimsiz)") + return parser + + +def resolve_repo(explicit: str = "") -> Repository: + """Locate the Blueforce checkout. + + An explicit ``--repo`` (or ``BF_REPO``) is a commitment, not a hint: when it + does not look like the repository the console refuses to run instead of + silently falling back to another checkout. + """ + for candidate in (explicit, os.environ.get("BF_REPO", "")): + if not candidate: + continue + repo = Repository(candidate) + if repo.looks_valid(): + return repo + raise Refused(config.MSG_REPO_INVALID.format(path=candidate)) + package_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for candidate in (package_parent, os.getcwd()): + repo = Repository(candidate) + if repo.looks_valid(): + return repo + raise Refused(config.MSG_REPO_INVALID.format(path=package_parent)) + + +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- +class App: + """The operator flows. All I/O goes through :class:`bfos.tui.Console`.""" + + def __init__( + self, + repo: Repository, + console: tui.Console, + runner_: runner.Runner, + inventory: Inventory, + full_mode: bool = False, + check_mode: bool = False, + inventory_error: str = "", + inventory_arg: str = "", + ) -> None: + self.repo = repo + self.console = console + self.runner = runner_ + self.inventory = inventory + self.full_mode = full_mode + self.check_mode = check_mode + self.inventory_error = inventory_error + self.inventory_arg = inventory_arg + + # -- infrastructure ---------------------------------------------------- + def require_inventory(self) -> bool: + """Fleet operations need a readable inventory: explain, never guess.""" + try: + path = self.repo.find_inventory(self.inventory_arg) + except Refused as exc: + self.console.exception(exc) + return False + if path != self.inventory.path: + self.inventory = Inventory.load(path) + return True + + def require_ansible(self) -> bool: + if self.runner.have_ansible(): + return True + self.console.error(config.MSG_ANSIBLE_MISSING) + return False + + def require_root(self) -> bool: + if self.runner.is_root(): + return True + self.console.error(config.MSG_ROOT_REQUIRED) + return False + + def ask_menu(self, screen: tui.Screen) -> Optional[str]: + """Render a screen until a valid key is chosen. + + Returns the chosen key, or ``None`` when the operator goes back. + Raises :class:`Quit` when the operator quits. + """ + while True: + self.console.screen(screen) + outcome, payload = self.console.ask_menu_choice(screen) + if outcome == tui.CHOICE_QUIT: + raise Quit() + if outcome == tui.CHOICE_BACK: + return None + if outcome == tui.CHOICE_ITEM: + return payload + + def pause(self) -> None: + self.console.pause() + + # -- entry point ------------------------------------------------------- + def run(self) -> int: + try: + if self.full_mode: + self.console.note(config.MSG_FULL_MODE_HINT) + self.detailed_loop() + else: + self.simple_loop() + except Quit: + self.console.note(config.MSG_QUIT) + return config.EXIT_OK + + # ------------------------------------------------------------------ + # Simple mode + # ------------------------------------------------------------------ + def simple_loop(self) -> None: + while True: + key = self.ask_menu(tui.simple_menu()) + if key is None: + self.console.note(config.MSG_AT_TOP) + continue + if key == "1": + self.simple_install() + elif key == "2": + self.simple_status() + elif key == "3": + raise Quit() + elif key == "d": + self.full_mode = True + self.console.note(config.MSG_FULL_MODE_HINT) + self.detailed_loop() + else: # pragma: no cover - ask_menu only returns known keys + self.console.warn(config.MSG_UNKNOWN_CHOICE.format(choice=key)) + + def simple_install(self) -> None: + """Guided local install: dealer number, then the installer.""" + self.console.section("Kurulum — bu cihaz") + self.console.note("Bu cihaz Field OS standardına getirilir. Yalnız bu cihaz; filo cihazlarına dokunulmaz.") + dealer = self.ask_dealer() + if dealer is None: + return + try: + installer = self.repo.installer_path() + except Refused as exc: + self.console.exception(exc) + self.pause() + return + offline = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") + if offline is None: + raise Quit() + if not self.require_root(): + self.pause() + return + argv = operations.build_install_command( + installer, dealer, offline=offline, check_mode=self.check_mode + ) + label = "kurulum: {0}".format(operations.dealer_to_device_id(dealer)) + self.console.info("Hedef cihaz: {0} ({1})".format( + operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) + self.runner.confirm_and_run(argv, label=label, destructive=True) + self.pause() + + def simple_status(self) -> None: + """Read-only status: identity, provisioning state, health tool.""" + self.console.section("Durum — bu cihaz") + self.console.show_lines(operations.identity_lines()) + self.console.write("") + self.console.show_lines(operations.state_lines()) + self.console.write("") + result = self.run_tool("bf-status", title="bf-status (salt okunur)") + if result is None: + self.console.note("bf-status kurulu değil; yukarıdaki özet gösterildi.") + self.console.note("Bu ekran hiçbir şeyi değiştirmez.") + self.pause() + + def detailed_loop(self) -> None: + while True: + key = self.ask_menu(tui.detailed_menu()) + if key is None: + self.console.note(config.MSG_AT_TOP) + continue + if key == "1": + self.category_install() + elif key == "2": + self.category_devices() + elif key == "3": + self.category_central() + elif key == "4": + self.category_tools() + elif key == "5": + self.category_info() + elif key == "s": + self.full_mode = False + self.console.note(config.MSG_SIMPLE_MODE_HINT) + self.simple_loop() + return + + # ------------------------------------------------------------------ + # Shared input helpers + # ------------------------------------------------------------------ + def ask_dealer(self) -> Optional[str]: + """Ask for a dealer number until it matches ^[0-9]{8}$ (or back/quit).""" + while True: + value = self.console.read(config.MSG_PROMPT_DEALER) + if value is None: + return None + value = value.strip() + if value == "" or value == "0": + return None + if operations.dealer_is_valid(value): + return value + self.console.warn(config.MSG_TARGET_DEALER_INVALID.format(value=value)) + + def ask_extra_vars(self, operation: Operation) -> Optional[List[Tuple[str, str]]]: + """Collect and validate the extra -e variables for one operation.""" + prompt = operation.prompt + if prompt == operations.PROMPT_NONE: + return [] + answers: Dict[str, str] = {} + if prompt == operations.PROMPT_PACKAGES: + self.console.note(config.MSG_PKG_HINT) + value = self.console.read(config.MSG_PROMPT_PACKAGES) + answers["packages"] = "" if value is None else value + elif prompt == operations.PROMPT_SERVICE: + self.console.note(config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST))) + value = self.console.read(config.MSG_PROMPT_SERVICE) + answers["service"] = "" if value is None else value + elif prompt == operations.PROMPT_SCRIPT: + self.console.note("İzinli dizinler: {0}".format(" ".join(config.SCRIPT_DIRS))) + path = self.console.read(config.MSG_PROMPT_SCRIPT) + answers["script"] = "" if path is None else path + args = self.console.read(config.MSG_PROMPT_SCRIPT_ARGS) + answers["script_args"] = "" if args is None else args + elif prompt == operations.PROMPT_GRACE: + value = self.console.ask_with_default( + config.MSG_PROMPT_GRACE.format(default=config.DEFAULT_REBOOT_GRACE), + str(config.DEFAULT_REBOOT_GRACE), + ) + answers["grace"] = "" if value is None else value + elif prompt == operations.PROMPT_DISK_PCT: + value = self.console.read(config.MSG_PROMPT_DISK) + answers["disk_pct"] = "" if value is None else value + elif prompt == operations.PROMPT_LOG_LINES: + value = self.console.ask_with_default( + config.MSG_PROMPT_LOG_LINES.format(default=config.DEFAULT_LOG_LINES), + str(config.DEFAULT_LOG_LINES), + ) + answers["log_lines"] = "" if value is None else value + elif prompt == operations.PROMPT_VERSION: + value = self.console.read(config.MSG_PROMPT_VERSION) + answers["expected_version"] = "" if value is None else value + try: + return operations.build_extra_vars(prompt, answers) + except Refused as exc: + self.console.exception(exc) + self.pause() + return None + + # ------------------------------------------------------------------ + # Target selection + # ------------------------------------------------------------------ + def select_target(self, mode: str) -> Optional[Target]: + """Interactive target picker (mirrors bf_target_select).""" + while True: + screen = tui.target_menu() + if mode == config.MODE_RELEASE: + screen = tui.Screen( + title=screen.title, + subtitle="Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez", + entries=screen.entries, + notes=("Yalnız onaylı dalga grupları kabul edilir; filo geneli reddedilir.",), + ) + self.console.screen(screen) + value = self.console.read(config.MSG_PROMPT_TARGET) + if value is None: + return None + choice = value.strip() + if choice == "" or choice == "0": + return None + if choice.lower() == "q": + raise Quit() + if choice == "1": + dealer = self.ask_dealer() + if dealer is None: + continue + word = dealer + elif choice == "2": + word = self.pick_group(mode) + if word is None: + continue + elif choice == "3": + word = "all" + elif choice == "4": + typed = self.console.read("Hedef (bayi no, grup ya da 'all'): ") + if typed is None: + return None + word = typed.strip() + else: + # Anything else is treated as a directly typed target, exactly + # like the bash prompt's "type the target" option. + word = choice + try: + return operations.resolve_target(word, mode, self.inventory) + except Refused as exc: + self.console.exception(exc) + self.pause() + + def pick_group(self, mode: str) -> Optional[str]: + if not self.inventory.resolved: + self.console.warn(config.MSG_INVENTORY_MISSING.format( + dir=self.repo.path(config.INVENTORY_DIR_REL))) + return None + groups = self.inventory.group_names() if mode == config.MODE_ANY else self.inventory.waves_present() + if not groups: + self.console.warn("Envanterde kullanılabilir grup yok.") + return None + self.console.section("Envanterdeki gruplar") + for index, group in enumerate(groups, start=1): + count = len(self.inventory.hosts_of(group)) + suffix = "onaylı dalga" if operations.is_wave(group) else "grup" + self.console.write(tui.render_item(str(index), group, "{0} cihaz, {1}".format(count, suffix), + self.console.palette)) + value = self.console.read("Grup numarası > ") + if value is None: + return None + value = value.strip() + if value.isdigit() and 1 <= int(value) <= len(groups): + return groups[int(value) - 1] + self.console.warn(config.MSG_MENU_RANGE.format(max=len(groups))) + return None + + # ------------------------------------------------------------------ + # Fleet operations + # ------------------------------------------------------------------ + def run_operation(self, operation: Operation) -> None: + """Full flow for one operation: checks, target, gates, execution.""" + try: + scope = self.repo.verify_scope(operation) + except Refused as exc: + self.console.exception(exc) + self.pause() + return + if not self.require_ansible(): + self.pause() + return + if not self.require_inventory(): + self.pause() + return + + self.console.section(operation.label) + self.console.note(operation.description) + if operation.declared_scope == config.SCOPE_RELEASE: + self.console.note("Sürüm kapılı işlem: yalnız onaylı dalga, yazılı dalga onayı zorunlu.") + target = self.select_target(operation.target_mode) + if target is None: + self.console.note(config.MSG_TARGET_NONE) + return + extra_vars = self.ask_extra_vars(operation) + if extra_vars is None: + return + + if scope == config.SCOPE_WAVE and target.kind == operations.KIND_ALL: + self.run_fleet_waves(operation, scope, target, extra_vars) + return + wave = target.wave + if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE) and not wave: + self.console.error(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) + self.pause() + return + self.gated_run(operation, scope, target, wave, extra_vars) + self.pause() + + def run_fleet_waves( + self, + operation: Operation, + scope: str, + target: Target, + extra_vars: Sequence[Tuple[str, str]], + ) -> None: + """Wave-scoped operation on the whole fleet: one wave at a time. + + The sequence stops at the first wave that is refused or fails, so no + later wave is ever touched by accident. + """ + sequence = operations.wave_sequence(self.inventory) + if not sequence: + self.console.error("Envanterde onaylı dalga yok; filo geneli işlem yapılamaz.") + self.pause() + return + self.console.section("Filo geneli — dalga dalga") + self.console.warn(config.MSG_GATE_WAVE_BY_WAVE.format(order=config.MSG_GATE_ORDER)) + if not self.console.confirm(config.MSG_GATE_START_FIRST): + self.console.note(config.MSG_CANCELLED) + return + for wave, count in sequence: + self.console.section("Dalga '{0}' — {1} cihaz".format(wave, count)) + answer = self.console.read(config.MSG_GATE_WAVE_LOOP_ASK.format(wave=wave, count=count)) + if answer is None: + return + if answer.strip() != wave: + self.console.info(config.MSG_GATE_WAVE_SKIPPED.format(wave=wave)) + self.runner.audit.write(config.AUDIT_CANCEL, "dalga atlandı: {0}".format(wave)) + continue + wave_target = Target( + kind=operations.KIND_GROUP, limit=wave, wave=wave, count=count, + label="dalga '{0}' ({1} cihaz)".format(wave, count), + ) + result = self.gated_run(operation, scope, wave_target, wave, extra_vars, + pause=False, wave_token_already_given=True) + if result is None or not result.ok: + self.console.error(config.MSG_GATE_WAVE_FAILED.format( + wave=wave, rc=(result.rc if result is not None else "-"))) + self.runner.audit.write(config.AUDIT_STOP, "{0} dalgasında durdu: {1}".format( + operation.ident, wave)) + self.pause() + return + self.pause() + + def gated_run( + self, + operation: Operation, + scope: str, + target: Target, + wave: str, + extra_vars: Sequence[Tuple[str, str]], + pause: bool = True, + wave_token_already_given: bool = False, + ): + """Apply the confirmation plan, build the command and run it.""" + try: + wave_gate_supported = self.repo.playbook_uses( + operation.playbook, operations.VAR_WAVE_GATE_CONFIRMED + ) + except Refused as exc: + self.console.exception(exc) + return None + + plan = operations.confirmation_plan( + operation, scope, target, wave=wave, wave_gate_supported=wave_gate_supported + ) + if wave_token_already_given: + # The fleet-wide loop already made the operator type this wave's + # name: it is never asked twice for the same wave. + plan = [step for step in plan if step.key != "wave_token"] + gates = GatesGranted() + if plan: + self.console.section(config.MSG_GATE_PLAN_TITLE) + for step in plan: + granted = self.apply_gate_step(step) + if granted is None: + return None + gates = gates.grant(step) + + self.console.section("İşlem özeti") + self.console.kv("İşlem", operation.label) + self.console.kv("Playbook", "{0}/{1}".format(config.PLAYBOOK_DIR_REL, operation.playbook)) + self.console.kv("Kapsam", scope) + self.console.kv("Envanter", self.inventory.labels()) + self.console.kv("Hedef", target.label) + if wave: + self.console.kv("Dalga", wave) + + try: + argv = operations.build_ansible_command( + self.repo, + self.inventory.path or "", + operation, + scope, + target, + wave=wave, + extra_vars=list(extra_vars), + gates=gates, + check_mode=self.check_mode, + ) + except Refused as exc: + self.console.exception(exc) + if pause: + self.pause() + return None + + self.runner.audit.write( + config.AUDIT_TARGET, + "{0} hedef={1} tür={2} dalga={3}".format( + operation.ident, target.limit, target.kind, wave or "yok"), + ) + result = self.runner.confirm_and_run( + argv, label="{0} — {1}".format(operation.label, target.label), + destructive=operation.destructive, + ) + if pause: + self.pause() + return result + + def apply_gate_step(self, step: GateStep) -> Optional[bool]: + """Ask one confirmation. Returns None when the operator does not grant it.""" + if step.kind == operations.STEP_TOKEN: + granted = self.console.confirm_token(step.message, step.token, step.prompt) + if granted is None: + return None + if not granted: + self.console.warn(self._token_refusal(step)) + self.pause() + return None + return True + granted = self.console.confirm(step.prompt) + if granted is None: + return None + if not granted: + self.console.warn(self._approval_refusal(step)) + self.pause() + return None + return True + + @staticmethod + def _token_refusal(step: GateStep) -> str: + if step.key == "production_token": + return config.MSG_GATE_PRODUCTION_REFUSED + if step.key == "fleet_token": + return config.MSG_GATE_FLEET_REFUSED + return config.MSG_GATE_WAVE_REFUSED + + @staticmethod + def _approval_refusal(step: GateStep) -> str: + if step.key == "production_approval": + return config.MSG_GATE_PRODUCTION_REFUSED + return config.MSG_CANCELLED_RUN + + # ------------------------------------------------------------------ + # Category: Kurulum (this device) + # ------------------------------------------------------------------ + def category_install(self) -> None: + while True: + key = self.ask_menu(tui.install_menu(self.install_items())) + if key is None: + return + if key == "1": + self.install_preflight() + elif key == "2": + self.install_apply(resume=False) + elif key == "3": + self.install_apply(resume=True) + elif key == "4": + self.install_single_module() + elif key == "5": + self.show_file(config.INSTALL_STATE_FILE, "Kurulum modül durumu") + self.pause() + elif key == "6": + self.tail_file(config.INSTALL_LOG_FILE, "Kurulum kaydı (son 40 satır)") + self.pause() + + @staticmethod + def install_items() -> Tuple[Tuple[str, str], ...]: + return ( + ("Ön kontrol (--check)", "kurulum betiği --check; hiçbir şey yazmaz"), + ("Bu cihazı kur", "tam kurulum; bayi numarası sorulur"), + ("Başarısız kurulumu sürdür", "kurulum betiği --resume"), + ("Tek modül çalıştır", "kurulum betiği --only"), + ("Modül durumunu göster", config.INSTALL_STATE_FILE), + ("Kurulum kaydını göster", "{0} son 40 satır".format(config.INSTALL_LOG_FILE)), + ) + + def install_preflight(self) -> None: + installer = self.require_installer() + if installer is None: + return + dealer = self.ask_dealer() + if dealer is None: + return + self.console.note("Salt okunur ön kontrol: --check modunda kayıt, durum ve dizin oluşturulmaz.") + argv = operations.build_install_command(installer, dealer, check_mode=True) + self.runner.run_readonly(argv, title="Ön kontrol: {0}".format( + operations.dealer_to_device_id(dealer))) + self.pause() + + def install_apply(self, resume: bool) -> None: + installer = self.require_installer() + if installer is None: + return + dealer = self.ask_dealer() + if dealer is None: + return + if not self.require_root(): + self.pause() + return + offline = False + if not resume: + answer = self.console.confirm("Çevrimdışı kurulum (yerel paket deposu) kullanılsın mı?") + if answer is None: + raise Quit() + offline = answer + resume_answer = self.console.confirm("Daha önce OK olan modüller atlanılsın mı (--resume)?") + if resume_answer is None: + raise Quit() + resume = resume_answer + argv = operations.build_install_command( + installer, dealer, offline=offline, resume=resume, check_mode=self.check_mode + ) + self.console.info("Hedef cihaz: {0} ({1})".format( + operations.dealer_to_device_id(dealer), operations.dealer_to_host(dealer))) + self.runner.confirm_and_run( + argv, label="kurulum: {0}".format(operations.dealer_to_device_id(dealer)), destructive=True + ) + self.pause() + + def install_single_module(self) -> None: + installer = self.require_installer() + if installer is None: + return + modules = operations.module_names(self.repo) + if not modules: + self.console.warn("Kurulum modülü bulunamadı: scripts/install/installer/modules/") + self.pause() + return + self.console.section("Kurulum modülleri") + for index, name in enumerate(modules, start=1): + self.console.write(tui.render_item(str(index), name, "", self.console.palette)) + value = self.console.read("Modül numarası > ") + if value is None: + return + value = value.strip() + if not value.isdigit() or not (1 <= int(value) <= len(modules)): + self.console.warn(config.MSG_MENU_RANGE.format(max=len(modules))) + self.pause() + return + module = modules[int(value) - 1] + dealer = self.ask_dealer() + if dealer is None: + return + if not self.require_root(): + self.pause() + return + argv = operations.build_install_command( + installer, dealer, only=module, check_mode=self.check_mode + ) + self.runner.confirm_and_run( + argv, label="tek modül ({0}): {1}".format(module, operations.dealer_to_device_id(dealer)), + destructive=True, + ) + self.pause() + + def require_installer(self) -> Optional[str]: + try: + return self.repo.installer_path() + except Refused as exc: + self.console.exception(exc) + self.pause() + return None + + # ------------------------------------------------------------------ + # Category: Cihaz işlemleri (fleet) + # ------------------------------------------------------------------ + def category_devices(self) -> None: + while True: + key = self.ask_menu(tui.devices_menu( + len(operations.REPORT_OPERATIONS), len(operations.MAINTENANCE_OPERATIONS))) + if key is None: + return + if key == "1": + self.operation_list(operations.REPORT_OPERATIONS, + "Salt okunur filo raporları", + "Bu listedeki hiçbir playbook cihazı değiştirmez") + elif key == "2": + self.operation_list(operations.MAINTENANCE_OPERATIONS, + "Filo bakım işlemleri", + "Yıkıcı işlemler iki kez onay ister") + elif key == "3": + if self.require_inventory(): + self.console.show_lines(operations.targets_lines(self.inventory)) + self.pause() + + def operation_list(self, ops: Sequence[Operation], title: str, subtitle: str) -> None: + screen = tui.Screen( + title=title, + subtitle=subtitle, + entries=tuple(tui.item(op.label, op.description) for op in ops), + ) + key = self.ask_menu(screen) + if key is None: + return + index = int(key) - 1 + if 0 <= index < len(ops): + self.run_operation(ops[index]) + + # ------------------------------------------------------------------ + # Category: Merkez hazırlığı + # ------------------------------------------------------------------ + def category_central(self) -> None: + while True: + key = self.ask_menu(tui.central_menu()) + if key is None: + return + if key == "1": + self.console.show_lines(operations.central_readiness_lines( + self.repo, self.inventory, self.inventory_error)) + elif key == "2": + self.console.show_lines(operations.central_endpoint_lines(self.repo)) + elif key == "3": + self.console.show_lines(operations.central_probe_lines(self.repo)) + elif key == "4": + self.console.show_lines(operations.wave_gate_state_lines(self.inventory)) + elif key == "5": + self.console.show_lines(operations.central_checklist_lines()) + self.pause() + + # ------------------------------------------------------------------ + # Category: Araçlar + # ------------------------------------------------------------------ + def category_tools(self) -> None: + while True: + key = self.ask_menu(tui.tools_menu()) + if key is None: + return + if key == "1": + self.tool_git_pull() + elif key == "2": + self.console.show_lines(operations.environment_lines( + self.repo, self.runner.audit.path or self.runner.audit.requested, + self.inventory, self.runner.dry_run, self.runner.check_mode)) + self.console.kv("Denetim kaydı modu", operations.file_mode( + self.runner.audit.path or "")) + self.pause() + elif key == "3": + self.console.show_lines(catalogue_lines()) + self.pause() + elif key == "4": + self.run_tool("bf-check-local", args=("--check",)) + self.pause() + elif key == "5": + self.run_tool("bf-check-enrollment", args=("--check",)) + self.pause() + elif key == "6": + self.run_tool("bf-check-ready", args=("--check",)) + self.pause() + elif key == "7": + if self.run_tool("bf-diagnostics") is None: + self.run_tool("bf-status") + self.pause() + elif key == "8": + self.run_tool(config.TOOL_LIVE_HW_CHECK) + self.pause() + elif key == "9": + self.run_tool("bf-remote-status", args=("--check",)) + self.pause() + elif key == "10": + self.run_tool("bf-release") + self.pause() + elif key == "11": + self.tool_hardware_inventory() + elif key == "12": + self.tool_support_bundle() + elif key == "13": + self.tail_file(self.runner.audit.path or config.DEFAULT_LOG_FILE, + "Konsol denetim kaydı") + self.pause() + + def run_tool(self, tool: str, args: Sequence[str] = (), title: str = ""): + """Run a repository diagnostics tool (read-only screen).""" + try: + path = self.repo.tool_path(tool) + except Refused as exc: + self.console.exception(exc) + return None + argv = [path] + list(args) + return self.runner.run_readonly(argv, title=title or tool) + + def tool_git_pull(self) -> None: + self.runner.confirm_and_run( + ["git", "-C", self.repo.root, "pull", "--ff-only"], + label="depoyu güncelle ({0})".format(self.repo.root), destructive=True, + ) + self.console.note("Depo değişmiş olabilir; konsolu yeniden başlatın.") + self.pause() + + def tool_hardware_inventory(self) -> None: + try: + path = self.repo.tool_path("bf-hardware-inventory.sh") + except Refused as exc: + self.console.exception(exc) + self.pause() + return + dealer = self.ask_dealer() + if dealer is None: + return + out_dir = "/var/lib/blueforce/inventory" if self.runner.is_root() \ + else os.path.join(os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), + "blueforce", "inventory") + argv = [path, "--dealer-id", dealer, "--out-dir", out_dir] + self.runner.run_readonly(argv, title="Donanım envanteri: {0}".format( + operations.dealer_to_device_id(dealer))) + self.pause() + + def tool_support_bundle(self) -> None: + self.run_tool("bf-support-bundle", args=("--out-dir", config.SUPPORT_BUNDLE_OUT_DIR)) + self.pause() + + # ------------------------------------------------------------------ + # Category: Cihaz bilgisi (read-only) + # ------------------------------------------------------------------ + def category_info(self) -> None: + while True: + key = self.ask_menu(tui.info_menu()) + if key is None: + return + if key == "1": + self.console.show_lines(operations.identity_lines()) + elif key == "2": + self.run_tool("bf-status") + elif key == "3": + self.console.show_lines(operations.state_lines()) + elif key == "4": + self.info_network() + elif key == "5": + self.info_resources() + elif key == "6": + self.info_packages() + elif key == "7": + self.info_services() + elif key == "8": + self.info_journal() + elif key == "9": + if self.require_inventory(): + self.console.show_lines(operations.targets_lines(self.inventory)) + elif key == "10": + self.info_resolve_target() + continue + self.pause() + + def _run_readonly(self, argv: Sequence[str], title: str) -> None: + self.runner.run_readonly(argv, title=title) + + def info_network(self) -> None: + self._run_readonly(["ip", "-brief", "address"], "Ağ arayüzleri") + self._run_readonly(["ip", "route", "show", "default"], "Varsayılan rotalar") + if os.path.isfile("/etc/resolv.conf"): + self._run_readonly(["awk", "/^nameserver/{print}", "/etc/resolv.conf"], + "DNS sunucuları (yalnız nameserver satırları)") + self._run_readonly(["wg", "show", "wg0", "listen-port"], "WireGuard dinleme portu") + self._run_readonly(["sh", "-c", "wg show wg0 peers | wc -l"], "WireGuard eş sayısı") + self._run_readonly(["wg", "show", "wg0", "latest-handshakes"], + "WireGuard el sıkışma zamanları (anahtar yok)") + self.console.note("WireGuard özel ve ön paylaşımlı anahtarları asla istenmez, gösterilmez ve kaydedilmez.") + + def info_resources(self) -> None: + self._run_readonly(["df", "-h"], "Dosya sistemleri") + self._run_readonly(["free", "-h"], "Bellek") + self._run_readonly(["uptime"], "Yük ortalaması ve açık kalma süresi") + + def info_packages(self) -> None: + self._run_readonly( + ["dpkg-query", "-W", "-f=${Package} ${Version} ${Status}\n", + "docker-ce", "wireguard", "rustdesk", "xrdp", "openssh-server"], + "Kurulu sabit paketler", + ) + + def info_services(self) -> None: + self._run_readonly( + ["systemctl", "is-active", "ssh", "sshd", "xrdp", "rustdesk", "docker", + "wg-quick@wg0", "meshagent", "cron"], + "Servis durumu (salt okunur)", + ) + + def info_journal(self) -> None: + self._run_readonly(["journalctl", "-p", "err", "-n", "20", "--no-pager"], + "Son journal hataları (20)") + + def info_resolve_target(self) -> None: + self.console.section("Hedefi çözümle") + self.console.note("Bayi numarası, grup adı, 'all' ya da bf- ana makine adı kabul edilir.") + value = self.console.read(config.MSG_PROMPT_TARGET) + if value is None or not value.strip(): + return + if not self.require_inventory(): + self.pause() + return + mode = config.MODE_ANY + answer = self.console.confirm("Bu hedef dalga kapsamlı bir işlem için mi?") + if answer: + mode = config.MODE_WAVE + try: + target = operations.resolve_target(value, mode, self.inventory) + except Refused as exc: + self.console.exception(exc) + self.pause() + return + self.console.kv("Tür", target.kind) + self.console.kv("Ansible --limit", target.limit) + self.console.kv("Dalga", target.wave or "yok") + self.console.kv("Cihaz sayısı", str(target.count)) + self.console.note("Örnek: ansible-playbook -i {0} {1}/bf-ping.yml --limit {2}".format( + self.inventory.labels(), config.PLAYBOOK_DIR_REL, target.limit)) + self.pause() + + # ------------------------------------------------------------------ + # Local helpers + # ------------------------------------------------------------------ + def show_file(self, path: str, title: str) -> None: + self.console.section(title) + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError: + self.console.warn("{0} okunamadı veya yok.".format(path)) + return + for line in lines: + self.console.write(" " + line) + + def tail_file(self, path: str, title: str, lines: int = 40) -> None: + if not os.path.isfile(path): + self.console.warn("{0} yok.".format(path)) + return + self.runner.run_readonly(["tail", "-n", str(lines), path], title=title) + + +# --------------------------------------------------------------------------- +# Non-interactive listings +# --------------------------------------------------------------------------- +def catalogue_lines() -> List[str]: + lines: List[str] = [] + for title, ops in ((config.LIST_TITLE_REPORTS, operations.REPORT_OPERATIONS), + (config.LIST_TITLE_MAINTENANCE, operations.MAINTENANCE_OPERATIONS)): + lines.append("") + lines.append(title) + for index, op in enumerate(ops, start=1): + lines.append(" {0:>2}) {1:<34} {2:<36} kapsam={3:<8} yıkıcı={4}".format( + index, op.ident, op.playbook, op.declared_scope, "evet" if op.destructive else "hayır")) + lines.append("") + lines.append(config.LIST_NOTE_WAVE) + lines.append(config.LIST_NOTE_RELEASE) + lines.append(config.LIST_NOTE_PLAYBOOK) + return lines + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.version: + sys.stdout.write("{0} {1}\n".format(config.PROGRAM_NAME, config.VERSION)) + return config.EXIT_OK + + if args.lang not in config.SUPPORTED_LANGS: + sys.stderr.write(config.MSG_LANG_UNSUPPORTED.format( + value=args.lang, supported=", ".join(config.SUPPORTED_LANGS)) + "\n") + return config.EXIT_USAGE + + try: + repo = resolve_repo(args.repo) + except Refused as exc: + sys.stderr.write(exc.message + "\n") + if exc.detail: + sys.stderr.write(exc.detail + "\n") + return config.EXIT_USAGE + + palette = config.Palette(tui.color_enabled( + no_color=args.no_color, mode="auto", is_tty=tui.stream_is_tty(sys.stdout))) + console = tui.Console(palette=palette) + + audit = runner.AuditLog(requested=args.log_file) + audit.initialise() + if audit.warning: + console.warn(audit.warning) + + inventory_error = "" + try: + inventory_path = repo.find_inventory(args.inventory) + inventory = Inventory.load(inventory_path) + except Refused as exc: + inventory = Inventory(path=None, groups={}) + inventory_error = exc.message + ("\n" + exc.detail if exc.detail else "") + except (OSError, ValueError) as exc: + inventory = Inventory(path=None, groups={}) + inventory_error = config.MSG_INVENTORY_UNREADABLE.format(path=args.inventory) + "\n" + str(exc) + + # Non-interactive listings first: they need no terminal and no Ansible. + if args.list: + console.show_lines(catalogue_lines()) + return config.EXIT_OK + if args.list_targets: + console.show_lines(operations.targets_lines(inventory)) + return config.EXIT_OK + + console.note("{0} {1} — {2}".format(config.PROGRAM_NAME, config.VERSION, config.SCREEN_SUBTITLE)) + console.kv("Depo", repo.root) + console.kv("Envanter", inventory.labels()) + console.kv("Denetim kaydı", audit.path or "yok") + if args.dry_run: + console.warn(config.MSG_DRY_RUN) + if args.check: + console.info(config.MSG_CHECK_MODE) + if not audit.ready: + console.warn(config.MSG_LOG_NONE) + if not inventory.resolved: + console.warn(inventory_error or config.MSG_INVENTORY_MISSING.format( + dir=repo.path(config.INVENTORY_DIR_REL))) + console.note("Filo kategorileri çalışmayacak; yerel kurulum ve bilgi ekranları kullanılabilir.") + if not runner.Runner.have_ansible(): + console.warn(config.MSG_ANSIBLE_MISSING) + + audit.write(config.AUDIT_SESSION, ( + "{0} {1} başlangıç uid={2} repo={3} dry_run={4} check={5} full={6}" + ).format(config.PROGRAM_NAME, config.VERSION, os.getuid(), repo.root, + args.dry_run, args.check, args.full)) + + app = App( + repo=repo, + console=console, + runner_=runner.Runner(console, audit=audit, dry_run=args.dry_run, check_mode=args.check), + inventory=inventory, + full_mode=args.full, + check_mode=args.check, + inventory_error=inventory_error, + inventory_arg=args.inventory, + ) + try: + code = app.run() + except KeyboardInterrupt: + console.write("") + console.note("Kullanıcı tarafından kesildi (Ctrl+C).") + return 130 + return code + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/bfos/config.py b/bfos/config.py new file mode 100644 index 0000000..5e32a88 --- /dev/null +++ b/bfos/config.py @@ -0,0 +1,381 @@ +"""Static configuration for the bfos operator console. + +Everything an operator can read lives here, in Turkish, so the wording can be +reviewed and changed in one place. Identifiers and comments stay English. + +Rules kept in this module: + * standard library only (Python 3.8+), no third-party import, + * no side effect at import time (no directory is created, nothing is read), + * thresholds and allowlists are shared with the Ansible playbooks/docs: the + canonical wave order and the dealer-number pattern come from docs/02 and + docs/10 exactly as admin/lib/targets.sh defines them. +""" + +# --------------------------------------------------------------------------- +# Identity and version +# --------------------------------------------------------------------------- +PROGRAM_NAME = "bfos" +VERSION = "1.0.0" +SCREEN_TITLE = "BLUEFORCE FIELD OS" +SCREEN_SUBTITLE = "Saha cihazı operatör konsolu" +PROGRAM_DESCRIPTION = "Blueforce saha cihazı operatör konsolu (Türkçe)" + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +DEFAULT_LOG_FILE = "/var/log/blueforce-console.log" +LOG_FALLBACK_SUBDIR = ("blueforce", "blueforce-console.log") +LOG_FILE_MODE = 0o600 +LOG_DIR_MODE = 0o700 + +INSTALLER_REL = "scripts/install/blueforce-install.sh" +PLAYBOOK_DIR_REL = "ansible/playbooks" +DIAGNOSTICS_DIR_REL = "scripts/diagnostics" +GROUP_VARS_REL = "ansible/inventory/group_vars/all.yml" +INVENTORY_DIR_REL = "ansible/inventory" +INVENTORY_EXAMPLE_REL = "ansible/inventory/hosts.example.yml" +INVENTORY_CANDIDATES = ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini") + +STATE_FILE = "/var/lib/blueforce/state.json" # nosec - read-only whitelist +INSTALL_STATE_FILE = "/var/lib/blueforce/install-state" +INSTALL_LOG_FILE = "/var/log/blueforce-install.log" +TOOL_BIN_DIR = "/usr/local/bin" +ADMIN_KEY_FILE = "~/.ssh/blueforce_id_ed25519" +SUPPORT_BUNDLE_OUT_DIR = "/var/tmp" + +# --------------------------------------------------------------------------- +# Rules, allowlists and thresholds (mirrors admin/lib/targets.sh + docs/10) +# --------------------------------------------------------------------------- +DEALER_PATTERN = r"^[0-9]{8}$" +HOST_PATTERN = r"^bf-[0-9]{8}$" + +# Canonical rollout order. One place, so the menu, the gate checks and the +# fleet-wide loop can never disagree. +WAVES = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") +PRODUCTION_WAVE = "production" + +# Target selection modes. +MODE_ANY = "any" +MODE_WAVE = "wave" +MODE_RELEASE = "release" + +# Scope names derived from the playbook file itself. +SCOPE_SIMPLE = "simple" +SCOPE_WAVE = "wave" +SCOPE_RELEASE = "release" + +SERVICE_ALLOWLIST = ("blueforce-agent", "docker", "ssh", "sshd", "systemd-journald", "cron") +SCRIPT_DIRS = ("/opt/blueforce/bin", "/usr/local/sbin") + +DISK_WARN_MIN = 1 +DISK_WARN_MAX = 100 +DEFAULT_REBOOT_GRACE = 300 +DEFAULT_LOG_LINES = 500 + +# Non-secret identity/lifecycle keys of state.json (whitelist, mirrors bf-menu). +STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id") + +# Central endpoint keys read from group_vars/all.yml for the preparation screens. +CENTRAL_ENDPOINT_KEYS = ( + "wg_subnet", + "wg_interface", + "wg_hub_endpoint", + "monitoring_endpoint", + "monitoring_job", + "update_repo_url", + "update_repo_suite", + "meg_image_tag", + "support_bundle_dir", +) + +# Local diagnostics tools the console can drive (resolved in /usr/local/bin +# first, then in the checkout under scripts/diagnostics/). +DIAGNOSTIC_TOOLS = ( + "bf-status", + "bf-diagnostics", + "bf-check-local", + "bf-check-enrollment", + "bf-check-ready", + "bf-release", + "bf-remote-status", + "bf-support-bundle", + "bf-hardware-inventory.sh", +) + +TOOL_ENROLL_STATUS = "bf-enrollment-status" +TOOL_LIVE_HW_CHECK = "bf-live-hw-check" + +# Language: this console is Turkish-only on purpose (user decision). The option +# exists so a future translation has a single switch to grow into. +SUPPORTED_LANGS = ("tr",) +DEFAULT_LANG = "tr" + +# Layout and exit codes (the bash console uses the same contract). +CONSOLE_WIDTH = 78 +EXIT_OK = 0 +EXIT_FAIL = 1 +EXIT_USAGE = 2 + +# Minimal Python version this console supports. +MIN_PYTHON = (3, 8) + +# --------------------------------------------------------------------------- +# ANSI palette +# --------------------------------------------------------------------------- +ANSI = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", +} + + +class Palette: + """ANSI codes, or empty strings when colour is switched off.""" + + __slots__ = ( + "enabled", + "reset", + "bold", + "dim", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + ) + + def __init__(self, enabled: bool) -> None: + self.enabled = enabled + for name, code in ANSI.items(): + setattr(self, name, code if enabled else "") + + def paint(self, text: str, *names: str) -> str: + """Wrap TEXT in the given colour names (ignored when disabled).""" + if not self.enabled or not names: + return text + prefix = "".join(getattr(self, name) for name in names) + return "{0}{1}{2}".format(prefix, text, self.reset) + + +# --------------------------------------------------------------------------- +# Turkish messages +# --------------------------------------------------------------------------- +# Generic messages +MSG_QUIT = "Konsoldan çıkılıyor." +MSG_BACK = "Geri" +MSG_QUIT_ITEM = "Çıkış" +MSG_AT_TOP = "Zaten ana menüdesiniz." +MSG_UNKNOWN_CHOICE = "Geçersiz seçim: '{choice}'. Lütfen menüdeki numaralardan birini yazın." +MSG_MENU_RANGE = "Lütfen 1 ile {max} arasında bir numara yazın." +MSG_PRESS_ENTER = "Devam etmek için Enter'a basın" +MSG_CANCELLED = "İptal edildi; hiçbir şey çalıştırılmadı." +MSG_CANCELLED_RUN = "İşlem iptal edildi, komut çalıştırılmadı." +MSG_INVALID_INPUT = "Girdi anlaşılamadı: {value}" +MSG_REQUIRED = "Bu alan boş bırakılamaz." +MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor." + +# Interpreter / environment +MSG_PYTHON_MISSING = ( + "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n" + "Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" +) +MSG_PYTHON_OLD = ( + "HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n" + "Yedek yol (bash): sudo admin/bf-menu" +) +MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}." +MSG_REPO_INVALID = ( + "HATA: {path} bir Blueforce deposu gibi görünmüyor (ansible/ ve scripts/ bulunamadı).\n" + "Depoyu klonlayıp konsolu oradan çalıştırın veya --repo YOL ile yolu belirtin." +) +MSG_ANSIBLE_MISSING = ( + "ansible-playbook bu makinede kurulu değil; filo işlemleri çalıştırılamaz.\n" + "Merkez makinaya kurun: sudo apt install ansible (veya: pipx install ansible-core)" +) +MSG_INVENTORY_MISSING = ( + "Ansible envanteri bulunamadı: {dir}\n" + "Gerçek envanteri oluşturun: cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml " + "ve bf-<8 haneli bayi no> cihazları doldurun." +) +MSG_INVENTORY_EXAMPLE_ONLY = ( + "Yalnızca örnek envanter var (örnek cihaz kimlikleri içerir): {path}\n" + "Gerçek cihazlara karşı kullanmayın; kopyalayıp gerçek kimlikleri yazın." +) +MSG_INVENTORY_UNREADABLE = "Envanter okunamıyor: {path}" +MSG_ROOT_REQUIRED = ( + "Bu işlem BU cihazda root yetkisi gerektirir.\n" + "Konsolu tekrar sudo ile başlatın: sudo admin/bf" +) +MSG_TOOL_MISSING = ( + "{tool} bulunamadı (/usr/local/bin altında ve depo içinde yok).\n" + "Kurulacak komut: sudo install -m 0755 scripts/diagnostics/{tool} /usr/local/bin/{tool}" +) +MSG_INSTALLER_MISSING = ( + "Kurulum betiği bulunamadı: {path}\n" + "--repo ile doğru depo yolunu gösterin." +) +MSG_PLAYBOOK_MISSING = ( + "Playbook bulunamadı: {path}\n" + "Konsolu deponun içinden çalıştırın (--repo YOL)." +) + +# Target selection +MSG_TARGET_NONE = "Hedef seçilmedi; komut üretilmedi." +MSG_TARGET_EMPTY = "Cihaz seçilmedi." +MSG_TARGET_DEALER_INVALID = ( + "Geçersiz bayi numarası: '{value}'. 8 hane olmalı, boşluk ve harf olmamalı (örn. 12010101)." +) +MSG_TARGET_UNKNOWN = "'{value}' bir bayi numarası (8 hane), grup ya da 'all' değil." +MSG_TARGET_DEVICE_UNKNOWN = ( + "{host} envanterde yok ({inventory}).\n" + "Cihazı önce ilgili dalga grubuna ekleyin." +) +MSG_TARGET_DEVICE_NO_WAVE = ( + "{host} onaylı bir dalgada değil. Dalga kapsamlı işlemler böyle bir cihazı reddeder (fail-closed)." +) +MSG_TARGET_GROUP_UNKNOWN = "'{group}' grubu envanterde yok. Bilinen gruplar: {groups}" +MSG_TARGET_GROUP_NOT_WAVE = ( + "'{group}' onaylı bir dalga değil. Dalga kapsamlı işlemler için: {waves}" +) +MSG_TARGET_GROUP_EMPTY = "'{group}' grubu boş; boş hedef reddedildi." +MSG_TARGET_ALL_EMPTY = "Envanterde hiç cihaz yok; boş hedef reddedildi." +MSG_TARGET_RELEASE_DEVICE = ( + "Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez, dalga grubu seçin." +) +MSG_TARGET_RELEASE_ALL = ( + "Sürüm kapılı işlemler için filo geneli reddedildi: güncelleme dalga dalga yapılır." +) +MSG_TARGET_KNOWN_GROUPS = "Bilinen gruplar: {groups}" + +# Gates +MSG_GATE_PLAN_TITLE = "Güvenlik kapıları" +MSG_GATE_ALREADY = "Bu işlem için ek kapı onayı gerekmiyor." +MSG_GATE_WAVE_TOKEN = ( + "Dalga onayı istendi: '{wave}'. Sürüm kapılı işlemler yalnızca yazılı dalga onayıyla koşar." +) +MSG_GATE_WAVE_TOKEN_ASK = "Onay için dalga adını aynen yazın ({wave}): " +MSG_GATE_WAVE_REFUSED = "Dalga onayı verilmedi; işlem yapılmadı." +MSG_GATE_GATE_FLAG = "Onayla birlikte playbook'a -e wave_gate_confirmed=true iletilecek." +MSG_GATE_PRODUCTION = ( + "DALGA 'production' CANLI FİLODUR. Playbook, operation/üretim onayı olmadan koşmayı reddeder." +) +MSG_GATE_PRODUCTION_ASK = "Canlı filo onayı için 'production' yazın: " +MSG_GATE_PRODUCTION_APPROVAL = ( + "ÜRETİM ONAYI: bu komut canlı filoyu değiştirir ve -e production_override=true iletilecek. Onaylıyor musunuz?" +) +MSG_GATE_PRODUCTION_REFUSED = "Üretim onayı verilmedi; işlem yapılmadı." +MSG_GATE_FLEET_TOKEN = ( + "FİLO GENELİ: komut envanterdeki tüm cihazları ({count}) hedefliyor. Bu en geniş kapsamdır." +) +MSG_GATE_FLEET_TOKEN_ASK = "Filo geneli onayı için 'all' yazın: " +MSG_GATE_FLEET_REFUSED = "Filo geneli onayı verilmedi; işlem yapılmadı." +MSG_GATE_WAVE_BY_WAVE = ( + "Bu playbook dalga kapsamlı: filo, dalga dalga işlenir ve ilk hatada DURUR.\n" + "Sıra: {order}" +) +MSG_GATE_WAVE_LOOP_ASK = "Dalga '{wave}' ({count} cihaz) için onay vermek üzere dalga adını yazın: " +MSG_GATE_WAVE_SKIPPED = "'{wave}' dalgası operatör tarafından atlandı." +MSG_GATE_WAVE_FAILED = "'{wave}' dalgası başarısız oldu (çıkış kodu {rc}); sıra DURDURULDU, sonraki dalgaya dokunulmadı." +MSG_GATE_WAVE_NOT_RUN = "'{wave}' dalgası tamamlanmadı; sıra DURDURULDU." +MSG_GATE_START_FIRST = "İlk dalga ile başlansın mı?" +MSG_GATE_ORDER = "lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production" +MSG_GATE_SCOPE_MISMATCH = ( + "RED: {playbook} için konsol kapsamı '{declared}' bekliyor, playbook '{actual}' diyor.\n" + "Playbook kendi kapılarının kaynağıdır; konsol tablosu güncellenmeden bu playbook çalıştırılmaz." +) +MSG_GATE_NO_TARGET = "Hedef seçilmedi; komut üretilmedi (kapı kuralı)." + +# Prompts / extra variables +MSG_PROMPT_DEALER = "Bayi numarası (8 hane): " +MSG_PROMPT_TARGET = "Hedef (bayi no, grup ya da 'all'): " +MSG_PROMPT_CHOICE = "Seçim > " +MSG_PROMPT_PACKAGES = "Paketler (virgülle ayrılmış, paket=sürüm): " +MSG_PROMPT_SERVICE = "Servis adı: " +MSG_PROMPT_SCRIPT = "Betik yolu (mutlak): " +MSG_PROMPT_SCRIPT_ARGS = "Betik argümanları (isteğe bağlı): " +MSG_PROMPT_GRACE = "Yeniden başlatma için bekleme süresi (saniye) [{default}]: " +MSG_PROMPT_DISK = "Uyarı eşiği yüzdesi (boş = playbook varsayılanı 80): " +MSG_PROMPT_LOG_LINES = "Cihaz başına log satırı [{default}]: " +MSG_PROMPT_VERSION = "Beklenen Field OS sürümü (boş = sapma denetimi yok): " + +MSG_PKG_HINT = "Yalnız kesin sürüm: paket=sürüm (aralık, 'latest' ve boş liste reddedilir)." +MSG_PKG_REQUIRED = "En az bir sabitlenmiş paket gerekir." +MSG_PKG_INVALID = "Geçersiz pin: '{value}'. Beklenen biçim: paket=sürüm (örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." +MSG_PKG_LATEST = ( + "Reddedildi: '{value}' kesin bir sürüm değil. 'latest' politikası yoktur; " + "pinlenmiş sürüm yazın (docs/10)." +) +MSG_SERVICE_HINT = "İzinli servisler: {allowlist}" +MSG_SERVICE_INVALID = "Reddedildi: '{value}' izinli servis listesinde değil (playbook da reddederdi)." +MSG_SCRIPT_ABSOLUTE = "Betik yolu mutlak olmalı (/ ile başlamalı)." +MSG_SCRIPT_OUTSIDE = "Reddedildi: '{value}' izinli dizinlerin dışında ({dirs})." +MSG_GRACE_INVALID = "Bekleme süresi tam sayı saniye olmalı." +MSG_PCT_INVALID = "Eşik {min} ile {max} arasında olmalı." +MSG_LINES_INVALID = "Satır sayısı pozitif bir tam sayı olmalı." + +# Command / run flow +MSG_COMMAND_TITLE = "Çalıştırılacak komut" +MSG_RUN_ASK = "Bu komut çalıştırılsın mı? [y=çalıştır / c=--check ile çalıştır / N=iptal]: " +MSG_DESTRUCTIVE_WARN = "DURUM DEĞİŞTİREN İŞLEM: {label}" +MSG_DESTRUCTIVE_SUB = "Onaylamadan önce yukarıdaki komutu okuyun; seçilen cihazları değiştirir." +MSG_DESTRUCTIVE_GATE = ( + "Yıkıcı işlem kapısı: {label} seçilen cihazları değiştirir. " + "Bu onaydan sonra komut üretilir ve çalıştırmadan önce ayrıca sorulur." +) +MSG_ASK_YES_NO = "{question} [e/H]: " +MSG_CHECK_MODE = "Check modu: komut --check ile çalışacak, hiçbir değişiklik uygulanmayacak." +MSG_DRY_RUN = "DENEME (dry-run) MODU: komutlar yazılır, asla çalıştırılmaz." +MSG_DRY_RUN_WARN = "dry-run: yukarıdaki komut ÇALIŞTIRILMADI." +MSG_RUN_OK = "Komut başarıyla tamamlandı (çıkış kodu 0)." +MSG_RUN_FAIL = "Komut {rc} çıkış koduyla başarısız oldu." +MSG_RUN_ABORTED = "Komut {signal} ile kesildi." +MSG_LOG_FALLBACK = "{requested} yazılamıyor; denetim kaydı {fallback} dosyasına yazılıyor (mod 600)." +MSG_LOG_NONE = "Yazılabilir denetim kaydı bulunamadı; bu oturum kaydedilmeyecek." + +# Simple (default) menu +SIMPLE_MENU_TITLE = SCREEN_TITLE +SIMPLE_MENU_SUBTITLE = "Bu cihazı kurmak ve durumunu görmek için 1 veya 2 yazın" +SIMPLE_ITEM_INSTALL_LABEL = "Kur" +SIMPLE_ITEM_INSTALL_DESC = "Bu cihazı Field OS standardına getir (kurulum ekranı)" +SIMPLE_ITEM_STATUS_LABEL = "Durum" +SIMPLE_ITEM_STATUS_DESC = "Kurulum ve cihaz durumunu göster (salt okunur)" +SIMPLE_ITEM_EXIT_LABEL = "Çıkış" +SIMPLE_ITEM_EXIT_DESC = "Konsoldan çık" +SIMPLE_ADVANCED_LABEL = "Detaylı mod" +SIMPLE_ADVANCED_DESC = "Filo işlemleri, merkez hazırlığı, araçlar ve 21 playbook" + +# Full (detailed) menu +FULL_MENU_TITLE = "Detaylı mod" +FULL_MENU_SUBTITLE = "Tüm operasyonlar; güvenlik kapıları her işlemde geçerlidir" +CAT_INSTALL = "Kurulum" +CAT_DEVICES = "Cihaz işlemleri" +CAT_CENTRAL = "Merkez hazırlığı" +CAT_TOOLS = "Araçlar" +CAT_INFO = "Cihaz bilgisi" + +MSG_FULL_MODE_HINT = "Detaylı mod açık: filo işlemleri ve araçlar görünür." +MSG_SIMPLE_MODE_HINT = "Basit mod: 1) Kur, 2) Durum, 3) Çıkış. Detaylı özellikler için 'd' yazın ya da --full ile başlatın." + +# Audit log +AUDIT_SESSION = "SESSION" +AUDIT_TARGET = "TARGET" +AUDIT_RUN = "RUN" +AUDIT_DRYRUN = "DRYRUN" +AUDIT_OK = "OK" +AUDIT_FAIL = "FAIL" +AUDIT_CANCEL = "CANCEL" +AUDIT_STOP = "STOP" + +# Non-interactive listings +LIST_TITLE_REPORTS = "Salt okunur filo raporları (cihazlara dokunmaz)" +LIST_TITLE_MAINTENANCE = "Filo bakım işlemleri (yıkıcı olanlar iki kez onay ister)" +LIST_NOTE_WAVE = "scope=wave -> -e operation_wave= -e operation_confirmed=true" +LIST_NOTE_RELEASE = "scope=release -> -e release_wave= + yazılı dalga onayı" +LIST_NOTE_PLAYBOOK = "Kapsam her playbook dosyasından okunur; uyuşmazsa çalıştırılmaz." diff --git a/bfos/operations.py b/bfos/operations.py new file mode 100644 index 0000000..aa60892 --- /dev/null +++ b/bfos/operations.py @@ -0,0 +1,1193 @@ +"""Operation model for the bfos operator console. + +This module is the behaviour contract of the console, ported from the proven +bash console (``admin/bf-menu`` + ``admin/lib/targets.sh``) so the two entry +points can never disagree: + + * the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared + scope (simple / wave / release) and destructive flag, + * the local install flow (``scripts/install/blueforce-install.sh``), + * the local diagnostics tools (``bf-status``, ``bf-diagnostics``, + ``bf-support-bundle``, ``bf-release``, ``bf-remote-status`` ...), + * target selection: dealer number ``^[0-9]{8}$``, group/wave, or ``all``, + * the safety gates: scope cross-check against the playbook file, production + override, release/wave tokens, destructive second confirmation, the + fleet-wide wave-by-wave loop that stops at the first failure. + +Design rules: + * no UI and no execution: every function here is pure or reads a file, and + every command is *produced* as an argv list for :mod:`bfos.runner`, + * refusals are raised as :class:`Refused` carrying a Turkish operator + message; nothing is auto-confirmed or silently downgraded, + * standard library only, Python 3.8+. +""" + +from __future__ import annotations + +import json +import os +import platform +import re +import socket +import stat +from dataclasses import dataclass, field, replace +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from . import config + +# --------------------------------------------------------------------------- +# Refusals +# --------------------------------------------------------------------------- + + +class Refused(Exception): + """A refusal with an operator-facing Turkish explanation. + + ``detail`` holds optional extra lines (hints, remediation) so the UI can + print the message and the hint separately. + """ + + def __init__(self, message: str, detail: str = "") -> None: + super().__init__(message) + self.message = message + self.detail = detail + + +# --------------------------------------------------------------------------- +# Identity helpers (mirrors admin/lib/targets.sh) +# --------------------------------------------------------------------------- +DEALER_RE = re.compile(config.DEALER_PATTERN) +HOST_RE = re.compile(config.HOST_PATTERN) + + +def dealer_is_valid(value: str) -> bool: + return bool(DEALER_RE.match(value or "")) + + +def dealer_to_host(dealer: str) -> str: + return "bf-{0}".format(dealer) + + +def dealer_to_device_id(dealer: str) -> str: + return "BF-{0}".format(dealer) + + +def host_to_dealer(host: str) -> Optional[str]: + match = HOST_RE.match(host or "") + return match.group(0)[3:] if match else None + + +def is_wave(name: str) -> bool: + return name in config.WAVES + + +def waves_csv() -> str: + return ",".join(config.WAVES) + + +def waves_readable() -> str: + return ", ".join(config.WAVES) + + +# --------------------------------------------------------------------------- +# Operation catalogue -- 21 playbooks, one row each +# --------------------------------------------------------------------------- +PROMPT_NONE = "none" +PROMPT_PACKAGES = "packages" +PROMPT_SERVICE = "service" +PROMPT_SCRIPT = "script" +PROMPT_GRACE = "grace" +PROMPT_DISK_PCT = "disk_pct" +PROMPT_LOG_LINES = "log_lines" +PROMPT_VERSION = "expected_version" + +CATEGORY_REPORT = "report" +CATEGORY_MAINTENANCE = "maintenance" + + +@dataclass(frozen=True) +class Operation: + """One fleet operation, bound to exactly one existing playbook.""" + + ident: str + label: str + playbook: str + declared_scope: str + destructive: bool + prompt: str + description: str + category: str + + @property + def target_mode(self) -> str: + """Target mode the operator is offered for this operation.""" + if self.declared_scope == config.SCOPE_RELEASE: + return config.MODE_RELEASE + if self.declared_scope == config.SCOPE_WAVE: + return config.MODE_WAVE + return config.MODE_ANY + + +# 2.1 Read-only fleet reports -- no playbook in this list changes a device. +REPORT_OPERATIONS: Tuple[Operation, ...] = ( + Operation("ping", "Erişilebilirlik testi (ping)", "bf-ping.yml", "simple", False, PROMPT_NONE, + "SSH erişimi kontrol edilir (salt okunur)", CATEGORY_REPORT), + Operation("uptime", "Çalışma süresi", "bf-uptime.yml", "simple", False, PROMPT_NONE, + "Açık kalma süresi ve sistem yükü", CATEGORY_REPORT), + Operation("disk", "Disk kullanımı", "bf-disk-check.yml", "simple", False, PROMPT_DISK_PCT, + "Disk doluluğu; eşik üstünde uyarı verir", CATEGORY_REPORT), + Operation("docker", "Docker durumu", "bf-docker-status.yml", "simple", False, PROMPT_NONE, + "Docker servisi ve konteynerler (salt okunur)", CATEGORY_REPORT), + Operation("packages", "Paket sürümleri", "bf-package-check.yml", "simple", False, PROMPT_NONE, + "Sabitlenmiş paketlerin kurulu sürümleri", CATEGORY_REPORT), + Operation("security-updates", "Bekleyen güvenlik güncellemeleri", + "bf-security-updates-check.yml", "simple", False, PROMPT_NONE, + "Yalnız rapor; hiçbir paket kurmaz", CATEGORY_REPORT), + Operation("provisioning-status", "Kurulum fazı raporu", "bf-provisioning-status.yml", "wave", + False, PROMPT_NONE, "Cihaz başına state.json fazı", CATEGORY_REPORT), + Operation("enrollment-status", "Kayıt ve merkez kanıtı", "bf-enrollment-status.yml", "wave", + False, PROMPT_NONE, "ENROLLED/READY iddiası ↔ merkez kanıtı", CATEGORY_REPORT), + Operation("fieldos-version", "Field OS sürüm raporu", "bf-fieldos-version.yml", "wave", + False, PROMPT_VERSION, "Cihaz başına sürüm; isteğe bağlı sapma denetimi", + CATEGORY_REPORT), + Operation("remote-channels", "Uzak erişim kanalları", "bf-remote-channels-check.yml", "wave", + False, PROMPT_NONE, "SSH/xRDP/RustDesk/WireGuard hazırlığı", CATEGORY_REPORT), + Operation("offline-ready", "Çevrimdışı hazırlık raporu", "bf-offline-ready-check.yml", "wave", + False, PROMPT_NONE, "İnternetsiz kurulum için yerel ön koşullar", CATEGORY_REPORT), +) + +# 2.2 Fleet maintenance -- destructive rows ask a second, explicit question. +MAINTENANCE_OPERATIONS: Tuple[Operation, ...] = ( + Operation("rdp-restart", "RDP servisini yeniden başlat", "bf-rdp-restart.yml", "simple", True, + PROMPT_NONE, "xrdp servisini yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "bf-rustdesk-restart.yml", + "simple", True, PROMPT_NONE, "RustDesk servisini yeniden başlatır", + CATEGORY_MAINTENANCE), + Operation("wireguard-restart", "WireGuard servisini yeniden başlat", + "bf-wireguard-restart.yml", "simple", True, PROMPT_NONE, + "wg-quick@wg0 servisini yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("service-restart", "İzinli bir servisi yeniden başlat", "bf-service-restart.yml", + "simple", True, PROMPT_SERVICE, "Yalnız izinli servis adları kabul edilir", + CATEGORY_MAINTENANCE), + Operation("gui-off", "Grafik arayüzü kapat (headless)", "bf-gui-off.yml", "wave", True, + PROMPT_NONE, "Dalgayı multi-user.target'e alır", CATEGORY_MAINTENANCE), + Operation("gui-on", "Grafik arayüzü aç (bakım)", "bf-gui-on.yml", "wave", True, PROMPT_NONE, + "Grafik oturumu yeniden başlatır", CATEGORY_MAINTENANCE), + Operation("collect-logs", "Log topla / destek paketi", "bf-collect-logs.yml", "wave", False, + PROMPT_LOG_LINES, "Zaman damgalı journal kaydı ve destek paketi", + CATEGORY_MAINTENANCE), + Operation("run-script", "Onaylı betik çalıştır", "bf-run-script.yml", "wave", True, + PROMPT_SCRIPT, "Yalnız /opt/blueforce/bin ve /usr/local/sbin", + CATEGORY_MAINTENANCE), + Operation("reboot", "Kontrollü yeniden başlatma", "bf-reboot.yml", "release", True, + PROMPT_GRACE, "Sağlık kapılı, sıralı yeniden başlatma", CATEGORY_MAINTENANCE), + Operation("deploy-update", "Onaylı paket güncellemesi kur", "bf-deploy-update.yml", "release", + True, PROMPT_PACKAGES, "Sabit sürümlü paketler, sağlık kapılı", CATEGORY_MAINTENANCE), +) + +ALL_OPERATIONS: Tuple[Operation, ...] = REPORT_OPERATIONS + MAINTENANCE_OPERATIONS + +#: Variables the playbooks use to express their own gates. The console derives +#: the scope from the playbook file and adds a variable only when the playbook +#: actually declares it (a permanent parity rule with admin/bf-menu). +VAR_OPERATION_WAVE = "operation_wave" +VAR_RELEASE_WAVE = "release_wave" +VAR_OPERATION_CONFIRMED = "operation_confirmed" +VAR_WAVE_GATE_CONFIRMED = "wave_gate_confirmed" +VAR_REBOOT_CONFIRMED = "reboot_confirmed" +VAR_PRODUCTION_OVERRIDE = "production_override" +VAR_ALLOW_PRODUCTION_REBOOT = "allow_production_reboot" + +GATE_VARIABLES = ( + VAR_OPERATION_WAVE, + VAR_RELEASE_WAVE, + VAR_WAVE_GATE_CONFIRMED, + VAR_REBOOT_CONFIRMED, + VAR_PRODUCTION_OVERRIDE, + VAR_ALLOW_PRODUCTION_REBOOT, +) + + +def find_operation(ident: str) -> Optional[Operation]: + for operation in ALL_OPERATIONS: + if operation.ident == ident: + return operation + return None + + +def operations_of(category: str) -> Tuple[Operation, ...]: + return tuple(op for op in ALL_OPERATIONS if op.category == category) + + +def catalogue_rows() -> Tuple[Operation, ...]: + """All operations ordered exactly like the bash catalogue.""" + return tuple(sorted(ALL_OPERATIONS, key=lambda op: (op.category != CATEGORY_REPORT, op.ident))) + + +# --------------------------------------------------------------------------- +# Playbook file: the source of truth for a playbook's own gates +# --------------------------------------------------------------------------- +def non_comment_text(text: str) -> str: + """The playbook text without full-line comments. + + Deliberately identical to ``grep -vE '^[[:space:]]*#'`` in bf-menu: a + trailing comment is kept, so the console and the bash console can never + disagree about what a playbook declares. + """ + keep = [line for line in text.splitlines() if not line.lstrip().startswith("#")] + return "\n".join(keep) + + +def playbook_uses_var(text: str, var: str) -> bool: + """True when VAR appears as a whole word outside the playbook's comments.""" + pattern = r"\b{0}\b".format(re.escape(var)) + return re.search(pattern, non_comment_text(text)) is not None + + +def playbook_scope(text: str) -> str: + """Derive wave / release / simple from the playbook's own variables.""" + if playbook_uses_var(text, VAR_RELEASE_WAVE): + return config.SCOPE_RELEASE + if playbook_uses_var(text, VAR_OPERATION_WAVE): + return config.SCOPE_WAVE + return config.SCOPE_SIMPLE + + +class Repository: + """Read-only view of the Blueforce checkout the console runs from.""" + + def __init__(self, root: str) -> None: + self.root = os.path.abspath(root) + + # -- discovery --------------------------------------------------------- + def path(self, rel: str) -> str: + return os.path.join(self.root, rel) + + def looks_valid(self) -> bool: + return os.path.isdir(self.path("ansible")) and os.path.isdir(self.path("scripts")) + + def playbook_path(self, playbook: str) -> str: + return self.path(os.path.join(config.PLAYBOOK_DIR_REL, playbook)) + + def playbook_text(self, playbook: str) -> str: + path = self.playbook_path(playbook) + try: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + except OSError as exc: + raise Refused( + config.MSG_PLAYBOOK_MISSING.format(path=path), str(exc) + ) from exc + + def verify_scope(self, operation: Operation) -> str: + """Cross-check the catalogue scope against the playbook file. + + The playbook is the source of truth for its own gates: a table that + drifted must refuse to run instead of silently dropping a gate. + """ + text = self.playbook_text(operation.playbook) + actual = playbook_scope(text) + if actual != operation.declared_scope: + raise Refused( + config.MSG_GATE_SCOPE_MISMATCH.format( + playbook=operation.playbook, + declared=operation.declared_scope, + actual=actual, + ) + ) + return actual + + def playbook_uses(self, playbook: str, var: str) -> bool: + return playbook_uses_var(self.playbook_text(playbook), var) + + # -- local assets ------------------------------------------------------ + def installer_path(self) -> str: + path = self.path(config.INSTALLER_REL) + if not os.path.isfile(path): + raise Refused(config.MSG_INSTALLER_MISSING.format(path=path)) + return path + + def tool_path(self, tool: str) -> str: + """Preferred path of a diagnostics/maintenance tool (bf_tool_path).""" + candidates = ( + os.path.join(config.TOOL_BIN_DIR, tool), + self.path(os.path.join(config.DIAGNOSTICS_DIR_REL, tool)), + self.path(os.path.join("scripts/maintenance", tool)), + ) + for candidate in candidates: + if os.access(candidate, os.X_OK) and os.path.isfile(candidate): + return candidate + raise Refused(config.MSG_TOOL_MISSING.format(tool=tool)) + + def group_vars_path(self) -> Optional[str]: + path = self.path(config.GROUP_VARS_REL) + return path if os.path.isfile(path) else None + + # -- inventory --------------------------------------------------------- + def inventory_candidates(self) -> Tuple[str, ...]: + base = self.path(config.INVENTORY_DIR_REL) + return tuple(os.path.join(base, name) for name in config.INVENTORY_CANDIDATES) + + def find_inventory(self, explicit: str = "") -> str: + """Resolve the Ansible inventory, or refuse with remediation. + + ``hosts.example.yml`` is never selected automatically: it carries + example device IDs and must only be used after a deliberate copy. + """ + if explicit: + if os.path.isfile(explicit) and os.access(explicit, os.R_OK): + return os.path.abspath(explicit) + raise Refused(config.MSG_INVENTORY_UNREADABLE.format(path=explicit)) + for candidate in self.inventory_candidates(): + if os.path.isfile(candidate) and os.access(candidate, os.R_OK): + return candidate + example = self.path(config.INVENTORY_EXAMPLE_REL) + detail = "" + if os.path.isfile(example): + detail = config.MSG_INVENTORY_EXAMPLE_ONLY.format(path=example) + raise Refused( + config.MSG_INVENTORY_MISSING.format(dir=self.path(config.INVENTORY_DIR_REL)), + detail, + ) + + +# --------------------------------------------------------------------------- +# Inventory parsing (a deliberately small YAML reader, mirrors targets.sh) +# --------------------------------------------------------------------------- +@dataclass +class Inventory: + """group -> ordered hosts, parsed from the Ansible inventory.""" + + path: Optional[str] = None + groups: Dict[str, List[str]] = field(default_factory=dict) + + # -- construction ------------------------------------------------------ + @classmethod + def parse(cls, text: str, path: Optional[str] = None) -> "Inventory": + groups: Dict[str, List[str]] = {} + # Stack of (key, indent) for keys whose value is an empty mapping. + stack: List[Tuple[str, int]] = [] + host_block = False + host_indent = 0 + host_step: Optional[int] = None + host_group = "" + + for raw in text.splitlines(): + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + if ":" not in line: + continue + indent = len(line) - len(line.lstrip(" ")) + key_part, _, value_part = line.partition(":") + key = key_part.strip().strip("'\"").strip() + value = value_part.strip() + if not key: + continue + + # Close blocks that are at or above this key's indentation. + while stack and stack[-1][1] >= indent: + stack.pop() + if host_block and indent <= host_indent: + host_block = False + + if key == "hosts" and not value: + host_block = True + host_indent = indent + host_step = None + host_group = stack[-1][0] if stack else "" + stack.append((key, indent)) + continue + + if host_block and indent > host_indent: + if host_step is None: + host_step = indent + if indent == host_step: + groups.setdefault(host_group, []) + if key not in groups[host_group]: + groups[host_group].append(key) + stack.append((key, indent)) + continue + + if not value: + stack.append((key, indent)) + + return cls(path=path, groups=groups) + + @classmethod + def load(cls, path: str) -> "Inventory": + with open(path, "r", encoding="utf-8") as handle: + return cls.parse(handle.read(), path=path) + + @classmethod + def load_repository(cls, repo: Repository, explicit: str = "") -> "Inventory": + """Load the inventory, or return an empty one marked as unresolved. + + Callers that must refuse on a missing inventory ask the repository + directly (:meth:`Repository.find_inventory`); the UI keeps working with + an unresolved inventory so read-only screens can still explain what is + missing instead of crashing. + """ + try: + resolved = repo.find_inventory(explicit) + except Refused: + return cls(path=None, groups={}) + try: + return cls.load(resolved) + except (OSError, ValueError): + return cls(path=None, groups={}) + + # -- queries ----------------------------------------------------------- + @property + def resolved(self) -> bool: + """True when an inventory was actually found and parsed. + + A path is not required: an inventory parsed from text (tests, callers + that hold the YAML themselves) is just as usable as a file on disk. + """ + return self.path is not None or bool(self.groups) + + def labels(self) -> str: + return self.path or "çözümlenmedi" + + def group_names(self) -> Tuple[str, ...]: + return tuple(self.groups) + + def hosts_of(self, group: str) -> Tuple[str, ...]: + return tuple(self.groups.get(group, ())) + + def group_exists(self, group: str) -> bool: + return group in self.groups + + def host_exists(self, host: str) -> bool: + return any(host in hosts for hosts in self.groups.values()) + + def all_hosts(self) -> Tuple[str, ...]: + hosts: List[str] = [] + for group_hosts in self.groups.values(): + for host in group_hosts: + if host not in hosts: + hosts.append(host) + return tuple(hosts) + + def count_all(self) -> int: + return len(self.all_hosts()) + + def waves_present(self) -> Tuple[str, ...]: + """Canonical waves that exist in the inventory, in rollout order.""" + return tuple(wave for wave in config.WAVES if self.group_exists(wave)) + + def host_wave(self, host: str) -> Optional[str]: + """The approved wave containing HOST, or None.""" + for wave in config.WAVES: + if host in self.groups.get(wave, ()): + return wave + return None + + +# --------------------------------------------------------------------------- +# Target selection (mirrors bf_target_*) +# --------------------------------------------------------------------------- +KIND_HOST = "host" +KIND_GROUP = "group" +KIND_ALL = "all" + + +@dataclass(frozen=True) +class Target: + kind: str + limit: str + wave: str + count: int + label: str + + +def _target_host(dealer: str, mode: str, inventory: Inventory) -> Target: + if not dealer_is_valid(dealer): + raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) + if mode == config.MODE_RELEASE: + raise Refused(config.MSG_TARGET_RELEASE_DEVICE) + if not inventory.resolved: + raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format( + host=dealer_to_host(dealer), inventory=inventory.labels())) + host = dealer_to_host(dealer) + if not inventory.host_exists(host): + raise Refused(config.MSG_TARGET_DEVICE_UNKNOWN.format(host=host, inventory=inventory.labels())) + wave = inventory.host_wave(host) or "" + if not wave and mode != config.MODE_ANY: + raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=host)) + label = "cihaz {0} ({1})".format(dealer_to_device_id(dealer), host) + if wave: + label += " — dalga '{0}'".format(wave) + return Target(kind=KIND_HOST, limit=host, wave=wave, count=1, label=label) + + +def _target_group(group: str, mode: str, inventory: Inventory) -> Target: + if not inventory.resolved or not inventory.group_exists(group): + raise Refused( + config.MSG_TARGET_GROUP_UNKNOWN.format( + group=group, groups=" ".join(inventory.group_names()) or "-" + ) + ) + if mode in (config.MODE_WAVE, config.MODE_RELEASE) and not is_wave(group): + raise Refused(config.MSG_TARGET_GROUP_NOT_WAVE.format(group=group, waves=waves_readable())) + count = len(inventory.hosts_of(group)) + if count == 0: + raise Refused(config.MSG_TARGET_GROUP_EMPTY.format(group=group)) + label = "dalga '{0}' ({1} cihaz)".format(group, count) if is_wave(group) else \ + "grup '{0}' ({1} cihaz)".format(group, count) + wave = group if is_wave(group) else "" + return Target(kind=KIND_GROUP, limit=group, wave=wave, count=count, label=label) + + +def _target_all(mode: str, inventory: Inventory) -> Target: + if mode == config.MODE_RELEASE: + raise Refused(config.MSG_TARGET_RELEASE_ALL) + if not inventory.resolved or inventory.count_all() == 0: + raise Refused(config.MSG_TARGET_ALL_EMPTY) + count = inventory.count_all() + return Target(kind=KIND_ALL, limit="all", wave="", count=count, + label="filo geneli ({0} cihaz)".format(count)) + + +def resolve_target(word: str, mode: str, inventory: Inventory) -> Target: + """Turn one operator word into a validated target. + + Accepts an 8-digit dealer number, a ``bf-`` hostname (convenience), + a group/wave name, or ``all``. Anything else is refused with a Turkish + explanation; nothing is guessed. + """ + word = (word or "").strip() + if not word: + raise Refused(config.MSG_TARGET_EMPTY) + if word == "all": + return _target_all(mode, inventory) + if dealer_is_valid(word): + return _target_host(word, mode, inventory) + if inventory.resolved and inventory.group_exists(word): + return _target_group(word, mode, inventory) + if HOST_RE.match(word) and dealer_is_valid(word[3:]): + return _target_host(word[3:], mode, inventory) + raise Refused( + config.MSG_TARGET_UNKNOWN.format(value=word), + config.MSG_TARGET_KNOWN_GROUPS.format(groups=" ".join(inventory.group_names()) or "-"), + ) + + +def wave_sequence(inventory: Inventory) -> Tuple[Tuple[str, int], ...]: + """Waves present in the inventory with their device counts, in order.""" + return tuple((wave, len(inventory.hosts_of(wave))) for wave in inventory.waves_present()) + + +# --------------------------------------------------------------------------- +# Gates: the confirmation plan (what the operator must state explicitly) +# --------------------------------------------------------------------------- +STEP_TOKEN = "token" +STEP_APPROVAL = "approval" + + +@dataclass(frozen=True) +class GateStep: + """One explicit confirmation the operator has to give before a run.""" + + kind: str + key: str + prompt: str + message: str = "" + token: str = "" + unlocks: Tuple[str, ...] = () + + +@dataclass +class GatesGranted: + """Flags granted by the operator's confirmations. + + ``build_ansible_command`` adds a gate variable only when the matching flag + is granted AND the playbook declares that variable, so a command can never + become less gated than the playbook expects. + """ + + wave_gate_confirmed: bool = False + production_override: bool = False + allow_production_reboot: bool = False + fleet_wide: bool = False + + def grant(self, step: GateStep) -> "GatesGranted": + changes = {name: True for name in step.unlocks if hasattr(self, name)} + return replace(self, **changes) if changes else self + + +def confirmation_plan( + operation: Operation, + scope: str, + target: Target, + wave: str = "", + wave_gate_supported: bool = False, +) -> List[GateStep]: + """The ordered list of confirmations required for one operation run. + + Rules (all preserved from the bash console): + * the production wave requires the word ``production`` **and** a separate + production approval that unlocks ``production_override=true``, + * a release-gated operation (reboot, deploy-update) requires its wave name + to be written out before anything is built; a wave-scoped read-only + report does not (the bash console asks no token for it either), + * a destructive operation requires a second, explicit approval, + * a fleet-wide simple run requires the word ``all``. + """ + steps: List[GateStep] = [] + effective_wave = wave or target.wave + + if scope in (config.SCOPE_WAVE, config.SCOPE_RELEASE): + if effective_wave == config.PRODUCTION_WAVE: + message = config.MSG_GATE_PRODUCTION + if wave_gate_supported: + message = message + "\n" + config.MSG_GATE_GATE_FLAG + steps.append( + GateStep( + kind=STEP_TOKEN, + key="production_token", + prompt=config.MSG_GATE_PRODUCTION_ASK, + message=message, + token=config.PRODUCTION_WAVE, + unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), + ) + ) + steps.append( + GateStep( + kind=STEP_APPROVAL, + key="production_approval", + prompt=config.MSG_GATE_PRODUCTION_APPROVAL, + message=config.MSG_GATE_PRODUCTION, + unlocks=(VAR_PRODUCTION_OVERRIDE, VAR_ALLOW_PRODUCTION_REBOOT), + ) + ) + elif effective_wave and scope == config.SCOPE_RELEASE: + # Release-gated operations never run from a typed word alone: the + # operator has to name the exact wave that is being released. + message = config.MSG_GATE_WAVE_TOKEN.format(wave=effective_wave) + if wave_gate_supported: + message = message + "\n" + config.MSG_GATE_GATE_FLAG + steps.append( + GateStep( + kind=STEP_TOKEN, + key="wave_token", + prompt=config.MSG_GATE_WAVE_TOKEN_ASK.format(wave=effective_wave), + message=message, + token=effective_wave, + unlocks=(VAR_WAVE_GATE_CONFIRMED,) if wave_gate_supported else (), + ) + ) + + if target.kind == KIND_ALL and scope == config.SCOPE_SIMPLE: + steps.append( + GateStep( + kind=STEP_TOKEN, + key="fleet_token", + prompt=config.MSG_GATE_FLEET_TOKEN_ASK, + message=config.MSG_GATE_FLEET_TOKEN.format(count=target.count), + token="all", + unlocks=("fleet_wide",), + ) + ) + + if operation.destructive and not any(step.key == "production_approval" for step in steps): + # The second, explicit approval for a state-changing operation. It is a + # gate (it happens before the command is even built); the run prompt + # after the command has been shown is a separate, final confirmation. + steps.append( + GateStep( + kind=STEP_APPROVAL, + key="destructive_approval", + prompt=config.MSG_DESTRUCTIVE_SUB, + message=config.MSG_DESTRUCTIVE_GATE.format(label=operation.label), + ) + ) + return steps + + +# --------------------------------------------------------------------------- +# Extra -e variables, validated with the rules the playbooks enforce +# --------------------------------------------------------------------------- +PIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$") + + +def parse_packages(text: str) -> List[str]: + pins: List[str] = [] + for chunk in (text or "").split(","): + pin = chunk.strip() + if not pin: + continue + if not PIN_RE.match(pin): + raise Refused(config.MSG_PKG_INVALID.format(value=pin)) + version = pin.partition("=")[2].strip("=").strip() + if not version or version.lower().startswith("latest"): + raise Refused(config.MSG_PKG_LATEST.format(value=pin)) + pins.append(pin) + if not pins: + raise Refused(config.MSG_PKG_REQUIRED) + return pins + + +def packages_expression(pins: Sequence[str]) -> str: + """The ``update_packages`` value: ``["pkg=ver",...]`` like bf_prompt_packages. + + Only the value is returned; the caller pairs it with the variable name, so + exactly one ``update_packages=`` prefix ever reaches the command line. + """ + return "[{0}]".format(",".join('"{0}"'.format(pin) for pin in pins)) + + +def parse_service(text: str) -> str: + name = (text or "").strip() + if name not in config.SERVICE_ALLOWLIST: + raise Refused( + config.MSG_SERVICE_INVALID.format(value=name), + config.MSG_SERVICE_HINT.format(allowlist=" ".join(config.SERVICE_ALLOWLIST)), + ) + return name + + +def parse_script(path: str, args: str = "") -> List[Tuple[str, str]]: + script = (path or "").strip() + if not script.startswith("/"): + raise Refused(config.MSG_SCRIPT_ABSOLUTE) + if not any(script.startswith(directory + "/") for directory in config.SCRIPT_DIRS): + raise Refused( + config.MSG_SCRIPT_OUTSIDE.format(value=script, dirs=" ".join(config.SCRIPT_DIRS)) + ) + pairs = [("script_path", script)] + if (args or "").strip(): + pairs.append(("script_args", args.strip())) + return pairs + + +def parse_grace(text: str) -> str: + value = (text or "").strip() or str(config.DEFAULT_REBOOT_GRACE) + if not value.isdigit(): + raise Refused(config.MSG_GRACE_INVALID) + return value + + +def parse_disk_pct(text: str) -> List[Tuple[str, str]]: + value = (text or "").strip() + if not value: + return [] + if not value.isdigit() or not (config.DISK_WARN_MIN <= int(value) <= config.DISK_WARN_MAX): + raise Refused(config.MSG_PCT_INVALID.format(min=config.DISK_WARN_MIN, max=config.DISK_WARN_MAX)) + return [("disk_warn_pct", value)] + + +def parse_log_lines(text: str) -> str: + value = (text or "").strip() or str(config.DEFAULT_LOG_LINES) + if not value.isdigit() or int(value) < 1: + raise Refused(config.MSG_LINES_INVALID) + return value + + +def parse_expected_version(text: str) -> List[Tuple[str, str]]: + value = (text or "").strip() + return [("fieldos_expected_version", value)] if value else [] + + +def build_extra_vars(prompt: str, answers: Dict[str, str]) -> List[Tuple[str, str]]: + """Validate the operator's answers for one operation's prompt kind.""" + if prompt == PROMPT_NONE: + return [] + if prompt == PROMPT_PACKAGES: + return [("update_packages", packages_expression(parse_packages(answers.get("packages", ""))))] + if prompt == PROMPT_SERVICE: + return [("service_name", parse_service(answers.get("service", "")))] + if prompt == PROMPT_SCRIPT: + return parse_script(answers.get("script", ""), answers.get("script_args", "")) + if prompt == PROMPT_GRACE: + return [("reboot_grace_seconds", parse_grace(answers.get("grace", "")))] + if prompt == PROMPT_DISK_PCT: + return parse_disk_pct(answers.get("disk_pct", "")) + if prompt == PROMPT_LOG_LINES: + return [("log_lines", parse_log_lines(answers.get("log_lines", "")))] + if prompt == PROMPT_VERSION: + return parse_expected_version(answers.get("expected_version", "")) + raise Refused("Konsol hatası: bilinmeyen soru türü '{0}'.".format(prompt)) + + +# --------------------------------------------------------------------------- +# Command construction +# --------------------------------------------------------------------------- +def build_ansible_command( + repo: Repository, + inventory_path: str, + operation: Operation, + scope: str, + target: Optional[Target], + wave: str = "", + extra_vars: Sequence[Tuple[str, str]] = (), + gates: Optional[GatesGranted] = None, + check_mode: bool = False, +) -> List[str]: + """Build the ansible-playbook argv for one operation and one target. + + Single choke point: no command is ever built without a target, and a gated + scope is never built without a wave. + """ + gates = gates or GatesGranted() + if target is None: + raise Refused(config.MSG_GATE_NO_TARGET) + playbook = operation.playbook + + def uses(var: str) -> bool: + return repo.playbook_uses(playbook, var) + + argv = [ + "ansible-playbook", + "-i", inventory_path, + repo.playbook_path(playbook), + "--limit", target.limit, + ] + effective_wave = wave or target.wave + + if scope == config.SCOPE_WAVE: + if not effective_wave: + raise Refused(config.MSG_TARGET_DEVICE_NO_WAVE.format(host=target.limit)) + argv += ["-e", "{0}={1}".format(VAR_OPERATION_WAVE, effective_wave), + "-e", "{0}=true".format(VAR_OPERATION_CONFIRMED)] + if effective_wave == config.PRODUCTION_WAVE and gates.production_override \ + and uses(VAR_PRODUCTION_OVERRIDE): + argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] + elif scope == config.SCOPE_RELEASE: + if not effective_wave or not is_wave(effective_wave): + raise Refused(config.MSG_TARGET_RELEASE_DEVICE) + argv += ["-e", "{0}={1}".format(VAR_RELEASE_WAVE, effective_wave)] + if gates.wave_gate_confirmed and uses(VAR_WAVE_GATE_CONFIRMED): + argv += ["-e", "{0}=true".format(VAR_WAVE_GATE_CONFIRMED)] + if uses(VAR_REBOOT_CONFIRMED): + argv += ["-e", "{0}=true".format(VAR_REBOOT_CONFIRMED)] + if effective_wave == config.PRODUCTION_WAVE: + if gates.production_override and uses(VAR_PRODUCTION_OVERRIDE): + argv += ["-e", "{0}=true".format(VAR_PRODUCTION_OVERRIDE)] + if gates.allow_production_reboot and uses(VAR_ALLOW_PRODUCTION_REBOOT): + argv += ["-e", "{0}=true".format(VAR_ALLOW_PRODUCTION_REBOOT)] + + for name, value in extra_vars: + argv += ["-e", "{0}={1}".format(name, value)] + if check_mode: + argv.append("--check") + return argv + + +def build_install_command( + installer: str, + dealer: str, + offline: bool = False, + resume: bool = False, + only: str = "", + check_mode: bool = False, +) -> List[str]: + """The local installer argv (bash --dealer-id ...).""" + if not dealer_is_valid(dealer): + raise Refused(config.MSG_TARGET_DEALER_INVALID.format(value=dealer)) + argv = ["bash", installer, "--dealer-id", dealer] + if offline: + argv.append("--offline") + if resume: + argv.append("--resume") + if only: + argv += ["--only", only] + if check_mode: + argv.append("--check") + return argv + + +def build_tool_command(tool: str, args: Sequence[str] = (), check_mode: bool = False) -> List[str]: + argv = [tool] + list(args) + if check_mode: + argv.append("--check") + return argv + + +def module_names(repo: Repository) -> Tuple[str, ...]: + """Installer modules (``NN-name.sh``) offered by the single-module action.""" + modules_dir = repo.path(os.path.join("scripts/install/installer/modules")) + try: + names = sorted( + entry for entry in os.listdir(modules_dir) + if re.match(r"^[0-9]{2}-.*\.sh$", entry) + ) + except OSError: + return () + return tuple(name[:-3] for name in names) + + +# --------------------------------------------------------------------------- +# Read-only information reports (returned as text lines for the UI) +# --------------------------------------------------------------------------- +def targets_lines(inventory: Inventory) -> List[str]: + lines: List[str] = [] + lines.append("DALGALAR (yayılma sırası)") + for wave in config.WAVES: + if inventory.group_exists(wave): + lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) + else: + lines.append(" {0:<14} envanterde yok".format(wave)) + others = [group for group in inventory.group_names() if not is_wave(group)] + if others: + lines.append("") + lines.append("DİĞER GRUPLAR") + for group in others: + lines.append(" {0:<14} {1} cihaz".format(group, len(inventory.hosts_of(group)))) + lines.append("") + lines.append("CİHAZLAR") + for host in inventory.all_hosts(): + lines.append(" {0}".format(host)) + lines.append("") + lines.append("Envanter: {0}".format(inventory.labels())) + lines.append("Toplam cihaz: {0}".format(inventory.count_all())) + return lines + + +def wave_gate_state_lines(inventory: Inventory) -> List[str]: + lines = ["DALGA GEÇİŞ KAPISI DURUMU"] + for wave in config.WAVES: + if inventory.group_exists(wave): + lines.append(" {0:<14} {1} cihaz".format(wave, len(inventory.hosts_of(wave)))) + else: + lines.append(" {0:<14} envanterde yok".format(wave)) + lines.append("") + lines.append("Kural: bir dalga, ancak önceki dalga sağlık kapısını ve merkez onayını geçtikten sonra başlar.") + lines.append("Üretim (production) ayrıca yazılı 'production' onayı ve production_override=true gerektirir.") + lines.append("Kaynak: docs/10 — güncelleme ve geri alma politikası.") + return lines + + +def central_checklist_lines() -> List[str]: + return [ + "MERKEZ HAZIRLIK LİSTESİ (bilgilendirme)", + " 1. WireGuard hub: uç nokta, anahtarlar ve her bf- için eş tahsisi (docs/08).", + " 2. Kayıt (enrollment) jeton servisi: tek kullanımlık, TTL sınırlı (docs/29).", + " 3. İzleme: Prometheus/VictoriaMetrics + Grafana, device_id etiketi BF- (docs/14).", + " 4. Uzak erişim: RustDesk hbbs/hbbr veya MeshCentral, WireGuard üzerinden (docs/07).", + " 5. Paket deposu + çevrimdışı anlık görüntü (docs/10, docs/27).", + " 6. Gerçek ana makine adlarıyla Ansible envanteri ve yönetici SSH anahtarı (docs/02, 06, 09).", + "Bu konsol 6'yı doğrular, 1-5'i hazırlar; merkez bileşeni kurmaz.", + ] + + +def _group_vars_value(path: Optional[str], key: str) -> str: + if not path or not os.path.isfile(path): + return "" + pattern = re.compile(r"^[ \t]*{0}[ \t]*:(.*)$".format(re.escape(key))) + try: + with open(path, "r", encoding="utf-8") as handle: + for line in handle: + match = pattern.match(line.rstrip("\n")) + if match: + value = match.group(1) + value = value.split("#", 1)[0].strip() + return value.strip("'\"") + except OSError: + return "" + return "" + + +def central_endpoint_lines(repo: Repository) -> List[str]: + path = repo.group_vars_path() + lines = ["MERKEZ UÇ NOKTALARI (group_vars/all.yml)"] + if path is None: + lines.append(" group_vars/all.yml bulunamadı: {0}".format(repo.path(config.GROUP_VARS_REL))) + return lines + for key in config.CENTRAL_ENDPOINT_KEYS: + value = _group_vars_value(path, key) + if value: + lines.append(" {0:<22} {1}".format(key, value)) + lines.append("") + lines.append("Değerler group_vars/all.yml dosyasından okunur; burada hiçbir sır saklanmaz veya yazılmaz.") + return lines + + +def central_readiness_lines(repo: Repository, inventory: Inventory, inventory_error: str = "") -> List[str]: + lines = ["MERKEZ HAZIRLIK DURUMU"] + if inventory.resolved: + lines.append(" [OK] Envanter: {0} ({1} cihaz)".format(inventory.path, inventory.count_all())) + else: + lines.append(" [UYARI] Envanter çözümlenemedi.") + if inventory_error: + lines.extend(" " + part for part in inventory_error.splitlines()) + path = repo.group_vars_path() + if path: + try: + with open(path, "r", encoding="utf-8") as handle: + placeholders = [line.strip() for line in handle if "PLACEHOLDER" in line] + except OSError: + placeholders = [] + if placeholders: + lines.append(" [UYARI] group_vars/all.yml içinde {0} PLACEHOLDER değeri var:".format(len(placeholders))) + lines.extend(" " + line for line in placeholders) + else: + lines.append(" [OK] group_vars/all.yml içinde PLACEHOLDER kalmamış.") + else: + lines.append(" [UYARI] ansible/inventory/group_vars/all.yml bulunamadı.") + key_path = os.path.expanduser(config.ADMIN_KEY_FILE) + if os.path.isfile(key_path): + lines.append(" [OK] Yönetici SSH anahtarı var: {0} (içeriği okunmaz/yazılmaz)".format(config.ADMIN_KEY_FILE)) + else: + lines.append(" [UYARI] Yönetici SSH anahtarı yok: {0} (docs/06)".format(config.ADMIN_KEY_FILE)) + if inventory.path and "example" in os.path.basename(inventory.path): + lines.append(" [UYARI] Bu örnek bir envanter; gerçek cihazlara yöneltmeyin.") + return lines + + +def split_endpoint(endpoint: str) -> Tuple[str, Optional[int]]: + """Split a configured endpoint into (host, port) — no network access.""" + value = (endpoint or "").strip() + if not value: + return "", None + if "//" in value: + value = value.split("//", 1)[1] + value = value.split("/", 1)[0] + if value.startswith("["): # IPv6 literal + host, _, rest = value.partition("]") + host = host.lstrip("[") + port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None + return host, port + if ":" in value: + host, _, port_text = value.rpartition(":") + if port_text.isdigit(): + return host, int(port_text) + return value, None + if endpoint.startswith("http://"): + return value, 80 + if endpoint.startswith("https://"): + return value, 443 + return value, None + + +def probe_endpoint(host: str, port: Optional[int], timeout: float = 3.0) -> Tuple[str, bool]: + """Resolve HOST and, when PORT is known, try one TCP connection.""" + if not host: + return "", False + try: + infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) + except OSError: + return "", False + if not infos: + return "", False + resolved = str(infos[0][4][0]) + if port is None: + return resolved, True + try: + with socket.create_connection((resolved, port), timeout=timeout): + return resolved, True + except OSError: + return resolved, False + + +def central_probe_lines(repo: Repository, timeout: float = 3.0) -> List[str]: + path = repo.group_vars_path() + lines = ["MERKEZ UÇ NOKTASI DENEMESİ (salt okunur)"] + if path is None: + lines.append(" group_vars/all.yml bulunamadı.") + return lines + for key in ("wg_hub_endpoint", "monitoring_endpoint", "update_repo_url"): + endpoint = _group_vars_value(path, key) + if not endpoint: + continue + host, port = split_endpoint(endpoint) + if not host: + continue + resolved, reachable = probe_endpoint(host, port, timeout=timeout) + if not resolved: + lines.append(" [UYARI] {0}: çözümlenemedi (yer tutucu olabilir)".format(host)) + elif port is None: + lines.append(" [OK] {0} -> {1} (port türetilemedi)".format(host, resolved)) + elif reachable: + lines.append(" [OK] {0}:{1} erişilebilir ({2})".format(host, port, resolved)) + else: + lines.append(" [UYARI] {0}:{1} erişilemiyor ({2})".format(host, port, resolved)) + return lines + + +def state_values(path: str = config.STATE_FILE) -> Dict[str, str]: + """Read state.json through a whitelist of non-secret keys. + + A future token field can never leak into the UI or the audit log. + """ + values: Dict[str, str] = {} + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + return values + if not isinstance(data, dict): + return values + for key in config.STATE_ALLOWED_KEYS: + value = data.get(key) + if value is None or isinstance(value, bool): + continue + values[key] = str(value) + return values + + +def identity_lines(hostname: str = "") -> List[str]: + host = hostname or platform.node() or "bilinmiyor" + dealer = host_to_dealer(host) + lines = ["KİMLİK", " Ana makine adı : {0}".format(host)] + if dealer: + lines.append(" Bayi numarası : {0}".format(dealer)) + lines.append(" Cihaz kimliği (insan) : {0}".format(dealer_to_device_id(dealer))) + else: + lines.append(" [UYARI] Ana makine adı bf-<8 hane> biçiminde değil (docs/02).") + lines.append(" Çekirdek : {0}".format(platform.platform())) + values = state_values() + for key in ("device_id", "phase"): + if key in values: + lines.append(" state.json {0:<10}: {1}".format(key, values[key])) + return lines + + +def state_lines(path: str = config.STATE_FILE) -> List[str]: + lines = ["KURULUM / KAYIT DURUMU"] + values = state_values(path) + if not values: + lines.append(" [UYARI] {0} yok veya okunamadı: cihaz kurulmamış olabilir.".format(path)) + return lines + for key in config.STATE_ALLOWED_KEYS: + if key in values: + lines.append(" {0:<18} {1}".format(key, values[key])) + lines.append("") + lines.append("Yalnız yukarıdaki kimlik/yaşam döngüsü alanları okunur; jeton ve anahtarlar asla yazılmaz.") + lines.append("İleri yönlü durum makinesi: PROVISIONED_OFFLINE -> ENROLLED -> READY (docs/29).") + return lines + + +def environment_lines( + repo: Repository, + log_file: str, + inventory: Inventory, + dry_run: bool, + check_mode: bool, +) -> List[str]: + lines = [ + "ORTAM", + " Konsol : {0} {1}".format(config.PROGRAM_NAME, config.VERSION), + " Depo : {0}".format(repo.root), + " Envanter : {0}".format(inventory.labels()), + " Denetim kaydı : {0}".format(log_file), + " dry-run : {0}".format("evet" if dry_run else "hayır"), + " check modu : {0}".format("evet" if check_mode else "hayır"), + " Python : {0}".format(platform.python_version()), + ] + for tool in (config.TOOL_ENROLL_STATUS, config.TOOL_LIVE_HW_CHECK): + try: + lines.append(" {0:<21}: {1}".format(tool, repo.tool_path(tool))) + except Refused: + lines.append(" {0:<21}: yok".format(tool)) + return lines + + +def file_mode(path: str) -> str: + """Octal permission bits of PATH, or "?" when it cannot be read.""" + try: + return oct(stat.S_IMODE(os.stat(path).st_mode))[2:].zfill(3) + except OSError: + return "?" diff --git a/bfos/runner.py b/bfos/runner.py new file mode 100644 index 0000000..56c8c7a --- /dev/null +++ b/bfos/runner.py @@ -0,0 +1,309 @@ +"""Command execution and the audit log for the bfos console. + +Separation of duties: + + * :mod:`bfos.operations` decides *what* may run and produces an argv list, + * this module decides *whether* it runs (confirmation, dry-run, check mode), + executes it with :mod:`subprocess`, reports the exit code, and writes the + audit record. + +Guarantees kept from the bash console (``admin/bf-menu``): + + * the command is always printed **before** it is confirmed and run, + * ``--dry-run`` executes nothing at all, + * the audit log is mode 600 (directories 700) and falls back to + ``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable, + * no secret reaches the log: a single sanitizer redacts + ``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=`` + style values and whole PEM blocks, + * raw command output is shown on screen and never copied into the log. +""" + +from __future__ import annotations + +import os +import re +import shlex +import subprocess +import sys +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Sequence + +from . import config + +# --------------------------------------------------------------------------- +# Secret redaction -- the single choke point before anything is logged +# --------------------------------------------------------------------------- +_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)" +_SECRET_ASSIGN_RE = re.compile( + r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)", + re.IGNORECASE, +) +_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY") +_PEM_BLOCK_RE = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", + re.DOTALL, +) + +REDACTED = "" + + +def redact(text: str) -> str: + """Return TEXT with secret-looking values and PEM blocks removed. + + Deliberately blunt: log records are written for every command, so the + sanitizer must be safe for text that was never meant to be a secret. A PEM + header without its matching footer (truncated line, interrupted paste) is + treated as the start of a secret: everything from the header on is dropped, + because a half-copied key is still a key. + """ + if not text: + return text + cleaned = _PEM_BLOCK_RE.sub(REDACTED, text) + header = _PEM_LINE_RE.search(cleaned) + if header: + cleaned = cleaned[:header.start()] + REDACTED + cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned) + return cleaned + + +# --------------------------------------------------------------------------- +# Audit log +# --------------------------------------------------------------------------- +def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str: + base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join( + home or os.path.expanduser("~"), ".local", "state" + ) + return os.path.join(base, *config.LOG_FALLBACK_SUBDIR) + + +def _prepare_log_file(path: str) -> bool: + """Create or tighten PATH to mode 600; True when it is usable.""" + directory = os.path.dirname(path) or "." + try: + if os.path.exists(path): + if not os.path.isfile(path) or not os.access(path, os.W_OK): + return False + os.chmod(path, config.LOG_FILE_MODE) + else: + if not os.path.isdir(directory): + os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True) + if not os.access(directory, os.W_OK): + return False + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE) + os.close(fd) + os.chmod(path, config.LOG_FILE_MODE) + except OSError: + return False + return _is_private(path) + + +def _is_private(path: str) -> bool: + """True when only the owner can read the log (never log into a world-readable file).""" + try: + mode = os.stat(path).st_mode + except OSError: + return False + return (mode & 0o077) == 0 + + +@dataclass +class AuditLog: + """Append-only audit trail. Never receives raw command output.""" + + requested: str = config.DEFAULT_LOG_FILE + state_home: Optional[str] = None + home: Optional[str] = None + path: Optional[str] = None + ready: bool = False + warning: str = "" + + def initialise(self) -> None: + for candidate in (self.requested, fallback_log_path(self.state_home, self.home)): + if not candidate: + continue + if _prepare_log_file(candidate): + self.path = candidate + self.ready = True + if candidate != self.requested: + self.warning = config.MSG_LOG_FALLBACK.format( + requested=self.requested, fallback=candidate + ) + return + self.ready = False + self.warning = config.MSG_LOG_NONE + + def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None: + if not self.ready or not self.path: + return + stamp = timestamp or _utc_now() + record = "{0} [{1}] {2}\n".format(stamp, level, redact(message)) + try: + with open(self.path, "a", encoding="utf-8") as handle: + handle.write(record) + except OSError: + # A failing log must never break an operator's session. + self.ready = False + + +def _utc_now() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- +RUN_MODE_RUN = "run" +RUN_MODE_CHECK = "check" +RUN_MODE_CANCEL = "cancel" + + +@dataclass +class RunResult: + argv: List[str] = field(default_factory=list) + executed: bool = False + dry_run: bool = False + check: bool = False + cancelled: bool = False + rc: Optional[int] = None + signal_name: str = "" + + @property + def ok(self) -> bool: + return self.executed and self.rc == 0 + + +def command_string(argv: Sequence[str]) -> str: + """A copy/pasteable shell line (like ``printf %q`` in the bash console).""" + return " ".join(shlex.quote(part) for part in argv) + + +def default_spawn(argv: Sequence[str]) -> int: + """Run ARGV with inherited stdio and return its exit code.""" + try: + completed = subprocess.run(list(argv), check=False) + return completed.returncode + except FileNotFoundError: + return 127 + except PermissionError: + return 126 + except KeyboardInterrupt: + return 130 + + +class Runner: + """Show, confirm and execute commands; record every decision.""" + + def __init__( + self, + console, + audit: Optional[AuditLog] = None, + dry_run: bool = False, + check_mode: bool = False, + spawn: Optional[Callable[[Sequence[str]], int]] = None, + ) -> None: + self.console = console + self.audit = audit or AuditLog() + self.dry_run = dry_run + self.check_mode = check_mode + self._spawn = spawn or default_spawn + + # -- helpers ----------------------------------------------------------- + def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str: + printable = command_string(argv) + self.console.section(title) + self.console.write(" " + printable) + self.console.write("") + return printable + + def _check_flag(self, argv: List[str]) -> List[str]: + if "--check" not in argv: + return argv + ["--check"] + return argv + + def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult: + try: + rc = self._spawn(argv) + except KeyboardInterrupt: + return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT") + return RunResult(argv=list(argv), executed=True, rc=rc) + + # -- public API -------------------------------------------------------- + def run_readonly(self, argv: Sequence[str], title: str) -> RunResult: + """Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd).""" + self.show_command(argv, title=title) + if self.dry_run: + self.console.warn(config.MSG_DRY_RUN_WARN) + self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) + return RunResult(argv=list(argv), dry_run=True) + self.audit.write(config.AUDIT_RUN, command_string(argv)) + result = self._spawn_exit_code(argv) + if result.rc == 0: + self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv)) + else: + self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc)) + self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv))) + return result + + def confirm_and_run( + self, + argv: Sequence[str], + label: str, + destructive: bool = False, + check_mode: Optional[bool] = None, + ) -> RunResult: + """Print the command, ask, then run it (or cancel / dry-run).""" + self.show_command(argv) + if self.dry_run: + self.console.warn(config.MSG_DRY_RUN_WARN) + self.audit.write(config.AUDIT_DRYRUN, command_string(argv)) + return RunResult(argv=list(argv), dry_run=True) + + if destructive: + self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label)) + self.console.warn(config.MSG_DESTRUCTIVE_SUB) + + mode = self.console.ask_run(config.MSG_RUN_ASK) + if mode == RUN_MODE_RUN: + final = list(argv) + elif mode == RUN_MODE_CHECK: + final = self._check_flag(list(argv)) + self.console.info(config.MSG_CHECK_MODE) + else: + self.console.info(config.MSG_CANCELLED_RUN) + self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv)) + return RunResult(argv=list(argv), cancelled=True) + + self.audit.write(config.AUDIT_RUN, command_string(final)) + result = self._spawn_exit_code(final) + result.check = "--check" in final + if result.rc == 0: + self.console.ok(config.MSG_RUN_OK) + self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final)) + else: + self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc)) + self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final))) + return result + + # -- checks ------------------------------------------------------------ + @staticmethod + def have_ansible() -> bool: + return _which("ansible-playbook") is not None + + @staticmethod + def have_python() -> bool: + return sys.version_info >= config.MIN_PYTHON + + @staticmethod + def is_root() -> bool: + return os.geteuid() == 0 + + +def _which(name: str) -> Optional[str]: + for directory in os.environ.get("PATH", "").split(os.pathsep): + candidate = os.path.join(directory, name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None diff --git a/bfos/tui.py b/bfos/tui.py new file mode 100644 index 0000000..3a6095d --- /dev/null +++ b/bfos/tui.py @@ -0,0 +1,499 @@ +"""ANSI terminal interface for the bfos operator console. + +Two presentations, one contract: + + * **simple mode** (default): one screen with three numbered actions — + ``1) Kur``, ``2) Durum``, ``3) Çıkış`` — plus a single shortcut that opens + the detailed mode. A beginner is never shown 21 playbooks, wave tooling or + central preparation, + * **detailed mode** (``--full`` or the ``d`` shortcut): the five categories of + the bash console — Kurulum, Cihaz işlemleri, Merkez hazırlığı, Araçlar, + Cihaz bilgisi. + +Rules: + * every screen ends with ``0) Geri`` and ``q) Çıkış``, + * every operator message is Turkish, every code identifier stays English, + * colours are pure ANSI escapes written by hand (no textual/rich/curses), + * input is validated; a wrong answer gets a polite Turkish warning instead of + a traceback or a silent default, + * rendering is a pure function of the screen description, so the menu + contract can be unit-tested without a terminal. +""" + +from __future__ import annotations + +import io # noqa: F401 - kept for callers that build an in-memory Console +import sys +from dataclasses import dataclass +from typing import Callable, Iterable, List, Optional, Sequence, Tuple + +from . import config + +# Values returned by :meth:`Console.ask_run`. They match +# bfos.runner.RUN_MODE_* on purpose (asserted by the test suite). +RUN = "run" +CHECK = "check" +CANCEL = "cancel" + +# Outcomes of :func:`interpret_menu_choice`. +CHOICE_QUIT = "quit" +CHOICE_BACK = "back" +CHOICE_ITEM = "item" +CHOICE_INVALID = "invalid" + +ENTRY_ITEM = "item" +ENTRY_SHORTCUT = "shortcut" +ENTRY_SECTION = "section" + + +# --------------------------------------------------------------------------- +# Colour handling +# --------------------------------------------------------------------------- +def color_enabled(no_color: bool = False, mode: str = "auto", is_tty: bool = True) -> bool: + """Decide whether ANSI colour may be written to the stream.""" + if no_color: + return False + if mode == "never": + return False + if mode == "always": + return True + return is_tty and not bool(_env("NO_COLOR")) + + +def _env(name: str) -> str: + import os + + return os.environ.get(name, "") + + +def stream_is_tty(stream) -> bool: + try: + return bool(stream.isatty()) + except (AttributeError, ValueError): + return False + + +# --------------------------------------------------------------------------- +# Screen description (pure data, unit-testable) +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class Entry: + """One line of a screen. + + ``kind=ENTRY_ITEM`` is numbered by its position among the items; + ``kind=ENTRY_SHORTCUT`` carries its own key (``d``) and is rendered next to + the footer; ``kind=ENTRY_SECTION`` is a heading. + """ + + kind: str + label: str = "" + description: str = "" + key: str = "" + + +@dataclass(frozen=True) +class Screen: + title: str + subtitle: str = "" + entries: Tuple[Entry, ...] = () + notes: Tuple[str, ...] = () + + def items(self) -> Tuple[Tuple[str, Entry], ...]: + """(key, entry) for every numbered item, key = "1", "2", ... """ + numbered = [entry for entry in self.entries if entry.kind == ENTRY_ITEM] + return tuple((str(index + 1), entry) for index, entry in enumerate(numbered)) + + def shortcuts(self) -> Tuple[Entry, ...]: + return tuple(entry for entry in self.entries if entry.kind == ENTRY_SHORTCUT) + + def keys(self) -> Tuple[str, ...]: + """Every selectable key: numbered items first, then shortcuts.""" + return tuple(key for key, _ in self.items()) + tuple( + entry.key for entry in self.shortcuts() + ) + + +def item(label: str, description: str = "") -> Entry: + return Entry(ENTRY_ITEM, label=label, description=description) + + +def shortcut(key: str, label: str, description: str = "") -> Entry: + return Entry(ENTRY_SHORTCUT, label=label, description=description, key=key) + + +def section(label: str) -> Entry: + return Entry(ENTRY_SECTION, label=label) + + +# --------------------------------------------------------------------------- +# The menus +# --------------------------------------------------------------------------- +def simple_menu() -> Screen: + """Default view: exactly three basic actions, nothing else. + + The only extra key is the ``d`` shortcut that opens the detailed mode; it is + rendered next to the numbered actions as a clearly-marked advanced entry, so + a beginner is never dropped into 21 playbooks and wave tooling by accident. + """ + return Screen( + title=config.SIMPLE_MENU_TITLE, + subtitle=config.SIMPLE_MENU_SUBTITLE, + entries=( + item(config.SIMPLE_ITEM_INSTALL_LABEL, config.SIMPLE_ITEM_INSTALL_DESC), + item(config.SIMPLE_ITEM_STATUS_LABEL, config.SIMPLE_ITEM_STATUS_DESC), + item(config.SIMPLE_ITEM_EXIT_LABEL, config.SIMPLE_ITEM_EXIT_DESC), + shortcut("d", config.SIMPLE_ADVANCED_LABEL, config.SIMPLE_ADVANCED_DESC), + ), + notes=(config.MSG_SIMPLE_MODE_HINT,), + ) + + +def detailed_menu() -> Screen: + """Detailed mode: the five categories of the bash console.""" + return Screen( + title=config.FULL_MENU_TITLE, + subtitle=config.FULL_MENU_SUBTITLE, + entries=( + section("Kategoriler"), + item(config.CAT_INSTALL, "Bu cihazı yerel olarak kur ve doğrula"), + item(config.CAT_DEVICES, "Filo playbook'ları, her zaman açık hedefle"), + item(config.CAT_CENTRAL, "Merkez entegrasyonunu doğrula ve hazırla"), + item(config.CAT_TOOLS, "Depo ve yerel yardımcı araçlar"), + item(config.CAT_INFO, "Salt okunur bilgi ekranları"), + shortcut("s", "Basit mod", "Yalnız Kur / Durum / Çıkış"), + ), + notes=(config.MSG_FULL_MODE_HINT,), + ) + + +def install_menu(items: Sequence[Tuple[str, str]]) -> Screen: + """Kurulum category (this device only).""" + return Screen( + title="{0} — {1}".format(config.CAT_INSTALL, "bu cihaz"), + subtitle="Yalnız bu cihaz kurulur; hiçbir filo cihazına dokunulmaz", + entries=(section("Yerel kurulum"),) + tuple(item(label, desc) for label, desc in items), + ) + + +def devices_menu(report_count: int, maintenance_count: int) -> Screen: + return Screen( + title=config.CAT_DEVICES, + subtitle="Yalnızca depodaki mevcut playbook'lar, her zaman açık hedefle", + entries=( + section("Filo işlemleri"), + item("Salt okunur raporlar", "{0} işlem, hiçbir cihazı değiştirmez".format(report_count)), + item("Bakım işlemleri", "{0} işlem; yıkıcı olanlar iki kez onay ister".format(maintenance_count)), + item("Envanter hedeflerini göster", "dalgalar, gruplar ve cihaz sayıları"), + ), + ) + + +def central_menu() -> Screen: + return Screen( + title=config.CAT_CENTRAL, + subtitle="Merkez entegrasyonunu doğrular ve hazırlar; merkez sunucu kurmaz", + entries=( + section("Hazırlık ve doğrulama"), + item("Merkez hazırlık durumu", "envanter, yer tutucular, yönetici anahtarı"), + item("Merkez uç noktalarını göster", "WireGuard hub, izleme, paket aynası"), + item("Merkez uç noktalarını dene", "DNS + TCP, salt okunur"), + item("Dalga geçiş kapısı durumu", "dalga başına cihaz sayısı ve kapı kuralı"), + item("Merkez hazırlık listesi", "kayıt öncesi gerekenler"), + ), + ) + + +def tools_menu() -> Screen: + return Screen( + title=config.CAT_TOOLS, + subtitle="Yerel yardımcılar; ISO derleme bu konsolun kapsamı dışındadır", + entries=( + section("Depo"), + item("Depoyu güncelle", "git pull --ff-only"), + item("Ortam ve sürüm bilgisi", "yollar, sürümler, çözümlenen ayarlar"), + item("İşlem kataloğunu göster", "--list çıktısı"), + section("Bu cihazın araçları"), + item("Yerel çevrimdışı kapı", "bf-check-local"), + item("Kayıt (enrollment) kapısı", "bf-check-enrollment"), + item("Hazır (READY) kapısı", "bf-check-ready"), + item("Derin tanılama", "bf-diagnostics (yoksa bf-status)"), + item("Canlı donanım / OS kontrolü", "bf-live-hw-check, salt okunur"), + item("Uzak erişim kanalları", "bf-remote-status --check"), + item("Field OS sürüm kimliği", "bf-release"), + item("Donanım envanteri", "bf-hardware-inventory.sh"), + item("Destek paketi", "bf-support-bundle --out-dir"), + section("Bu konsol"), + item("Konsol denetim kaydını göster", "denetim kaydının son 40 satırı"), + ), + ) + + +def info_menu() -> Screen: + return Screen( + title=config.CAT_INFO, + subtitle="Salt okunur: buradaki hiçbir işlem cihazı değiştirmez", + entries=( + section("Bu cihaz"), + item("Kimlik", "ana makine adı, cihaz kimliği, bayi numarası"), + item("Sağlık özeti", "bf-status"), + item("Kurulum ve kayıt durumu", "state.json beyaz listesi"), + item("Ağ", "adresler, rota, DNS, WireGuard eş sayısı"), + item("Disk, bellek, CPU", "df, free, yük"), + item("Kurulu sabit paketler", "dpkg-query"), + item("Servis durumu", "ssh, xrdp, rustdesk, docker, wg"), + item("Son journal hataları", "journalctl -p err"), + section("Filo"), + item("Envanter özeti", "dalgalar, gruplar, sayılar"), + item("Hedefi çözümle", "bayi numarası veya grubu doğrula"), + ), + ) + + +def target_menu() -> Screen: + return Screen( + title="Hedef seçimi", + subtitle="Hedef sorulmadan hiçbir komut üretilmez", + entries=( + item("Tek cihaz (bayi numarası)", "8 hane, örn. 12010101"), + item("Grup / dalga", ", ".join(config.WAVES)), + item("Filo geneli", "dalga kapsamlı playbook'lar dalga dalga sorar"), + item("Hedefi doğrudan yaz", "bayi numarası, grup ya da 'all'"), + ), + ) + + +def yes_no_suffix(default_yes: bool = False) -> str: + return "[e/H]" if not default_yes else "[E/h]" + + +# --------------------------------------------------------------------------- +# Rendering (pure functions) +# --------------------------------------------------------------------------- +def horizontal_rule(char: str = "-", width: int = config.CONSOLE_WIDTH) -> str: + return char * width + + +def render_item(key: str, label: str, description: str = "", palette: Optional[config.Palette] = None) -> str: + palette = palette or config.Palette(False) + head = " {0:>2}) {1:<46}".format(key, label) + if description: + return head + " " + palette.paint(description, "dim") + return head.rstrip() + + +def render_screen(screen: Screen, palette: Optional[config.Palette] = None, width: int = config.CONSOLE_WIDTH) -> str: + """The complete text of one screen (banner, items, footer).""" + palette = palette or config.Palette(False) + lines: List[str] = [""] + lines.append(palette.paint(horizontal_rule("=", width), "dim")) + lines.append(" " + palette.paint(screen.title, "bold", "cyan")) + if screen.subtitle: + lines.append(" " + palette.paint(screen.subtitle, "dim")) + lines.append(palette.paint(horizontal_rule("=", width), "dim")) + lines.append("") + + item_keys = dict((entry, key) for key, entry in screen.items()) + for entry in screen.entries: + if entry.kind == ENTRY_SECTION: + lines.append("") + lines.append(" " + palette.paint(entry.label, "magenta")) + elif entry.kind == ENTRY_ITEM: + lines.append(render_item(item_keys[entry], entry.label, entry.description, palette)) + for entry in screen.shortcuts(): + lines.append(render_item(entry.key, entry.label, entry.description, palette)) + + for note in screen.notes: + lines.append("") + lines.append(" " + palette.paint(note, "dim")) + + lines.append("") + lines.append(palette.paint(horizontal_rule("-", width), "dim")) + footer = " {0:>2}) {1:<46} {2:>2}) {3}".format( + "0", config.MSG_BACK, "q", config.MSG_QUIT_ITEM + ) + lines.append(footer) + lines.append(palette.paint(horizontal_rule("-", width), "dim")) + lines.append("") + return "\n".join(lines) + + +def interpret_menu_choice(choice: str, keys: Sequence[str]) -> Tuple[str, str]: + """Classify what the operator typed on a menu screen. + + ``""``/``0`` -> back, ``q``/``Q`` -> quit, a known key -> item, everything + else -> invalid (the caller prints a polite Turkish warning). + """ + value = (choice or "").strip() + if value == "" or value == "0": + return CHOICE_BACK, "" + if value.lower() == "q": + return CHOICE_QUIT, "" + for key in keys: + if value == key: + return CHOICE_ITEM, key + return CHOICE_INVALID, value + + +# --------------------------------------------------------------------------- +# Console I/O +# --------------------------------------------------------------------------- +class Console: + """Thin ANSI console: writes to a stream, reads lines, validates input. + + ``reader`` can be injected (a callable taking the prompt and returning a + line, or ``None`` for EOF) which is how the console is tested without a + terminal. + """ + + def __init__( + self, + out=None, + inp=None, + palette: Optional[config.Palette] = None, + interactive: Optional[bool] = None, + reader: Optional[Callable[[str], Optional[str]]] = None, + clear: bool = True, + ) -> None: + self.out = out if out is not None else sys.stdout + self.inp = inp if inp is not None else sys.stdin + self.reader = reader + tty = stream_is_tty(self.inp) and stream_is_tty(self.out) + self.interactive = tty if interactive is None else interactive + self.palette = palette or config.Palette(False) + self.clear = clear + + # -- output ------------------------------------------------------------ + def write(self, text: str = "") -> None: + self.out.write(text + "\n") + + def paint(self, text: str, *names: str) -> str: + return self.palette.paint(text, *names) + + def section(self, label: str) -> None: + self.write("") + self.write(" " + self.paint(label, "magenta")) + + def kv(self, key: str, value: str) -> None: + self.write(" {0:<24} {1}".format(key, value)) + + def note(self, text: str) -> None: + self.write(self.paint(text, "dim")) + + def info(self, text: str) -> None: + self.write("{0} {1}".format(self.paint("[i]", "cyan"), text)) + + def ok(self, text: str) -> None: + self.write("{0} {1}".format(self.paint("[OK]", "green"), text)) + + def warn(self, text: str) -> None: + for line in str(text).splitlines() or [""]: + self.write("{0} {1}".format(self.paint("[UYARI]", "yellow"), line)) + + def error(self, text: str) -> None: + for line in str(text).splitlines() or [""]: + self.write("{0} {1}".format(self.paint("[HATA]", "red"), line)) + + def banner(self, title: str, subtitle: str = "") -> None: + self.write(render_screen(Screen(title=title, subtitle=subtitle), self.palette)) + + def screen(self, screen: Screen) -> None: + if self.interactive and self.clear: + self.out.write("\033[H\033[2J") + self.write(render_screen(screen, self.palette)) + + def show_lines(self, lines: Iterable[str]) -> None: + for line in lines: + self.write(line) + + def exception(self, refused) -> None: + """Report a :class:`bfos.operations.Refused` nicely.""" + self.error(refused.message) + if getattr(refused, "detail", ""): + for line in refused.detail.splitlines(): + self.note(" " + line) + + # -- input ------------------------------------------------------------- + def read(self, prompt: str = "") -> Optional[str]: + """Read one line; ``None`` means EOF (stdin closed / non-interactive).""" + if self.reader is not None: + self.out.write(prompt) + return self.reader(prompt) + self.out.write(prompt) + self.out.flush() + try: + line = self.inp.readline() + except (ValueError, OSError): + return None + if line == "": + return None + return line.rstrip("\n") + + def ask(self, prompt: str, allow_empty: bool = False) -> Optional[str]: + """Read a non-empty answer; re-asks politely on an empty line.""" + while True: + value = self.read(prompt) + if value is None: + return None + value = value.strip() + if value or allow_empty: + return value + self.warn(config.MSG_REQUIRED) + + def ask_with_default(self, prompt: str, default: str) -> Optional[str]: + value = self.read(prompt) + if value is None: + return None + value = value.strip() + return value or default + + def confirm(self, question: str, default_yes: bool = False) -> Optional[bool]: + """Turkish yes/no question. Only an explicit yes proceeds.""" + while True: + value = self.read("{0} {1}: ".format(question, yes_no_suffix(default_yes))) + if value is None: + return None + answer = value.strip().lower() + if answer in ("e", "evet", "y", "yes"): + return True + if answer in ("h", "hayir", "hayır", "n", "no"): + return False + if answer == "": + return default_yes + self.warn(config.MSG_INVALID_INPUT.format(value=value)) + + def confirm_token(self, message: str, token: str, prompt: str) -> Optional[bool]: + """The operator must type TOKEN exactly (highest-risk confirmations).""" + if message: + self.warn(message) + value = self.read(prompt) + if value is None: + return None + return value.strip() == token + + def ask_run(self, question: str = config.MSG_RUN_ASK) -> str: + """y = run, c = run with --check, anything else = cancel.""" + value = self.read(question) + if value is None: + return CANCEL + answer = value.strip().lower() + if answer in ("y", "yes", "e", "evet"): + return RUN + if answer in ("c", "check"): + return CHECK + return CANCEL + + def pause(self, message: str = config.MSG_PRESS_ENTER) -> None: + if self.interactive: + self.read(self.paint(message + " ", "dim")) + + def ask_menu_choice(self, screen: Screen) -> Tuple[str, str]: + """Read and classify a menu answer, warning on invalid input.""" + value = self.read(config.MSG_PROMPT_CHOICE) + if value is None: + return CHOICE_QUIT, "" + outcome, payload = interpret_menu_choice(value, screen.keys()) + if outcome == CHOICE_INVALID: + self.warn(config.MSG_UNKNOWN_CHOICE.format(choice=payload)) + self.write(" " + config.MSG_MENU_RANGE.format(max=len(screen.items()))) + return CHOICE_INVALID, payload + return outcome, payload diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py new file mode 100644 index 0000000..4fde5a0 --- /dev/null +++ b/bfos/ui_gui.py @@ -0,0 +1,3029 @@ +"""bfos/ui_gui.py -- Blueforce Field OS Turkce masaustu arayuzu (tkinter). + +Ne yapar +-------- +`admin/bf-menu` terminal konsolunun yaptigi isi ayni is mantigi uzerinden bir +gercek pencereye tasir: + + * basit mod : uc buyuk buton -- Kur / Durum / Cikis (+ "Detayli mod"), + * detayli mod: kategori sekmeleri -- Kurulum, Cihaz islemleri, Merkez + hazirligi, Araclar, Cihaz bilgisi, + * hedef secimi: bayi no alani (^[0-9]{8}$ dogrulamali) + dalga grubu listesi, + * her komut CALISTIRILMADAN ONCE gosterilir ve onay ister; yikici islemlerde + AYRICA ikinci onay istenir, production dalgasi icin "production" yazilir, + * komut ciktisi canli olarak kaydirilabilir bir metin kutusuna akar, + * ekran yoksa (SSH/headless) veya tkinter kurulu degilse net TURKCE hata + verir ve terminal menusunu onerir. + +====================================================================== +SOZLESME -- backend arareyuzu (bfos/operations.py ve bfos/runner.py icin) +====================================================================== +Bu dosya is mantigini TAKLIT ETMEZ; onu bir "backend adapter" uzerinden +kullanir. Gercek moduller (bfos.operations, bfos.runner) yazildiginda asagidaki +sozlesmeyi saglamalari yeterlidir; ui_gui.py onlari otomatik olarak adapte eder. + +Sozlesme (bu dosyada tanimli veri tipleriyle ayni alan adlari): + +1) Operasyon listesi + list_operations() -> Sequence[Operation] + ya da OPERATIONS / OPS / OPERATION_CATALOGUE isimli bir dizi. + Operation alanlari: id (str, tekil), label (str, TURKCE ad), + category (str, su bes kategoriden biri: "Kurulum", "Cihaz islemleri", + "Merkez hazirligi", "Araclar", "Cihaz bilgisi"), destructive (bool), + scope ("local"|"simple"|"wave"|"release"), description (str), + playbook (str, sadece filo islemleri icin "bf-*.yml" adi). + +2) Hedef secimi + Target(kind="dealer"|"group"|"all", value="12010193"|"pilot_1"|"all") + - dealer -> Ansible --limit bf-<8 hane>, insan kimligi BF-<8 hane> + - group -> dalga/grup adi; wave/release kapsamli islemler SADECE onayli + bir dalga grubu kabul eder (lab, pilot_1, pilot_2, wave_1, wave_2, + production); release kapsamli islemler tek cihaz veya "all" KABUL ETMEZ. + +3) Komut uretimi (calistirmadan) + build_command(operation, target, options, grants=None, check_mode=False) + -> CommandPlan + CommandPlan: + * commands : her biri bir argv olan demet (satir listesi + `CommandPlan.lines` uzerinden shlex ile uretilir), + * report_lines: SALT-OKUNUR rapor satirlari; commands bos ise hicbir sey + calistirilmaz, satirlar dogrudan gosterilir, + * destructive / requires_root / operation_id / target_label / notes, + * dry_run / check_mode. + options : {secenek_adi: str} (bkz. Operation.options) + grants : {"wave_gate_confirmed"|"production_override"| + "allow_production_reboot"|"fleet_wide": bool} -- operatorun + ACIK onaylari. Bir kapi bayragi yalniz onay verildiginde ve + playbook o degiskeni kullaniyorsa komuta eklenir. + check_mode: True ise komut --check ile uretilir (degisiklik uygulanmaz). + +4) Kapi onaylari (acik onay adimlari) + gate_steps(operation, target) -> Sequence[GateStepInfo] + GateStepInfo(kind="token"|"approval", key, prompt, message, token, unlocks) + - "token" : operator kutulara `token` sozcugunu yazmak zorundadir + (dalga adi, "production", "GATE", "all"), + - "approval" : evet/hayir onayi (yikici ikinci onay dahil), + - unlocks : onay verilince build_command'a gecirilecek grant adlari. + Arayuz onaylarin TAMAMINI sorar; hicbir kapi sessizce gecilmez. + +5) Komut calistirma (cikti + cikis kodu) + run(plan, on_line=None, cancel=None) -> RunResult(exit_code, ok, output) + on_line(str) her cikti satirinda cagrilir (canli akis icin); + cancel threading.Event ile calisan komut sonlandirilir. + +Uyumluluk icin adapter su adlari da dener (sirayla): + katalog : catalogue_rows | ALL_OPERATIONS | list_operations | operations | + get_operations | OPERATIONS | OPS + komut : build_command | build_plan | command_for | build_ansible_cmd + kosum : run | run_plan | run_command | execute +ve donus degerlerini (dict / dataclass / object) normalize eder. + +bfos.operations ve bfos.runner MEVCUTSA (bu repodaki gercek moduller) arayuz +dogrudan onlari kullanir; hicbir kural kopyalanmaz: + katalog : catalogue_rows() / ALL_OPERATIONS + envanter/hedef : Repository + Inventory.load_repository + resolve_target + komut uretimi : build_ansible_command / build_install_command / + build_tool_command / build_extra_vars + kapilar : confirmation_plan + GatesGranted + denetim kaydi : runner.AuditLog (+ runner.redact) + salt-okunur ekranlar: targets_lines / central_*_lines / identity_lines / + state_lines / environment_lines +Gercek moduller yoksa dosya kendi YERLESIK KATALOG'unu kullanir: bu katalog +admin/bf-menu'nun tablosuyla (id, playbook, kapsam, yikici, ek degisken +kurallari) birebir ayni kurallara ve ayni komut uretimine dayanir. +====================================================================== + +Kullanim: + python3 -m bfos.ui_gui # basit mod + python3 -m bfos.ui_gui --full # dogrudan detayli mod + python3 -m bfos.ui_gui --list # pencere acmadan katalogu yazdir + python3 -m bfos.ui_gui --dry-run # komutlari goster, calistirma + +Notlar: + * tkinter Ubuntu'da hazir gelir (paket: python3-tk); indirme gerekmez. + * Bu dosya tkinter olmadan da ice aktarilabilir: yardimci fonksiyonlar + (dogrulama, komut onizleme, yikici siniflama, headless tespiti) tkinter'siz + calisir, bu yuzden statik testler pencere ACMADAN kosar. +""" + +from __future__ import annotations + +import argparse +import os +import queue +import re +import shlex +import shutil +import subprocess +import sys +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +# --------------------------------------------------------------------------- +# Sabitler -- kullanicinin gordugu TUM metinler Turkce +# --------------------------------------------------------------------------- +WINDOW_TITLE = "Blueforce Field OS — Yönetim" +APP_SUBTITLE = "Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)" + +# Basit modda gorunen uc buyuk buton (sira onemli). +SIMPLE_MODE_BUTTONS: tuple[str, ...] = ("Kur", "Durum", "Çıkış") +SIMPLE_MODE_LABEL = "Detaylı mod" +SIMPLE_MODE_BACK_LABEL = "Basit moda dön" + +# Detayli moddaki kategori sekmeleri (docs/33 §9.2 ile ayni bes kategori). +CATEGORIES: tuple[str, ...] = ( + "Kurulum", + "Cihaz işlemleri", + "Merkez hazırlığı", + "Araçlar", + "Cihaz bilgisi", +) + +# Onayli dalga sirasi (docs/10; admin/lib/targets.sh BF_WAVES ile ayni). +WAVES: tuple[str, ...] = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") +PRODUCTION_WAVE = "production" +GATE_TOKEN_RELEASE = "GATE" + +DEALER_PATTERN = re.compile(r"^[0-9]{8}$") +SCOPE_LABELS: Mapping[str, str] = { + "local": "yerel — bu cihaz", + "simple": "filo — tek cihaz/grup (salt-okunur)", + "wave": "filo — dalga kapılı", + "release": "filo — sürüm kapılı (onaylı dalga)", +} + +# admin/lib/targets.sh + admin/bf-menu ile ayni kurallar. +SERVICE_ALLOWLIST: tuple[str, ...] = ( + "blueforce-agent", + "docker", + "ssh", + "sshd", + "systemd-journald", + "cron", +) +SCRIPT_DIRS: tuple[str, ...] = ("/opt/blueforce/bin", "/usr/local/sbin") +MODULE_PATTERN = re.compile(r"^[0-9]{2}-[a-z0-9][a-z0-9._-]*$") +PACKAGE_PIN_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*=.+$") +HOST_PATTERN = re.compile(r"^bf-[0-9]{8}$") +STATE_JSON_PATH = "/var/lib/blueforce/state.json" +STATE_ALLOWED_KEYS: tuple[str, ...] = ( + "phase", + "enrollment_status", + "fleet_status", + "device_id", + "provisioning_id", +) +INSTALL_STATE_PATH = "/var/lib/blueforce/install-state" +INSTALL_LOG_PATH = "/var/log/blueforce-install.log" + +#: --check eklenebilecek yerel komutlar (kurulum betigi ve kapi araclari). +CHECK_CAPABLE_LOCAL_OPS: tuple[str, ...] = ( + "install-preflight", + "install-local", + "install-resume", + "install-module", + "tools-gate-local", + "tools-gate-enrollment", + "tools-gate-ready", +) +DEFAULT_CONSOLE_LOG = "/var/log/blueforce-console.log" +INSTALLER_REL = "scripts/install/blueforce-install.sh" +PLAYBOOK_DIR_REL = "ansible/playbooks" +MENU_REL = "admin/bf-menu" + +TK_PACKAGE_HINT = "sudo apt install -y python3-tk" +TERMINAL_MENU_HINT = "sudo bf-menu" + +# --------------------------------------------------------------------------- +# tkinter -- opsiyonel ice aktarma +# --------------------------------------------------------------------------- +class _MissingTk: + """tkinter yoksa: kullanildigi anda Turkce hata veren yer tutucu. + + Amac iki yonlu: (1) modul tkinter'siz de ice aktarilabilir kalsin, + (2) GUI kodundaki `tk.X`/`ttk.X` tip denetimi gecerli bir nesne gordugu + icin None uzerinden gelen yanlis uyarilar uretilmesin. + """ + + def __getattr__(self, name: str): + raise RuntimeError( + "tkinter kurulu değil (" + TK_IMPORT_ERROR + "). " + f"Kurmak için: {TK_PACKAGE_HINT}" + ) + + +try: # pragma: no cover - ortama bagli + import tkinter as tk + from tkinter import messagebox, ttk + from tkinter import scrolledtext + + HAVE_TKINTER = True + TK_IMPORT_ERROR = "" +except Exception as _tk_exc: # pragma: no cover - ortama bagli + HAVE_TKINTER = False + TK_IMPORT_ERROR = str(_tk_exc) + tk = ttk = messagebox = scrolledtext = _MissingTk() # type: ignore[assignment] + + + +# --------------------------------------------------------------------------- +# Veri tipleri (backend sozlesmesi) +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class OptionSpec: + """Bir islemin ek parametresi (bf-menu'deki -e degiskenleri).""" + + name: str + label: str + kind: str = "text" # text | int | bool | choice + default: str = "" + choices: tuple[str, ...] = () + hint: str = "" + required: bool = False + validator: Callable[[str], "str | None"] | None = None + + +@dataclass(frozen=True) +class Operation: + """Operasyon katalogu kaydi (sozlesme: bkz. dosya basligi).""" + + id: str + label: str + category: str + destructive: bool = False + scope: str = "local" # local | simple | wave | release + description: str = "" + playbook: str = "" + options: tuple[OptionSpec, ...] = () + requires_root: bool = False + needs_dealer: bool = False + + @property + def is_fleet(self) -> bool: + return self.scope in ("simple", "wave", "release") + + +@dataclass(frozen=True) +class Target: + """Hedef secimi: bayı no, dalga/grup adi ya da tum filo.""" + + kind: str # dealer | group | all + value: str + + @property + def limit(self) -> str: + if self.kind == "dealer": + return dealer_to_host(self.value) + return self.value + + @property + def label(self) -> str: + if self.kind == "dealer": + return f"cihaz {dealer_to_device_id(self.value)} ({dealer_to_host(self.value)})" + if self.kind == "all": + return "tüm filo (envanterdeki her cihaz)" + if is_wave(self.value): + return f"dalga '{self.value}'" + return f"grup '{self.value}'" + + +@dataclass(frozen=True) +class GateStepInfo: + """Calistirmadan once gereken ACIK operator onayi (sozlesme eki). + + kind : "token" (yazilarak) | "approval" (evet/hayir) + token: kind == "token" ise yazilmasi gereken sozcuk + unlocks: onay verilince backend'e gecirilecek bayrak adlari + (or. "wave_gate_confirmed", "production_override") + """ + + kind: str + key: str + prompt: str + message: str = "" + token: str = "" + unlocks: tuple[str, ...] = () + + +@dataclass(frozen=True) +class CommandPlan: + """Calistirilacak komutlar + ekranda gosterilecek satir listesi. + + `report_lines` dolu ve `commands` bos oldugunda plan HICBIR SEY calistirmaz: + rapor satirlari (bfos.operations icindeki salt-okunur ekranlar) dogrudan + gosterilir. + """ + + commands: tuple[tuple[str, ...], ...] = () + destructive: bool = False + requires_root: bool = False + operation_id: str = "" + target_label: str = "" + notes: tuple[str, ...] = () + report_lines: tuple[str, ...] = () + dry_run: bool = False + check_mode: bool = False + + @property + def lines(self) -> tuple[str, ...]: + """Kopyala/yapistir edilebilir komut satirlari (bf-menu %q ile ayni fikir).""" + return tuple(shlex.join(argv) for argv in self.commands if argv) + + @property + def is_empty(self) -> bool: + return not any(self.commands) and not self.report_lines + + @property + def is_report_only(self) -> bool: + return not any(self.commands) and bool(self.report_lines) + + +@dataclass +class RunResult: + """Komut calistirma sonucu: cikti + cikis kodu (sozlesme maddesi 4).""" + + exit_code: int + ok: bool + output: str = "" + + +class BackendUnavailable(RuntimeError): + """Gercek backend modulu var ama sozlesmeye uymuyor.""" + + +class CommandError(RuntimeError): + """Komut uretilemedi / secenek gecersiz (TURKCE mesaj tasir).""" + + +def _as_int(value: Any, default: int = 1) -> int: + """Farkli backend'lerin cikis kodu tiplerini (int/str/None) guvenle cevirir.""" + try: + return int(value) + except (TypeError, ValueError): + return default + + +# --------------------------------------------------------------------------- +# Saf yardimcilar: birim testleri bunlari pencere ACMADAN kullanir +# --------------------------------------------------------------------------- +def validate_dealer(value: str) -> bool: + """Bayi no kapisi: ^[0-9]{8}$ (docs/02, K-12).""" + return bool(DEALER_PATTERN.match(value or "")) + + +def dealer_error(value: str) -> str | None: + """Gecerli ise None, degilse Turkce hata metni.""" + if validate_dealer(value): + return None + if not value: + return "Bayi numarası boş olamaz. Tam 8 hane, yalnız rakam girin (örn. 12010193)." + if not value.isdigit(): + return "Bayi numarası yalnız rakam içermelidir (harf, boşluk ve işaret kullanılamaz)." + return ( + f"Geçersiz bayi numarası '{value}': tam 8 hane olmalıdır " + f"(verilen uzunluk: {len(value)}). Örnek: 12010193" + ) + + +def dealer_to_host(dealer: str) -> str: + return f"bf-{dealer}" + + +def dealer_to_device_id(dealer: str) -> str: + return f"BF-{dealer}" + + +def host_to_dealer(host: str) -> str | None: + return host[3:] if HOST_PATTERN.match(host or "") else None + + +def is_wave(name: str) -> bool: + return name in WAVES + + +def validate_wave(value: str) -> str | None: + """Dalga adini dogrular; gecerliyse None doner.""" + if value in WAVES: + return None + return ( + f"Geçersiz dalga '{value}'. Onaylı dalgalar (sırayla): {', '.join(WAVES)}." + ) + + +def validate_disk_pct(value: str) -> str | None: + if value == "": + return None + if not value.isdigit() or not (1 <= int(value) <= 100): + return "Eşik 1 ile 100 arasında bir tam sayı olmalıdır." + return None + + +def validate_grace(value: str) -> str | None: + if value == "": + return None + if not value.isdigit(): + return "Bekleme süresi saniye cinsinden bir tam sayı olmalıdır (örn. 300)." + return None + + +def validate_log_lines(value: str) -> str | None: + if value == "": + return None + if not value.isdigit() or int(value) < 1: + return "Satır sayısı pozitif bir tam sayı olmalıdır (örn. 500)." + return None + + +def validate_service_name(value: str) -> str | None: + if value in SERVICE_ALLOWLIST: + return None + return ( + f"'{value}' izinli servis listesinde değil. İzinli olanlar: " + f"{', '.join(SERVICE_ALLOWLIST)}." + ) + + +def validate_script_path(value: str) -> str | None: + if not value: + return "Script yolu boş olamaz." + if not value.startswith("/"): + return "Script yolu mutlak olmalıdır (örn. /opt/blueforce/bin/kontrol.sh)." + if not any(value == d or value.startswith(d + "/") for d in SCRIPT_DIRS): + return ( + "Script yolu izinli dizinlerin dışında. İzinli dizinler: " + f"{', '.join(SCRIPT_DIRS)}." + ) + return None + + +def validate_package_pins(value: str) -> str | None: + if not value.strip(): + return "En az bir paket sürümü gerekir (biçim: paket=sürüm)." + for pin in value.split(","): + pin = pin.strip() + if not pin: + continue + if not PACKAGE_PIN_PATTERN.match(pin): + return ( + f"Geçersiz pin '{pin}': 'paket=sürüm' biçiminde olmalıdır " + "(örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)." + ) + return None + + +def validate_module_name(value: str) -> str | None: + if not value: + return "Modül numarası boş olamaz (örn. 01-precheck)." + if not MODULE_PATTERN.match(value): + return ( + f"Geçersiz modül '{value}': '01-precheck' biçiminde olmalıdır " + "(iki hane, tire, küçük harfli ad)." + ) + return None + + +def validate_expected_version(value: str) -> str | None: + if value == "": + return None + if any(ch.isspace() for ch in value): + return "Sürüm boşluk içermemelidir (örn. 1.0.0)." + return None + + +# --------------------------------------------------------------------------- +# Headless / tkinter hatalari -- net TURKCE yonlendirme +# --------------------------------------------------------------------------- +def headless_error(env: Mapping[str, str] | None = None) -> str | None: + """Ekran yoksa Turkce aciklama doner; ekran varsa None. + + Sadece DISPLAY / WAYLAND_DISPLAY ortam degiskenlerine bakar, boylece + pencere acmadan test edilebilir. + """ + data = os.environ if env is None else env + if data.get("DISPLAY") or data.get("WAYLAND_DISPLAY"): + return None + return ( + "Grafik ekran bulunamadı (DISPLAY ve WAYLAND_DISPLAY tanımlı değil).\n" + "\n" + "SSH veya başsız (headless) bir oturumda masaüstü penceresi açılamaz.\n" + "Bu durumda terminal menüsünü kullanın:\n" + f"\n {TERMINAL_MENU_HINT}\n" + "\n" + "Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:\n" + " python3 -m bfos.ui_gui\n" + ) + + +def tkinter_missing_message(error: str = "") -> str: + """tkinter kurulu degilse Turkce hata + kurulum ipucu.""" + detail = f" (teknik ayrıntı: {error})" if error else "" + return ( + "tkinter bulunamadı; masaüstü arayüzü başlatılamıyor" + f"{detail}.\n" + "\n" + "Bu, minimal kurulumlarda sık görülür: tkinter ayrı bir sistem paketidir.\n" + "Ubuntu/Debian üzerinde kurmak için:\n" + f"\n {TK_PACKAGE_HINT}\n" + " sudo apt update && sudo apt install -y python3-tk\n" + "\n" + f"tkinter kurmak istemiyorsanız terminal menüsünü kullanın: {TERMINAL_MENU_HINT}\n" + ) + + +def window_start_error(exc: BaseException) -> str: + """Tk() acilamazsa (DISPLAY var ama X/Wayland erisilemiyor) Turkce mesaj.""" + return ( + "Pencere açılamadı; grafik oturumuna erişilemiyor.\n" + f"Teknik ayrıntı: {exc}\n" + "\n" + "Muhtemel nedenler:\n" + " * SSH oturumundasınız ve X yönlendirmesi (ssh -X) yok,\n" + " * ekran kilitli veya grafik oturum kapalı,\n" + " * başka bir kullanıcının X sunucusuna bağlanmaya çalışıyorsunuz.\n" + f"\nTerminal menüsünü kullanın: {TERMINAL_MENU_HINT}\n" + ) + + +# --------------------------------------------------------------------------- +# Onizleme metni (onay pencereleri ve kayit icin tek kaynak) +# --------------------------------------------------------------------------- +def preview_text( + plan: CommandPlan, + *, + operation: Operation | None = None, + target: Target | None = None, +) -> str: + """Calistirilacak komutu gosteren Turkce onay metni.""" + parts: list[str] = [] + if plan.is_report_only: + parts.append("Bu ekran komut çalıştırmaz; salt-okunur rapor gösterir:") + parts.append("") + for line in plan.report_lines: + parts.append(f" {line}") + parts.append("") + else: + parts.append("Çalıştırılacak komut(lar):") + parts.append("") + if plan.is_empty: + parts.append(" (komut üretilemedi)") + for line in plan.lines: + parts.append(f" {line}") + parts.append("") + if operation is not None: + parts.append(f"İşlem : {operation.label} [{operation.id}]") + parts.append(f"Kapsam : {SCOPE_LABELS.get(operation.scope, operation.scope)}") + if operation.description: + parts.append(f"Açıklama: {operation.description}") + if target is not None: + parts.append(f"Hedef : {target.label}") + elif plan.target_label: + parts.append(f"Hedef : {plan.target_label}") + if plan.requires_root: + parts.append("Yetki : root gerekir — konsolu sudo ile açın.") + if plan.check_mode: + parts.append("Kip : --check (deneme) — hiçbir değişiklik uygulanmaz.") + if plan.dry_run: + parts.append("Kip : DENEME (--dry-run) — komut çalıştırılmaz, yalnız gösterilir.") + if plan.destructive: + parts.append("") + parts.append("UYARI: Bu YIKICI bir işlemdir; seçili hedefte değişiklik yapar.") + for note in plan.notes: + parts.append(f"Not : {note}") + return "\n".join(parts) + + +def destructive_operations( + operations: Sequence[Operation] | None = None, +) -> tuple[str, ...]: + """Yikici isaretli operasyonlarin id'leri.""" + ops = OPERATIONS if operations is None else operations + return tuple(op.id for op in ops if op.destructive) + + +def operation_is_destructive(operation_id: str) -> bool: + op = operation_by_id(operation_id) + return bool(op and op.destructive) + + +# --------------------------------------------------------------------------- +# Yerlesik katalog -- admin/bf-menu tablosuyla ayni id/kapsam/yikici degerleri +# (kaynak: admin/bf-menu BF_OPS_REPORT / BF_OPS_MAINTENANCE) +# --------------------------------------------------------------------------- +_FLEET_OPTIONS: Mapping[str, tuple[OptionSpec, ...]] = { + "disk": ( + OptionSpec( + "disk_warn_pct", + "Uyarı eşiği (%)", + kind="int", + default="", + hint="Boş = playbook varsayılanı (80)", + validator=validate_disk_pct, + ), + ), + "fieldos-version": ( + OptionSpec( + "fieldos_expected_version", + "Beklenen Field OS sürümü", + default="", + hint="Boş = drift denetimi atlanır", + validator=validate_expected_version, + ), + ), + "service-restart": ( + OptionSpec( + "service_name", + "Servis adı", + kind="choice", + choices=SERVICE_ALLOWLIST, + required=True, + hint="Yalnız izinli servis adları", + validator=validate_service_name, + ), + ), + "run-script": ( + OptionSpec( + "script_path", + "Script yolu", + required=True, + hint=f"İzinli dizinler: {', '.join(SCRIPT_DIRS)}", + validator=validate_script_path, + ), + OptionSpec("script_args", "Script argümanları", default="", hint="İsteğe bağlı"), + ), + "collect-logs": ( + OptionSpec( + "log_lines", + "Cihaz başına journal satırı", + kind="int", + default="500", + validator=validate_log_lines, + ), + ), + "reboot": ( + OptionSpec( + "reboot_grace_seconds", + "Yeniden başlatma bekleme süresi (saniye)", + kind="int", + default="300", + validator=validate_grace, + ), + ), + "deploy-update": ( + OptionSpec( + "update_packages", + "Paket pinleri (paket=sürüm, virgülle)", + required=True, + hint="Örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble", + validator=validate_package_pins, + ), + ), +} + +OPERATIONS: tuple[Operation, ...] = ( + # --- Kurulum (bu cihaz; filo cihazina DOKUNMAZ) ------------------------ + Operation( + "install-preflight", + "Kurulum ön kontrolü (--check)", + "Kurulum", + destructive=False, + scope="local", + description="Hiçbir şey yazmadan kurulum ön koşullarını raporlar.", + needs_dealer=True, + ), + Operation( + "install-local", + "Bu cihazı kur", + "Kurulum", + destructive=True, + scope="local", + description="Cihazı kurar, hostname'i bf- yapar.", + requires_root=True, + needs_dealer=True, + options=( + OptionSpec("offline", "Yerel çevrimdışı depodan kur (--offline)", kind="bool"), + OptionSpec("resume", "Zaten OK olan modülleri atla (--resume)", kind="bool"), + ), + ), + Operation( + "install-resume", + "Başarısız kuruluma devam et (--resume)", + "Kurulum", + destructive=True, + scope="local", + description="Yarıda kalan kurulumu idempotent biçimde sürdürür.", + requires_root=True, + needs_dealer=True, + ), + Operation( + "install-module", + "Tek modül çalıştır (--only)", + "Kurulum", + destructive=True, + scope="local", + description="Kurulumun tek bir modülünü çalıştırır.", + requires_root=True, + needs_dealer=True, + options=( + OptionSpec( + "only", + "Modül", + required=True, + hint="Örn. 01-precheck", + validator=validate_module_name, + ), + ), + ), + Operation( + "install-state", + "Kurulum modül durumu", + "Kurulum", + destructive=False, + scope="local", + description="Kurulumun hangi modülde kaldığını gösterir (salt-okunur).", + ), + Operation( + "install-log", + "Kurulum günlüğü (son 40 satır)", + "Kurulum", + destructive=False, + scope="local", + description="Kurulum günlüğünün sonunu gösterir (salt-okunur).", + ), + # --- Cihaz islemleri: salt-okunur raporlar ----------------------------- + Operation("ping", "Ping / erişilebilirlik", "Cihaz işlemleri", False, "simple", + "SSH erişilebilirliği (salt-okunur).", "bf-ping.yml"), + Operation("uptime", "Çalışma süresi ve yük", "Cihaz işlemleri", False, "simple", + "Uptime ve yük ortalaması (salt-okunur).", "bf-uptime.yml"), + Operation("disk", "Disk kullanımı", "Cihaz işlemleri", False, "simple", + "Disk kullanımı; eşik üstünde uyarır.", "bf-disk-check.yml", + options=_FLEET_OPTIONS["disk"]), + Operation("docker", "Docker durumu", "Cihaz işlemleri", False, "simple", + "Docker servisi ve konteynerler (salt-okunur).", "bf-docker-status.yml"), + Operation("packages", "Kurulu paket sürümleri", "Cihaz işlemleri", False, "simple", + "Pinlenmiş paketlerin kurulu sürümleri.", "bf-package-check.yml"), + Operation("security-updates", "Bekleyen güvenlik güncellemeleri", "Cihaz işlemleri", + False, "simple", "Yalnız rapor; hiçbir güncelleme kurmaz.", + "bf-security-updates-check.yml"), + Operation("provisioning-status", "Kurulum fazı raporu", "Cihaz işlemleri", False, + "wave", "Cihaz başına state.json fazı.", "bf-provisioning-status.yml"), + Operation("enrollment-status", "Kayıt (enrollment) ve merkez kanıtı", + "Cihaz işlemleri", False, "wave", + "ENROLLED/READY iddialarını merkezi kanıta karşı denetler.", + "bf-enrollment-status.yml"), + Operation("fieldos-version", "Field OS sürüm raporu", "Cihaz işlemleri", False, "wave", + "Sürüm bilgisi; istenirse drift denetimi.", "bf-fieldos-version.yml", + options=_FLEET_OPTIONS["fieldos-version"]), + Operation("remote-channels", "Uzak erişim kanalları", "Cihaz işlemleri", False, "wave", + "SSH/xRDP/RustDesk/WireGuard hazırlığı.", "bf-remote-channels-check.yml"), + Operation("offline-ready", "Çevrimdışı hazırlık raporu", "Cihaz işlemleri", False, "wave", + "Offline provisioning yerel ön koşulları.", "bf-offline-ready-check.yml"), + # --- Cihaz islemleri: bakim (yikici olanlar isaretli) ------------------- + Operation("rdp-restart", "xRDP servisini yeniden başlat", "Cihaz işlemleri", True, + "simple", "xRDP servisini yeniden başlatır (RDP açık kalır).", + "bf-rdp-restart.yml"), + Operation("rustdesk-restart", "RustDesk servisini yeniden başlat", "Cihaz işlemleri", + True, "simple", "RustDesk servisini yeniden başlatır.", "bf-rustdesk-restart.yml"), + Operation("wireguard-restart", "WireGuard (wg0) yeniden başlat", "Cihaz işlemleri", True, + "simple", "wg-quick@wg0 servisini yeniden başlatır.", "bf-wireguard-restart.yml"), + Operation("service-restart", "İzinli servisi yeniden başlat", "Cihaz işlemleri", True, + "simple", "Yalnız izinli servis adlarını yeniden başlatır.", + "bf-service-restart.yml", options=_FLEET_OPTIONS["service-restart"]), + Operation("gui-off", "Yerel grafik oturumu kapat (headless)", "Cihaz işlemleri", True, + "wave", "Dalgayı multi-user.target'e alır; xRDP'yi durdurmaz.", "bf-gui-off.yml"), + Operation("gui-on", "Yerel grafik oturumu aç (bakım)", "Cihaz işlemleri", True, "wave", + "Grafik oturumu yeniden açar (bakım için).", "bf-gui-on.yml"), + Operation("collect-logs", "Günlük topla / destek paketi", "Cihaz işlemleri", False, + "wave", "Zaman damgalı journal + destek paketi üretir.", "bf-collect-logs.yml", + options=_FLEET_OPTIONS["collect-logs"]), + Operation("run-script", "Onaylı script çalıştır", "Cihaz işlemleri", True, "wave", + "Yalnız onaylı dizinlerdeki scriptleri çalıştırır.", "bf-run-script.yml", + options=_FLEET_OPTIONS["run-script"]), + Operation("reboot", "Kontrollü yeniden başlatma", "Cihaz işlemleri", True, "release", + "Seri yeniden başlatma + sağlık kapısı.", "bf-reboot.yml", + options=_FLEET_OPTIONS["reboot"]), + Operation("deploy-update", "Onaylı paket güncellemesi", "Cihaz işlemleri", True, + "release", "package=sürüm pinleri + dalga ve sağlık kapıları.", + "bf-deploy-update.yml", options=_FLEET_OPTIONS["deploy-update"]), + # --- Merkez hazirligi (salt-okunur denetim; merkez SUNUCUSU kurulmaz) --- + Operation("central-targets", "Envanter hedefleri (dalga/grup/cihaz sayıları)", + "Merkez hazırlığı", False, "local", + "Envanteri ve dalga gruplarını listeler; hiçbir cihaza dokunmaz."), + Operation("central-placeholders", "Doldurulmamış PLACEHOLDER denetimi", + "Merkez hazırlığı", False, "local", + "group_vars/all.yml içinde kalan PLACEHOLDER değerlerini gösterir."), + Operation("central-endpoints", "Yapılandırılmış merkez uç noktaları", + "Merkez hazırlığı", False, "local", + "WireGuard hub, izleme ve depo uç noktalarını (secret hariç) gösterir."), + Operation("central-checklist", "Merkez hazırlık kontrol listesi", + "Merkez hazırlığı", False, "local", + "Kayıttan önce merkezde var olması gerekenleri hatırlatır."), + # --- Araclar ----------------------------------------------------------- + Operation("tools-catalogue", "İşlem kataloğunu göster (--list)", "Araçlar", False, + "local", "Konsolun işlem kataloğunu listeler."), + Operation("tools-git-pull", "Bu kopyayı güncelle (git pull --ff-only)", "Araçlar", True, + "local", "Depo kopyasını ileri sarar; yerel değişikliği silmez."), + Operation("tools-gate-local", "Yerel çevrimdışı kapısı (bf-check-local)", "Araçlar", + False, "local", "Cihazın çevrimdışı kurulum ön koşullarını denetler."), + Operation("tools-gate-enrollment", "Kayıt kapısı (bf-check-enrollment)", "Araçlar", + False, "local", "Kayıt (enrollment) ön koşullarını denetler."), + Operation("tools-gate-ready", "Teslim kapısı (bf-check-ready)", "Araçlar", False, + "local", "Cihazın READY olabilmesi için gerekenleri denetler."), + Operation("tools-diagnostics", "Derin teşhis (bf-diagnostics)", "Araçlar", False, + "local", "Kapsamlı teşhis raporu üretir (salt-okunur)."), + Operation("tools-release", "Field OS sürüm kimliği (bf-release)", "Araçlar", False, + "local", "Cihazdaki Field OS sürümünü gösterir."), + Operation("tools-support-bundle", "Destek paketi oluştur", "Araçlar", False, "local", + "Parola/anahtar içermeyen destek paketi üretir."), + Operation("tools-console-log", "Konsol denetim günlüğü (son 40 satır)", "Araçlar", + False, "local", "Operatör konsolunun denetim günlüğünü gösterir."), + # --- Cihaz bilgisi (salt-okunur) --------------------------------------- + Operation("info-summary", "Durum özeti (sağlık + kurulum durumu)", "Cihaz bilgisi", + False, "local", + "bf-status, whitelist'li state.json alanları ve envanter hedefleri."), + Operation("info-identity", "Kimlik (hostname, cihaz no)", "Cihaz bilgisi", False, + "local", "Makine adı ve cihaz kimliği."), + Operation("info-network", "Ağ (adres, rota, DNS, WireGuard)", "Cihaz bilgisi", False, + "local", "Ağ arayüzleri, varsayılan rota, DNS ve WireGuard durumu."), + Operation("info-resources", "Disk, bellek, yük", "Cihaz bilgisi", False, "local", + "Dosya sistemleri, bellek ve yük ortalaması."), + Operation("info-packages", "Kurulu pinlenmiş paketler", "Cihaz bilgisi", False, "local", + "docker-ce, wireguard, rustdesk, xrdp, openssh-server sürümleri."), + Operation("info-services", "Servis durumu", "Cihaz bilgisi", False, "local", + "ssh, xrdp, rustdesk, docker, wg-quick@wg0, cron durumu."), + Operation("info-journal", "Son journal hataları", "Cihaz bilgisi", False, "local", + "journalctl -p err son 20 satır."), + Operation("info-state", "Kurulum/kayıt durumu (state.json beyaz liste)", "Cihaz bilgisi", + False, "local", + "Yalnız kimlik/durum alanları okunur; token ve anahtar asla gösterilmez."), +) + +_OPERATION_INDEX: Mapping[str, Operation] = {op.id: op for op in OPERATIONS} + + +def operation_by_id(operation_id: str) -> Operation | None: + return _OPERATION_INDEX.get(operation_id) + + +def operations_by_category(category: str) -> tuple[Operation, ...]: + return tuple(op for op in OPERATIONS if op.category == category) + + +# --------------------------------------------------------------------------- +# Yardimci: yol cozumleme (admin/bf-menu ile ayni kurallar) +# --------------------------------------------------------------------------- +def repo_root_from_file() -> Path: + """bfos/ui_gui.py -> depo koku (bir ust dizin).""" + return Path(__file__).resolve().parents[1] + + +def resolve_inventory(repo_root: Path, explicit: str | None = None) -> Path | None: + if explicit: + candidate = Path(explicit).expanduser() + return candidate if candidate.is_file() else None + base = repo_root / "ansible" / "inventory" + for name in ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini"): + candidate = base / name + if candidate.is_file(): + return candidate + return None + + +def resolve_tool(name: str, repo_root: Path | None = None) -> str | None: + """admin/bf-menu bf_tool_path ile ayni sira: /usr/local/bin, sonra depo.""" + system = Path("/usr/local/bin") / name + if system.is_file() and os.access(system, os.X_OK): + return str(system) + root = repo_root or repo_root_from_file() + for sub in ("scripts/diagnostics", "scripts/maintenance"): + candidate = root / sub / name + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + return None + + +def playbook_path(repo_root: Path, playbook: str) -> Path: + return repo_root / PLAYBOOK_DIR_REL / playbook + + +def playbook_declares_var(path: Path, var: str) -> bool: + """Playbook dosyasi VAR'i (yorum satirlari haric) kullaniyor mu? + + admin/bf-menu -> bf_playbook_var_present ile ayni fikir: playbook kendi + kapisinin kaynagi kalir, konsol onu varsaymaz. + """ + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + pattern = re.compile(rf"\b{re.escape(var)}\b") + for line in text.splitlines(): + stripped = line.lstrip() + if stripped.startswith("#"): + continue + if pattern.search(line): + return True + return False + + +def playbook_scope(path: Path) -> str: + """Playbook'un kendi beyan ettigi kapsam: release | wave | simple.""" + if playbook_declares_var(path, "release_wave"): + return "release" + if playbook_declares_var(path, "operation_wave"): + return "wave" + return "simple" + + +def parse_inventory(path: Path) -> dict[str, list[str]]: + """Salt-okunur envanter okuma: {grup: [host, ...]}. + + admin/lib/targets.sh -> bf_inventory_pairs awk okuyucusunun Python + karsiligi: `hosts:` bloklarini iceren en yakin grup anahtari kullanilir. + Amac yalnizca hedef listesi/dalga listesi doldurmak; hicbir sey yazilmaz. + """ + groups: dict[str, list[str]] = {} + stack: list[tuple[int, str]] = [] + hosts_indent: int | None = None + hosts_step: int | None = None + hosts_group = "" + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return groups + + for raw in lines: + if raw.lstrip().startswith("#") or not raw.strip() or raw.lstrip().startswith("-"): + continue + if ":" not in raw: + continue + indent = len(raw) - len(raw.lstrip(" ")) + key, _, value = raw.partition(":") + key = key.strip().strip("\"'") + value = value.strip().strip("\"'") + if not key: + continue + while stack and stack[-1][0] >= indent: + stack.pop() + + if key == "hosts" and value == "": + hosts_indent = indent + hosts_step = None + hosts_group = stack[-1][1] if stack else "" + stack.append((indent, key)) + continue + if hosts_indent is not None and indent <= hosts_indent: + hosts_indent = None + hosts_step = None + + if hosts_indent is not None and indent > hosts_indent: + if hosts_step is None: + hosts_step = indent + if indent == hosts_step and hosts_group: + groups.setdefault(hosts_group, []).append(key) + stack.append((indent, key)) + continue + if value == "": + stack.append((indent, key)) + return groups + + +def waves_in_inventory(groups: Mapping[str, Sequence[str]]) -> tuple[str, ...]: + """Envanterde gercekten bulunan onayli dalgalar (yayilim sirasiyla).""" + return tuple(wave for wave in WAVES if groups.get(wave)) + + +def device_wave(host: str, groups: Mapping[str, Sequence[str]]) -> str | None: + for wave in WAVES: + if host in (groups.get(wave) or ()): + return wave + return None + + +# --------------------------------------------------------------------------- +# Komut calistirma (sozlesme maddesi 4) +# --------------------------------------------------------------------------- +def run_command( + argv: Sequence[str], + on_line: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, + cwd: str | None = None, +) -> RunResult: + """Tek komutu calistirir; ciktisi satir satir `on_line`'a akar.""" + argv = [str(a) for a in argv] + if not argv: + return RunResult(2, False, "Boş komut çalıştırılamaz.") + emit = on_line or (lambda _line: None) + try: + proc = subprocess.Popen( # noqa: S603 - argv listesi, shell yok + argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=cwd, + ) + except FileNotFoundError: + message = ( + f"Komut bulunamadı: '{argv[0]}' bu cihazda kurulu değil. " + "Araçları bağlamak için: sudo admin/bf-bootstrap.sh" + ) + emit(message) + return RunResult(127, False, message + "\n") + except OSError as exc: + message = f"Komut başlatılamadı: {' '.join(argv)} ({exc})" + emit(message) + return RunResult(126, False, message + "\n") + + collected: list[str] = [] + assert proc.stdout is not None + for line in proc.stdout: + collected.append(line) + emit(line.rstrip("\n")) + if cancel is not None and cancel.is_set(): + proc.terminate() + note = "Kullanıcı iptal etti; komut sonlandırıldı." + collected.append(note + "\n") + emit(note) + break + exit_code = proc.wait() + return RunResult(exit_code, exit_code == 0, "".join(collected)) + + +def run_plan( + plan: CommandPlan, + on_line: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, + cwd: str | None = None, +) -> RunResult: + """Plan icindeki komutlari sirayla calistirir; ilk hatada DURUR. + + (Wave akisindaki "bir dalga basarisizsa sonraki dalgaya gecme" kuralinin + basit karsiligi: hata sonrasi komut calistirilmaz.) + """ + emit = on_line or (lambda _line: None) + if plan.is_empty: + return RunResult(2, False, "Çalıştırılacak komut yok.\n") + if plan.is_report_only: + for line in plan.report_lines: + emit(line) + return RunResult(0, True, "\n".join(plan.report_lines) + "\n") + if plan.dry_run: + for line in plan.lines: + emit(f"[deneme] {line}") + return RunResult(0, True, "[deneme] komut çalıştırılmadı.\n") + + chunks: list[str] = [] + for index, argv in enumerate(plan.commands, start=1): + if not argv: + continue + if len(plan.commands) > 1: + emit(f"--- [{index}/{len(plan.commands)}] {shlex.join(argv)}") + result = run_command(argv, on_line=emit, cancel=cancel, cwd=cwd) + chunks.append(result.output) + if not result.ok: + emit(f"[HATA] Komut {result.exit_code} koduyla bitti: {shlex.join(argv)}") + return RunResult(result.exit_code, False, "".join(chunks)) + return RunResult(0, True, "".join(chunks)) + + +# --------------------------------------------------------------------------- +# Backend: yerlesik katalog (admin/bf-menu kurallari) + gercek modul adapteri +# --------------------------------------------------------------------------- +@dataclass +class GuiContext: + """Arayuzun calisma baglami (yol ve kip bilgisi).""" + + repo_root: Path = field(default_factory=repo_root_from_file) + inventory: Path | None = None + console_log: str = DEFAULT_CONSOLE_LOG + dry_run: bool = False + + @classmethod + def detect(cls, repo: str | None = None, inventory: str | None = None, + dry_run: bool = False) -> "GuiContext": + root = Path(repo).expanduser().resolve() if repo else repo_root_from_file() + return cls( + repo_root=root, + inventory=resolve_inventory(root, inventory), + console_log=os.environ.get("BF_CONSOLE_LOG", DEFAULT_CONSOLE_LOG), + dry_run=dry_run, + ) + + +class CatalogueBackend: + """Yerlesik katalog: admin/bf-menu'nun tablosu + komut uretimi. + + Gercek moduller (bfos.operations / bfos.runner) yoksa arayuz bunu kullanir; + boylece GUI, backend yazilmadan once de dogru komutu gosterip calistirabilir. + """ + + name = "yerleşik katalog (admin/bf-menu kuralları)" + + def __init__(self, ctx: GuiContext): + self.ctx = ctx + self._groups: dict[str, list[str]] | None = None + + # -- envanter (salt-okunur) ------------------------------------------- + @property + def groups(self) -> dict[str, list[str]]: + if self._groups is None: + self._groups = ( + parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} + ) + return self._groups + + def list_operations(self) -> tuple[Operation, ...]: + return OPERATIONS + + def list_waves(self) -> tuple[str, ...]: + present = waves_in_inventory(self.groups) + return present or WAVES + + def inventory_note(self) -> str: + if self.ctx.inventory is None: + note = ( + "Ansible envanteri bulunamadı (ansible/inventory/hosts.yml). " + "Filo işlemleri için envanteri oluşturun: " + "cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml" + ) + else: + note = f"Envanter: {self.ctx.inventory}" + if shutil.which("ansible-playbook") is None: + note += ( + " | UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz " + "(sudo apt install ansible)." + ) + return note + + # -- hedef ------------------------------------------------------------- + def validate_target(self, operation: Operation, target: Target) -> str | None: + if target.kind == "dealer": + problem = dealer_error(target.value) + if problem: + return problem + if operation.scope in ("wave", "release"): + if target.kind == "dealer": + if self.ctx.inventory is None: + return ( + "Bu işlem dalga kapılıdır ve cihazın dalgasını çözmek için " + "envanter gerekir; envanter bulunamadı." + ) + wave = device_wave(dealer_to_host(target.value), self.groups) + if not wave: + return ( + f"{dealer_to_host(target.value)} onaylı bir dalga grubunda değil " + f"({', '.join(WAVES)}). Önce cihazı dalgasına ekleyin." + ) + elif target.kind == "all": + if operation.scope == "release": + return ( + "Sürüm kapılı işlemler tüm filo ile çalıştırılamaz: " + "onaylı bir dalga grubu seçin (dalga dalga yayılım)." + ) + elif target.kind == "group" and not is_wave(target.value): + return ( + f"'{target.value}' onaylı bir dalga değil. Dalga kapılı işlemler " + f"şu dalgalardan birini ister: {', '.join(WAVES)}." + ) + return None + + def target_wave(self, operation: Operation, target: Target) -> str | None: + if target.kind == "group" and is_wave(target.value): + return target.value + if target.kind == "dealer": + return device_wave(dealer_to_host(target.value), self.groups) + return None + + # -- kapilar (acik onay adimlari) --------------------------------------- + def gate_steps( + self, operation: Operation, target: Target | None + ) -> tuple[GateStepInfo, ...]: + """Calistirmadan once sorulacak ACIK onaylar (bf-menu ile ayni kurallar). + + Hicbir kapi sessizce gecilmez: dalga adi yazdirilir, production icin + ayrica `production` yazilir, surum kapisi icin GATE yazilir, yikici + islemler icin ikinci onay istenir. + """ + steps: list[GateStepInfo] = [] + wave = self.target_wave(operation, target) if target else None + book = ( + playbook_path(self.ctx.repo_root, operation.playbook) + if operation.playbook + else None + ) + gate_supported = bool(book and playbook_declares_var(book, "wave_gate_confirmed")) + + if operation.scope in ("wave", "release") and wave: + if wave == PRODUCTION_WAVE: + steps.append( + GateStepInfo( + kind="token", + key="production_token", + prompt="PRODUCTION onayı — canlı filo", + message=( + "Seçilen dalga canlı filodur (production).\n" + "Onaylamak için kutuya 'production' yazın; başka bir şey iptal eder." + + ("\nEk olarak dalga kapısı bayrağı onaylanacak." if gate_supported else "") + ), + token=PRODUCTION_WAVE, + unlocks=("wave_gate_confirmed",) if gate_supported else (), + ) + ) + steps.append( + GateStepInfo( + kind="approval", + key="production_approval", + prompt="Production ek onayı", + message=( + "production_override / allow_production_reboot bayrakları " + "yalnız bu onayla eklenir (playbook ilgili değişkeni " + "kullanıyorsa).\nDevam edilsin mi?" + ), + unlocks=("production_override", "allow_production_reboot"), + ) + ) + elif operation.scope == "release" and gate_supported: + steps.append( + GateStepInfo( + kind="token", + key="wave_gate_token", + prompt=f"Sürüm kapısı onayı (dalga: {wave})", + message=( + f"'{wave}' dalgası için sürüm kapısını onaylıyorsunuz.\n" + f"Önceki dalga sağlık kapısını geçtiyse '{GATE_TOKEN_RELEASE}' yazın." + ), + token=GATE_TOKEN_RELEASE, + unlocks=("wave_gate_confirmed",), + ) + ) + if target is not None and target.kind == "all" and operation.scope == "simple": + steps.append( + GateStepInfo( + kind="token", + key="fleet_token", + prompt="FİLO GENELİ onayı", + message=( + "Komut envanterdeki HER cihaza uygulanacak.\n" + "Onaylamak için kutuya 'all' yazın." + ), + token="all", + unlocks=("fleet_wide",), + ) + ) + if operation.destructive: + steps.append( + GateStepInfo( + kind="approval", + key="destructive_approval", + prompt="İKİNCİ ONAY — yıkıcı işlem", + message=( + f"'{operation.label}' seçili hedefte DEĞİŞİKLİK YAPAR.\n" + "Bu onay yalnız bu işlem için geçerlidir. Devam edilsin mi?" + ), + ) + ) + return tuple(steps) + + # -- komut uretimi ----------------------------------------------------- + def build_command( + self, + operation: Operation, + target: Target | None = None, + options: Mapping[str, str] | None = None, + grants: Mapping[str, bool] | None = None, + check_mode: bool = False, + ) -> CommandPlan: + opts = dict(options or {}) + self._check_options(operation, opts) + if operation.scope in ("wave", "release") and target is not None: + problems = self.validate_target(operation, target) + if problems: + raise CommandError(problems) + if operation.is_fleet: + return self._build_fleet_plan(operation, target, opts, dict(grants or {}), check_mode) + return self._build_local_plan(operation, target, opts, check_mode) + + def _check_options(self, operation: Operation, opts: Mapping[str, str]) -> None: + for spec in operation.options: + value = (opts.get(spec.name) or "").strip() + if spec.required and not value: + raise CommandError(f"'{spec.label}' zorunludur ({operation.label}).") + if value and spec.validator: + problem = spec.validator(value) + if problem: + raise CommandError(problem) + + def _build_fleet_plan( + self, + operation: Operation, + target: Target | None, + opts: Mapping[str, str], + grants: Mapping[str, bool], + check_mode: bool = False, + ) -> CommandPlan: + if target is None or not target.value: + raise CommandError( + "Hedef seçilmedi; komut üretilmedi. Bayi numarası veya dalga grubu seçin." + ) + problem = self.validate_target(operation, target) + if problem: + raise CommandError(problem) + if self.ctx.inventory is None: + raise CommandError( + "Ansible envanteri bulunamadı; filo işlemleri komutu üretilemiyor. " + "Beklenen yol: ansible/inventory/hosts.yml (veya --inventory PATH)." + ) + book = playbook_path(self.ctx.repo_root, operation.playbook) + if not book.is_file(): + raise CommandError( + f"Playbook bulunamadı: {PLAYBOOK_DIR_REL}/{operation.playbook}\n" + "Doğru depo kopyasında çalıştırın (--repo PATH) veya checkout'u güncelleyin." + ) + declared = playbook_scope(book) + if declared != operation.scope: + raise CommandError( + f"{operation.playbook} çalıştırılmıyor: konsol kapsamı '{operation.scope}' " + f"beklerken playbook '{declared}' beyan ediyor.\n" + "Playbook kapıların kaynağıdır; tablo ile playbook'u eşitleyin " + "(admin/bf-menu -> bf_playbook_check ile aynı kural)." + ) + + wave = self.target_wave(operation, target) + if operation.scope in ("wave", "release") and not wave: + raise CommandError( + "Bu işlem dalga kapsamlıdır ama hedefte onaylı bir dalga yok. " + "Bir dalga grubu seçin (" + ", ".join(WAVES) + ")." + ) + + argv: list[str] = [ + "ansible-playbook", + "-i", + str(self.ctx.inventory), + str(book), + "--limit", + target.limit, + ] + notes: list[str] = [] + if operation.scope == "wave": + argv += ["-e", f"operation_wave={wave}", "-e", "operation_confirmed=true"] + if ( + wave == PRODUCTION_WAVE + and grants.get("production_override") + and playbook_declares_var(book, "production_override") + ): + argv += ["-e", "production_override=true"] + notes.append( + "Dalga kapısı: yalnız önceki dalga sağlık kapısını ve merkez onayını " + "geçtiyse çalıştırın (docs/10)." + ) + elif operation.scope == "release": + argv += ["-e", f"release_wave={wave}"] + if grants.get("wave_gate_confirmed") and playbook_declares_var( + book, "wave_gate_confirmed" + ): + argv += ["-e", "wave_gate_confirmed=true"] + if playbook_declares_var(book, "reboot_confirmed"): + argv += ["-e", "reboot_confirmed=true"] + if wave == PRODUCTION_WAVE: + if grants.get("production_override") and playbook_declares_var( + book, "production_override" + ): + argv += ["-e", "production_override=true"] + if grants.get("allow_production_reboot") and playbook_declares_var( + book, "allow_production_reboot" + ): + argv += ["-e", "allow_production_reboot=true"] + notes.append( + "Sürüm kapısı: dalga onayı ve sağlık kapısı olmadan çalıştırmayın (docs/10)." + ) + if target.kind == "all" and operation.scope == "wave": + notes.append( + "Tüm filo: dalga kapılı playbook'lar dalga dalga ilerler; her dalgayı " + "ayrı ayrı onaylayın." + ) + + argv += self._extra_vars(operation.id, opts) + if check_mode: + argv.append("--check") + notes.append("--check: playbook hiçbir değişiklik uygulamaz (deneme koşusu).") + return CommandPlan( + commands=(tuple(argv),), + destructive=operation.destructive, + requires_root=operation.requires_root, + operation_id=operation.id, + target_label=target.label + (f" (dalga {wave})" if wave else ""), + notes=tuple(notes), + dry_run=self.ctx.dry_run, + check_mode=check_mode, + ) + + def _extra_vars(self, operation_id: str, opts: Mapping[str, str]) -> list[str]: + value = lambda name: (opts.get(name) or "").strip() # noqa: E731 + extra: list[str] = [] + if operation_id == "disk" and value("disk_warn_pct"): + extra += ["-e", f"disk_warn_pct={value('disk_warn_pct')}"] + elif operation_id == "fieldos-version" and value("fieldos_expected_version"): + extra += ["-e", f"fieldos_expected_version={value('fieldos_expected_version')}"] + elif operation_id == "collect-logs" and value("log_lines"): + extra += ["-e", f"log_lines={value('log_lines')}"] + elif operation_id == "reboot" and value("reboot_grace_seconds"): + extra += ["-e", f"reboot_grace_seconds={value('reboot_grace_seconds')}"] + elif operation_id == "service-restart" and value("service_name"): + extra += ["-e", f"service_name={value('service_name')}"] + elif operation_id == "run-script" and value("script_path"): + extra += ["-e", f"script_path={value('script_path')}"] + if value("script_args"): + extra += ["-e", f"script_args={value('script_args')}"] + elif operation_id == "deploy-update" and value("update_packages"): + pins = [ + f'"{pin.strip()}"' + for pin in value("update_packages").split(",") + if pin.strip() + ] + extra += ["-e", f"update_packages=[{','.join(pins)}]"] + return extra + + def _installer_arg(self) -> str: + installer = self.ctx.repo_root / INSTALLER_REL + if not installer.is_file(): + raise CommandError( + f"Kurulum betiği bulunamadı: {INSTALLER_REL}\n" + "Depo kopyasını doğru verin (--repo PATH) veya checkout'u güncelleyin." + ) + return str(installer) + + def _menu_arg(self) -> str: + menu = self.ctx.repo_root / MENU_REL + if not menu.is_file(): + raise CommandError( + f"Konsol betiği bulunamadı: {MENU_REL}\n" + "Depo kopyasını doğru verin (--repo PATH)." + ) + return str(menu) + + def _tool_argv(self, tool: str, *args: str, optional: bool = False) -> tuple[str, ...] | None: + path = resolve_tool(tool, self.ctx.repo_root) + if path is None: + if optional: + return None + raise CommandError( + f"Araç bulunamadı: {tool}\n" + "Bu araç depoda veya /usr/local/bin altında yok. Bağlamak için: " + "sudo admin/bf-bootstrap.sh" + ) + return (path, *args) + + def _build_local_plan( + self, + operation: Operation, + target: Target | None, + opts: Mapping[str, str], + check_mode: bool = False, + ) -> CommandPlan: + dealer = "" + if operation.needs_dealer: + if target is None or target.kind != "dealer" or not target.value: + raise CommandError( + f"'{operation.label}' için bayi numarası gerekir " + "(hedef alanına 8 haneli bayi no yazın)." + ) + problem = dealer_error(target.value) + if problem: + raise CommandError(problem) + dealer = target.value + commands: list[tuple[str, ...]] = [] + notes: list[str] = [] + op_id = operation.id + + if op_id == "install-preflight": + commands.append( + ("bash", self._installer_arg(), "--dealer-id", dealer, "--check") + ) + notes.append("Ön kontrol hiçbir şey yazmaz (log/state/dizin oluşturmaz).") + elif op_id in ("install-local", "install-resume"): + argv = ["bash", self._installer_arg(), "--dealer-id", dealer] + if op_id == "install-resume" or opts.get("resume") == "1": + argv.append("--resume") + if opts.get("offline") == "1": + argv.append("--offline") + commands.append(tuple(argv)) + notes.append(f"Hedef cihaz kimliği: {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)}).") + elif op_id == "install-module": + commands.append( + ( + "bash", + self._installer_arg(), + "--dealer-id", + dealer, + "--only", + (opts.get("only") or "").strip(), + ) + ) + elif op_id == "install-state": + commands.append(("cat", INSTALL_STATE_PATH)) + elif op_id == "install-log": + commands.append(("tail", "-n", "40", INSTALL_LOG_PATH)) + elif op_id == "central-targets": + commands.append(("bash", self._menu_arg(), "--list-targets")) + elif op_id == "central-placeholders": + commands.append( + ( + "grep", + "-n", + "PLACEHOLDER", + str(self.ctx.repo_root / "ansible" / "inventory" / "group_vars" / "all.yml"), + ) + ) + notes.append("Eşleşme yoksa bu iyi haberdir (PLACEHOLDER kalmamış demektir).") + elif op_id == "central-endpoints": + commands.append( + ( + "grep", + "-nE", + r"^[[:space:]]*(wg_subnet|wg_interface|wg_hub_endpoint|monitoring_endpoint|" + r"monitoring_job|update_repo_url|meg_image_tag|support_bundle_dir)[[:space:]]*:", + str(self.ctx.repo_root / "ansible" / "inventory" / "group_vars" / "all.yml"), + ) + ) + notes.append("Yalnız yapılandırma anahtarları okunur; secret hiçbir zaman gösterilmez.") + elif op_id == "central-checklist": + for line in CENTRAL_CHECKLIST: + notes.append(line) + elif op_id == "tools-catalogue": + commands.append(("bash", self._menu_arg(), "--list")) + elif op_id == "tools-git-pull": + commands.append(("git", "-C", str(self.ctx.repo_root), "pull", "--ff-only")) + notes.append("Çekme sonrası konsol değişmiş olabilir; arayüzü yeniden başlatın.") + elif op_id == "tools-gate-local": + commands.append(self._tool_argv("bf-check-local", "--check") or ()) + elif op_id == "tools-gate-enrollment": + commands.append(self._tool_argv("bf-check-enrollment", "--check") or ()) + elif op_id == "tools-gate-ready": + commands.append(self._tool_argv("bf-check-ready", "--check") or ()) + elif op_id == "tools-diagnostics": + commands.append(self._tool_argv("bf-diagnostics") or ()) + elif op_id == "tools-release": + commands.append(self._tool_argv("bf-release") or ()) + elif op_id == "tools-support-bundle": + commands.append( + self._tool_argv("bf-support-bundle", "--out-dir", "/var/tmp") or () + ) + notes.append("Destek paketi parola, private key veya token içermez.") + elif op_id == "tools-console-log": + commands.append(("tail", "-n", "40", self.ctx.console_log)) + elif op_id == "info-summary": + commands.extend(self._status_summary_commands()) + elif op_id == "info-identity": + commands.append(("hostname",)) + commands.append( + ( + "grep", + "-nE", + r"^[[:space:]]*\"(device_id|phase)\"", + STATE_JSON_PATH, + ) + ) + elif op_id == "info-network": + commands.append(("ip", "-brief", "address")) + commands.append(("ip", "route", "show", "default")) + commands.append(("sh", "-c", "awk '/^nameserver/{print}' /etc/resolv.conf")) + commands.append(("wg", "show", "wg0", "latest-handshakes")) + notes.append("WireGuard private/preshared anahtarları istenmez ve gösterilmez.") + elif op_id == "info-resources": + commands.append(("df", "-h")) + commands.append(("free", "-h")) + commands.append(("uptime",)) + elif op_id == "info-packages": + commands.append( + ( + "dpkg-query", + "-W", + "-f=${Package} ${Version} ${Status}\n", + "docker-ce", + "wireguard", + "rustdesk", + "xrdp", + "openssh-server", + ) + ) + elif op_id == "info-services": + commands.append( + ( + "systemctl", + "is-active", + "ssh", + "sshd", + "xrdp", + "rustdesk", + "docker", + "wg-quick@wg0", + "cron", + ) + ) + elif op_id == "info-journal": + commands.append(("journalctl", "-p", "err", "-n", "20", "--no-pager")) + elif op_id == "info-state": + commands.append( + ( + "grep", + "-nE", + r"^[[:space:]]*\"(phase|enrollment_status|fleet_status|device_id|provisioning_id)\"", + STATE_JSON_PATH, + ) + ) + notes.append( + "Yalnız kimlik/durum alanları okunur; token ve anahtarlar gösterilmez." + ) + else: + raise CommandError(f"Bilinmeyen operasyon: {operation.id}") + + commands = [argv for argv in commands if argv] + if not commands: + raise CommandError( + f"'{operation.label}' için çalıştırılabilir komut üretilemedi. " + "Bu cihazda gerekli araç kurulu olmayabilir (sudo admin/bf-bootstrap.sh)." + ) + # --check yalnizca destekleyen komutlara eklenir (kurulum betigi ve + # bf-check-* kapilari); tail/cat/grep/dpkg gibi komutlara eklenmez. + if check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS: + commands = [tuple(argv) + ("--check",) for argv in commands] + notes.append("--check: yalnız denetim yapılır, değişiklik uygulanmaz.") + label = target.label if target is not None and target.value else "bu cihaz (yerel)" + return CommandPlan( + commands=tuple(commands), + destructive=operation.destructive, + requires_root=operation.requires_root, + operation_id=operation.id, + target_label=label, + notes=tuple(notes), + dry_run=self.ctx.dry_run, + check_mode=check_mode and op_id in CHECK_CAPABLE_LOCAL_OPS, + ) + + def _status_summary_commands(self) -> list[tuple[str, ...]]: + """Durum ozeti: bakim sirasinda kaybolan cikti yerine guvenli, opsiyonel set.""" + commands: list[tuple[str, ...]] = [] + for tool, args in (("bf-status", ()), ("bf-enrollment-status", ("--check",))): + argv = self._tool_argv(tool, *args, optional=True) + if argv: + commands.append(argv) + commands.append( + ( + "grep", + "-nE", + r"^[[:space:]]*\"(phase|enrollment_status|fleet_status|device_id|provisioning_id)\"", + STATE_JSON_PATH, + ) + ) + commands.append(("bash", self._menu_arg(), "--list-targets")) + return commands + + # -- calistirma -------------------------------------------------------- + def run( + self, + plan: CommandPlan, + on_line: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, + ) -> RunResult: + return run_plan(plan, on_line=on_line, cancel=cancel, cwd=str(self.ctx.repo_root)) + + +CENTRAL_CHECKLIST: tuple[str, ...] = ( + "1. WireGuard hub: uç nokta, anahtarlar ve her bf- için eş ataması (docs/08).", + "2. Kayıt (enrollment) token servisi: tek kullanımlık, TTL sınırlı token (docs/29).", + "3. İzleme: Prometheus/VictoriaMetrics + Grafana, device_id etiketi = BF- (docs/14).", + "4. Uzak erişim: RustDesk hbbs/hbbr veya MeshCentral, WireGuard üzerinden erişilebilir (docs/07).", + "5. Paket aynası + saha kurulumları için çevrimdışı anlık görüntü (docs/10, docs/27).", + "6. Gerçek ana makine adlarını içeren Ansible envanteri ve yönetici SSH anahtarı (docs/02, 06, 09).", + "Bu arayüz 6'yı denetler; 1-5'i hazırlamaya yardımcı olur, merkez sunucusu KURMAZ.", +) + + +class ModuleBackend: + """Gercek bfos.operations / bfos.runner modullerini kullanan backend. + + Paylasilan is mantigi TEK kaynaktan gelir (kopyalanmaz): + * katalog : bfos.operations (catalogue_rows / ALL_OPERATIONS) + * envanter/hedef: Inventory.load_repository + resolve_target + * komut uretimi : build_ansible_command / build_install_command / + build_tool_command / build_extra_vars + * kapilar : confirmation_plan + GatesGranted (dalga, production, + surum kapisi, yikici ikinci onay) + * denetim kaydi : bfos.runner.AuditLog (varsa) -- TUI ile ayni kayit. + + Yerel (kurulum / arac / merkez / bilgi) islemler icin modulde karsiligi olan + kurucular kullanilir; karsiligi olmayan salt-okunur ekran ve komutlar + CatalogueBackend'e devredilir (bkz. _local). + """ + + name = "bfos.operations + bfos.runner (gerçek backend)" + + #: modulde komut kurucusu olan yerel arac islemleri: id -> (arac, sabit arg.) + _TOOL_OPS: Mapping[str, tuple[str, tuple[str, ...]]] = { + "tools-gate-local": ("bf-check-local", ()), + "tools-gate-enrollment": ("bf-check-enrollment", ()), + "tools-gate-ready": ("bf-check-ready", ()), + "tools-diagnostics": ("bf-diagnostics", ()), + "tools-release": ("bf-release", ()), + "tools-support-bundle": ("bf-support-bundle", ("--out-dir", "/var/tmp")), + } + + #: modulun salt-okunur rapor ureticileri: id -> fonksiyon adi + _REPORT_OPS: Mapping[str, str] = { + "central-targets": "targets_lines", + "central-readiness": "central_readiness_lines", + "central-endpoints": "central_endpoint_lines", + "central-probe": "central_probe_lines", + "central-wave-gates": "wave_gate_state_lines", + "info-identity": "identity_lines", + "info-state": "state_lines", + } + + def __init__(self, ops_module, runner_module, ctx: GuiContext): + self._ops = ops_module + self._runner = runner_module + self.ctx = ctx + self._local = CatalogueBackend(ctx) + self._repo = ops_module.Repository(str(ctx.repo_root)) + self._inventory = ops_module.Inventory.load_repository( + self._repo, str(ctx.inventory) if ctx.inventory else "" + ) + self._raw_ops: dict[str, Any] = {} + self._catalogue = self._load_catalogue() + self._by_id = {op.id: op for op in self._catalogue} + self._fleet_ids = frozenset(op.id for op in self._catalogue if op.is_fleet) + + # -- katalog ----------------------------------------------------------- + def _load_catalogue(self) -> tuple[Operation, ...]: + """Modulun kataloğu (filo işlemleri) + yerel işlemler (kurulum/araç/...).""" + raw: Any = None + for name in ("catalogue_rows", "list_operations", "operations", + "ALL_OPERATIONS", "OPERATIONS", "OPS", "get_operations"): + candidate = getattr(self._ops, name, None) + if candidate is None: + continue + try: + value = candidate() if callable(candidate) else candidate + except Exception: + continue + if value: + raw = value + break + if not raw: + raise BackendUnavailable( + "bfos.operations içinde operasyon listesi bulunamadı.\n" + "Beklenen: catalogue_rows() / ALL_OPERATIONS " + "(bkz. bfos/ui_gui.py başlığındaki SÖZLEŞME)." + ) + fleet: list[Operation] = [] + for item in raw: + operation = self._normalize_operation(item) + fleet.append(operation) + self._raw_ops[operation.id] = item + if not fleet: + raise BackendUnavailable("bfos.operations boş bir operasyon listesi döndürdü.") + local = tuple(op for op in OPERATIONS if not op.is_fleet) + return tuple(fleet) + local + + @staticmethod + def _normalize_operation(item) -> Operation: + """Modul operasyonunu arayuzun Operation tipine cevirir (SÖZLEŞME).""" + if isinstance(item, Operation): + return item + get = (lambda key, default=None: item.get(key, default)) if isinstance(item, dict) \ + else (lambda key, default=None: getattr(item, key, default)) + op_id = get("id") or get("ident") or get("name") or get("operation_id") + if not op_id: + raise BackendUnavailable("Bir operasyonun id alanı yok (SÖZLEŞME: id zorunlu).") + label = get("label") or get("title") or get("description") or op_id + category = get("category") or "Cihaz işlemleri" + if category not in CATEGORIES: + category = "Cihaz işlemleri" + scope = get("scope") or get("declared_scope") or "simple" + prompt = get("prompt") or "none" + return Operation( + id=str(op_id), + label=str(label), + category=str(category), + destructive=bool(get("destructive") or get("is_destructive") or False), + scope=str(scope), + description=str(get("description") or ""), + playbook=str(get("playbook") or ""), + options=_prompt_options(str(prompt)), + requires_root=bool(get("requires_root") or False), + needs_dealer=bool(get("needs_dealer") or False), + ) + + # -- envanter ---------------------------------------------------------- + def list_operations(self) -> tuple[Operation, ...]: + return self._catalogue + + def list_waves(self) -> tuple[str, ...]: + try: + present = self._inventory.waves_present() + except Exception: + present = () + return tuple(present) or WAVES + + def inventory_note(self) -> str: + note = self._local.inventory_note() + if self._inventory.path: + note = f"Envanter: {self._inventory.path} ({self._inventory.count_all()} cihaz)" + if not self._have_ansible(): + note += ( + " | UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz " + "(sudo apt install ansible)." + ) + return note + + def _have_ansible(self) -> bool: + checker = getattr(getattr(self._runner, "Runner", None), "have_ansible", None) + try: + if callable(checker): + return bool(checker()) + except Exception: + pass + return shutil.which("ansible-playbook") is not None + + def target_wave(self, operation: Operation, target: Target | None) -> str | None: + if target is None or not target.value: + return None + try: + resolved = self._resolve_target(target, operation) + except CommandError: + return None + return resolved.wave or None + + def validate_target(self, operation: Operation, target: Target) -> str | None: + try: + self._resolve_target(target, operation) + except CommandError as exc: + return str(exc) + return None + + def _resolve_target(self, target: Target, operation: Operation): + """Modulun resolve_target'i ile hedefi cozer (Refused -> CommandError).""" + raw_op = self._raw_ops.get(operation.id) + mode = "any" + if raw_op is not None: + mode = getattr(raw_op, "target_mode", "any") + try: + return self._ops.resolve_target(target.value, mode, self._inventory) + except Exception as exc: + raise self._refusal(exc) from exc + + @staticmethod + def _refusal(exc: Exception) -> CommandError: + """Modulun Refused hatasini (mesaj + ayrinti) Turkce CommandError'a cevirir.""" + message = getattr(exc, "message", None) or str(exc) + detail = getattr(exc, "detail", "") or "" + text = f"{message}\n{detail}".strip() if detail else str(message) + if isinstance(exc, CommandError): + return exc + return CommandError(text) + # -- kapilar ----------------------------------------------------------- + def gate_steps( + self, operation: Operation, target: Target | None + ) -> tuple[GateStepInfo, ...]: + """Modulun confirmation_plan'i ile ayni kapi kurallari. + + confirmation_plan yoksa/çağrılamazsa yerel ayna (CatalogueBackend) + kullanilir: kapi sormamak gibi sessiz bir davranis ASLA secilmez. + """ + if operation.id not in self._fleet_ids or target is None or not target.value: + return self._local.gate_steps(operation, target) + planner = getattr(self._ops, "confirmation_plan", None) + raw_op = self._raw_ops.get(operation.id) + if not callable(planner) or raw_op is None: + return self._local.gate_steps(operation, target) + try: + resolved = self._resolve_target(target, operation) + gate_supported = False + plays = getattr(self._repo, "playbook_uses", None) + if callable(plays): + gate_supported = bool(plays(raw_op.playbook, "wave_gate_confirmed")) + steps = planner( + raw_op, raw_op.declared_scope, resolved, + wave_gate_supported=gate_supported, + ) + except Exception: + return self._local.gate_steps(operation, target) + infos: list[GateStepInfo] = [] + for step in steps: + infos.append( + GateStepInfo( + kind=str(getattr(step, "kind", "approval")), + key=str(getattr(step, "key", "")), + prompt=str(getattr(step, "prompt", "")), + message=str(getattr(step, "message", "")), + token=str(getattr(step, "token", "")), + unlocks=tuple(getattr(step, "unlocks", ()) or ()), + ) + ) + return tuple(infos) + + def _gates(self, grants: Mapping[str, bool]): + """Onaylanan bayraklari modulun GatesGranted tipine cevirir.""" + gates_cls = getattr(self._ops, "GatesGranted", None) + if gates_cls is None: + return None + fields = getattr(gates_cls, "__dataclass_fields__", {}) + kwargs = {name: bool(value) for name, value in grants.items() if name in fields} + try: + return gates_cls(**kwargs) + except Exception: + return None + + # -- komut uretimi ----------------------------------------------------- + def build_command( + self, + operation: Operation, + target: Target | None = None, + options: Mapping[str, str] | None = None, + grants: Mapping[str, bool] | None = None, + check_mode: bool = False, + ) -> CommandPlan: + opts = dict(options or {}) + granted = dict(grants or {}) + if operation.id in self._fleet_ids: + return self._build_fleet_plan(operation, target, opts, granted, check_mode) + for builder in (self._build_install_plan, self._build_tool_plan, + self._build_report_plan): + plan = builder(operation, target, opts, check_mode) + if plan is not None: + return plan + # Modulde karsiligi olmayan yerel komut/salt-okunur ekranlar. + return self._local.build_command(operation, target, opts, granted, check_mode) + + def _build_fleet_plan( + self, + operation: Operation, + target: Target | None, + opts: Mapping[str, str], + grants: Mapping[str, bool], + check_mode: bool, + ) -> CommandPlan: + raw_op = self._raw_ops.get(operation.id) + if raw_op is None: + raise CommandError( + f"'{operation.id}' operasyonunun tanımı backend'de bulunamadı." + ) + if target is None or not target.value: + raise CommandError( + "Hedef seçilmedi; komut üretilmedi. Bayi numarası veya dalga grubu seçin." + ) + if not self._inventory.path: + raise CommandError( + "Ansible envanteri bulunamadı; filo işlemleri komutu üretilemiyor. " + "Beklenen yol: ansible/inventory/hosts.yml (veya --inventory PATH)." + ) + verify = getattr(self._repo, "verify_scope", None) + if callable(verify): + # Playbook kendi kapisinin kaynagidir; tablo ile uyusmazsa REDDET. + try: + verify(raw_op) + except Exception as exc: + raise self._refusal(exc) from exc + try: + resolved = self._resolve_target(target, operation) + extra = self._ops.build_extra_vars(raw_op.prompt, opts) + argv = self._ops.build_ansible_command( + self._repo, self._inventory.path, raw_op, raw_op.declared_scope, + resolved, extra_vars=extra, gates=self._gates(grants), + check_mode=check_mode, + ) + except Exception as exc: + raise self._refusal(exc) from exc + + notes: list[str] = [] + if raw_op.declared_scope in ("wave", "release"): + notes.append( + "Dalga/sürüm kapısı: yalnız önceki dalga sağlık kapısını ve merkez " + "onayını geçtiyse çalıştırın (docs/10)." + ) + if not grants.get("wave_gate_confirmed") and _declares( + self._repo, raw_op.playbook, "wave_gate_confirmed" + ): + notes.append( + "Not: wave_gate_confirmed bayrağı yalnız sürüm kapısı onayı verilirse " + "komuta eklenir." + ) + if check_mode: + notes.append("--check: playbook hiçbir değişiklik uygulamaz (deneme koşusu).") + return CommandPlan( + commands=(tuple(argv),), + destructive=bool(raw_op.destructive), + requires_root=False, + operation_id=operation.id, + target_label=str(getattr(resolved, "label", target.label)), + notes=tuple(notes), + dry_run=self.ctx.dry_run, + check_mode=check_mode, + ) + + def _build_install_plan( + self, operation: Operation, target: Target | None, + opts: Mapping[str, str], check_mode: bool, + ) -> CommandPlan | None: + if not operation.id.startswith("install-"): + return None + builder = getattr(self._ops, "build_install_command", None) + if not callable(builder): + return None + if operation.id in ("install-state", "install-log"): + return None + dealer = "" + if operation.needs_dealer: + if target is None or target.kind != "dealer" or not target.value: + raise CommandError( + f"'{operation.label}' için bayi numarası gerekir " + "(hedef alanına 8 haneli bayi no yazın)." + ) + problem = dealer_error(target.value) + if problem: + raise CommandError(problem) + dealer = target.value + try: + installer = self._repo.installer_path() + except Exception as exc: + raise self._refusal(exc) from exc + check = operation.id == "install-preflight" or check_mode + try: + argv = builder( + installer, + dealer, + offline=opts.get("offline") == "1", + resume=operation.id == "install-resume" or opts.get("resume") == "1", + only=(opts.get("only") or "").strip(), + check_mode=check, + ) + except Exception as exc: + raise self._refusal(exc) from exc + notes = [f"Hedef cihaz kimliği: {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)})."] + if operation.id == "install-preflight": + notes.append("Ön kontrol hiçbir şey yazmaz (log/state/dizin oluşturmaz).") + if check: + notes.append("--check: hiçbir değişiklik uygulanmaz.") + return CommandPlan( + commands=(tuple(argv),), + destructive=operation.destructive, + requires_root=operation.requires_root, + operation_id=operation.id, + target_label=f"cihaz {dealer_to_device_id(dealer)} ({dealer_to_host(dealer)})", + notes=tuple(notes), + dry_run=self.ctx.dry_run, + check_mode=check, + ) + + def _build_tool_plan( + self, operation: Operation, target: Target | None, + opts: Mapping[str, str], check_mode: bool, + ) -> CommandPlan | None: + entry = self._TOOL_OPS.get(operation.id) + if entry is None: + return None + builder = getattr(self._ops, "build_tool_command", None) + tool, args = entry + try: + path = self._repo.tool_path(tool) + except Exception as exc: + raise self._refusal(exc) from exc + if not callable(builder): + return None + check = tool.startswith("bf-check-") or check_mode + argv = builder(path, list(args), check_mode=check) + notes: list[str] = [] + if operation.id == "tools-support-bundle": + notes.append("Destek paketi parola, private key veya token içermez.") + return CommandPlan( + commands=(tuple(argv),), + destructive=operation.destructive, + requires_root=operation.requires_root, + operation_id=operation.id, + target_label="bu cihaz (yerel)", + notes=tuple(notes), + dry_run=self.ctx.dry_run, + check_mode=check, + ) + + def _build_report_plan( + self, operation: Operation, target: Target | None, + opts: Mapping[str, str], check_mode: bool, + ) -> CommandPlan | None: + """Modulun salt-okunur ekran ureticileri (komut CALISTIRILMAZ).""" + lines = self._module_report_lines(operation) + if lines is None: + return None + return CommandPlan( + report_lines=tuple(lines), + destructive=False, + operation_id=operation.id, + target_label="bu cihaz (yerel)", + notes=("Salt-okunur ekran: hiçbir komut çalıştırılmadı.",), + dry_run=self.ctx.dry_run, + ) + + def _module_report_lines(self, operation: Operation) -> tuple[str, ...] | None: + ops = self._ops + try: + if operation.id == "central-targets" and hasattr(ops, "targets_lines"): + return tuple(ops.targets_lines(self._inventory)) + if operation.id == "central-placeholders" and hasattr( + ops, "central_readiness_lines" + ): + return tuple(ops.central_readiness_lines(self._repo, self._inventory)) + if operation.id == "central-endpoints" and hasattr(ops, "central_endpoint_lines"): + return tuple(ops.central_endpoint_lines(self._repo)) + if operation.id == "central-checklist" and hasattr(ops, "central_checklist_lines"): + return tuple(ops.central_checklist_lines()) + if operation.id == "info-identity" and hasattr(ops, "identity_lines"): + return tuple(ops.identity_lines()) + if operation.id == "info-state" and hasattr(ops, "state_lines"): + return tuple(ops.state_lines()) + if operation.id == "info-summary" and hasattr(ops, "environment_lines"): + lines = list( + ops.environment_lines( + self._repo, str(self.ctx.console_log), self._inventory, + self.ctx.dry_run, False, + ) + ) + if hasattr(ops, "state_lines"): + lines += [""] + lines += list(ops.state_lines()) + return tuple(lines) + except Exception: + # Rapor uretilemedi: yerel (katalog) yoluna dusulur, hata gizlenmez. + return None + return None + # -- calistirma -------------------------------------------------------- + def run( + self, + plan: CommandPlan, + on_line: Callable[[str], None] | None = None, + cancel: threading.Event | None = None, + ) -> RunResult: + """Komutlari canli ciktiyla calistirir; denetim kaydi modul uzerinden. + + Not: bfos.runner.Runner komutu kendi stdio'suyla calistirir (TUI icin + dogru olan davranis budur) ve ciktiyi dondurmez; GUI metin kutusuna + satir satir akis icin buranin streaming calistiricisi kullanilir. + Denetim kaydi, modulun AuditLog'u varsa onun uzerinden yazilir, boylece + GUI ile TUI ayni bicimde kayit tutar. + """ + audit = self._audit_log() + redact = getattr(self._runner, "redact", None) + command_text = " && ".join(shlex.join(argv) for argv in plan.commands) + if callable(redact): + try: + command_text = str(redact(command_text)) + except Exception: + pass + if audit is not None: + self._audit_write(audit, self._audit_level("AUDIT_RUN", "RUN"), command_text) + result = run_plan(plan, on_line=on_line, cancel=cancel, cwd=str(self.ctx.repo_root)) + if audit is not None: + level = self._audit_level("AUDIT_OK", "OK") if result.ok \ + else self._audit_level("AUDIT_FAIL", "FAIL") + self._audit_write(audit, level, f"exit={result.exit_code} :: {command_text}") + return result + + def _audit_log(self): + """bfos.runner.AuditLog (varsa) -- kurulamazsa None (kayit sessizce atlanir).""" + factory = getattr(self._runner, "AuditLog", None) + if factory is None: + return None + try: + return factory() + except Exception: + return None + + @staticmethod + def _audit_write(audit, level: str, message: str) -> None: + try: + audit.write(level, message) + except Exception: + pass + + def _audit_level(self, name: str, fallback: str) -> str: + config = getattr(self._ops, "config", None) + return str(getattr(config, name, fallback) or fallback) + + +def _declares(repo, playbook: str, var: str) -> bool: + """Playbook VAR'i kullaniyor mu? (modulun okuma yolu varsa onu kullanir.)""" + checker = getattr(repo, "playbook_uses", None) + if not callable(checker): + return False + try: + return bool(checker(playbook, var)) + except Exception: + return False + + +#: Modulun prompt turleri (bfos.operations PROMPT_*) icin arayuz secenekleri. +#: Anahtarlar modulun build_extra_vars() cevap anahtarlariyla AYNI olmalidir. +_PROMPT_OPTION_SPECS: Mapping[str, tuple[OptionSpec, ...]] = { + "none": (), + "packages": ( + OptionSpec( + "packages", + "Paket pinleri (paket=sürüm, virgülle)", + required=True, + hint="Yalnız tam pin; 'latest' ve aralık kabul edilmez", + validator=validate_package_pins, + ), + ), + "service": ( + OptionSpec( + "service", + "Servis adı", + kind="choice", + choices=SERVICE_ALLOWLIST, + required=True, + hint="Yalnız izinli servis adları", + validator=validate_service_name, + ), + ), + "script": ( + OptionSpec( + "script", + "Script yolu", + required=True, + hint=f"İzinli dizinler: {', '.join(SCRIPT_DIRS)}", + validator=validate_script_path, + ), + OptionSpec("script_args", "Script argümanları", default="", hint="İsteğe bağlı"), + ), + "grace": ( + OptionSpec( + "grace", + "Yeniden başlatma bekleme süresi (saniye)", + kind="int", + default="300", + validator=validate_grace, + ), + ), + "disk_pct": ( + OptionSpec( + "disk_pct", + "Uyarı eşiği (%)", + kind="int", + default="", + hint="Boş = playbook varsayılanı (80)", + validator=validate_disk_pct, + ), + ), + "log_lines": ( + OptionSpec( + "log_lines", + "Cihaz başına journal satırı", + kind="int", + default="500", + validator=validate_log_lines, + ), + ), + "expected_version": ( + OptionSpec( + "expected_version", + "Beklenen Field OS sürümü", + default="", + hint="Boş = sapma (drift) denetimi atlanır", + validator=validate_expected_version, + ), + ), +} + + +def _prompt_options(prompt: str) -> tuple[OptionSpec, ...]: + return _PROMPT_OPTION_SPECS.get(prompt, ()) + + +def load_backend(ctx: GuiContext): + """Gercek modulleri dener; yoksa yerlesik katalog'a duser. + + Donus: (backend, not) -- `not` Turkce bilgi satiridir (durum cubugunda + gosterilir), hata DEGILDIR. + """ + add_repo_to_path(ctx.repo_root) + try: + from bfos import operations as operations_module # type: ignore + except Exception as exc: # ImportError ve beklenmeyen hatalar + return ( + CatalogueBackend(ctx), + f"bfos.operations bulunamadı ({type(exc).__name__}); yerleşik katalog " + "kullanılıyor (admin/bf-menu kuralları).", + ) + runner_module = None + try: + from bfos import runner as runner_module # type: ignore # noqa: F811 + except Exception: + runner_module = None + if runner_module is None: + return ( + CatalogueBackend(ctx), + "bfos.runner bulunamadı; komutlar yerleşik çalıştırıcıyla koşuluyor.", + ) + try: + backend = ModuleBackend(operations_module, runner_module, ctx) + except BackendUnavailable as exc: + return CatalogueBackend(ctx), f"UYARI: {exc}" + except Exception as exc: # beklenmeyen modul hatasi arayuzu kilitlemesin + return ( + CatalogueBackend(ctx), + f"UYARI: gerçek backend başlatılamadı ({type(exc).__name__}: {exc}); " + "yerleşik katalog kullanılıyor.", + ) + catalogue = len(backend.list_operations()) + fleet = len(backend._fleet_ids) # noqa: SLF001 - bilgi amacli sayim + return ( + backend, + f"Gerçek backend: bfos.operations + bfos.runner " + f"({fleet} filo işlemi, {catalogue - fleet} yerel işlem).", + ) + + +def add_repo_to_path(repo_root: Path) -> None: + root = str(repo_root) + if root not in sys.path: + sys.path.insert(0, root) + + +# --------------------------------------------------------------------------- +# Cikti paneli + canli akis +# --------------------------------------------------------------------------- +class OutputPane: + """Kaydirilabilir, canli guncellenen cikti alani (tkinter sarmalayicisi).""" + + def __init__(self, parent, height: int = 16): + self.frame = ttk.LabelFrame(parent, text="Komut çıktısı") + self.frame.pack(fill="both", expand=True, padx=8, pady=(4, 8)) + self.text = scrolledtext.ScrolledText( + self.frame, height=height, wrap="word", font=("monospace", 10) + ) + self.text.pack(fill="both", expand=True, padx=6, pady=6) + self.text.configure(state="disabled") + self._queue: "queue.Queue[str | None]" = queue.Queue() + self._parent = parent + + def append(self, line: str) -> None: + """Herhangi bir is parcacigindan guvenle cagrilabilir (kuyruga yazar).""" + self._queue.put(line) + + def drain(self) -> None: + """Ana is parcaciginda kuyrugu bosaltir (after ile periyodik cagrilir).""" + wrote = False + while True: + try: + line = self._queue.get_nowait() + except queue.Empty: + break + self.text.configure(state="normal") + self.text.insert("end", line + "\n") + self.text.configure(state="disabled") + wrote = True + if wrote: + self.text.see("end") + self._parent.after(120, self.drain) + + def clear(self) -> None: + self.text.configure(state="normal") + self.text.delete("1.0", "end") + self.text.configure(state="disabled") + + def write_now(self, line: str) -> None: + self.append(line) + self.drain_once() + + def drain_once(self) -> None: + while True: + try: + line = self._queue.get_nowait() + except queue.Empty: + break + self.text.configure(state="normal") + self.text.insert("end", line + "\n") + self.text.configure(state="disabled") + self.text.see("end") + + +# --------------------------------------------------------------------------- +# Arayuz +# --------------------------------------------------------------------------- +class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) + """Blueforce Field OS Turkce masaustu arayuzu.""" + + def __init__(self, root, backend, ctx: GuiContext, *, full_mode: bool = False): + self.root = root + self.backend = backend + self.ctx = ctx + self.cancel_event = threading.Event() + self.running = False + self.option_widgets: dict[str, tuple[str, Any]] = {} + self._panes: dict[str, tuple[Any, Any]] = {} + # Islem tanimlari BACKEND'den gelir (yerlesik katalog ya da gercek + # bfos.operations): secenek adlari backend ile ayni olmak zorundadir. + self._ops_by_id: dict[str, Operation] = { + op.id: op for op in backend.list_operations() + } + self._selected_operation: Operation | None = None + self._current_plan: CommandPlan | None = None + + root.title(WINDOW_TITLE) + root.minsize(980, 720) + ttk.Style().configure(".", font=("Sans", 10)) + + self._build_header() + self._build_mode_bar() + self.simple_frame = ttk.Frame(root) + self.detail_frame = ttk.Frame(root) + self._build_simple_mode(self.simple_frame) + self._build_detail_mode(self.detail_frame) + self.output = OutputPane(root, height=18) + self.output.drain() + self._build_footer() + + self.show_mode(full=full_mode) + self._load_inventory() + + # -- ust bilgi --------------------------------------------------------- + def _build_header(self) -> None: + header = ttk.Frame(self.root) + header.pack(fill="x", padx=8, pady=(8, 2)) + ttk.Label(header, text=WINDOW_TITLE, font=("Sans", 15, "bold")).pack(anchor="w") + ttk.Label(header, text=APP_SUBTITLE, foreground="#444").pack(anchor="w") + ttk.Label( + header, + text=f"Depo: {self.ctx.repo_root}", + foreground="#666", + ).pack(anchor="w") + self.backend_note = ttk.Label(header, text=self.backend.name, foreground="#666") + self.backend_note.pack(anchor="w") + if self.ctx.dry_run: + ttk.Label( + header, + text="DENEME KİPİ (--dry-run): komutlar gösterilir, çalıştırılmaz.", + foreground="#a05000", + ).pack(anchor="w") + + def _build_mode_bar(self) -> None: + bar = ttk.Frame(self.root) + bar.pack(fill="x", padx=8, pady=(6, 0)) + self.mode_button = ttk.Button(bar, text=SIMPLE_MODE_LABEL, command=self.toggle_mode) + self.mode_button.pack(side="left") + ttk.Label(bar, text="Hedef:").pack(side="left", padx=(16, 4)) + self.dealer_var = tk.StringVar() + self.dealer_entry = ttk.Entry(bar, textvariable=self.dealer_var, width=12) + self.dealer_entry.pack(side="left") + self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) + ttk.Label(bar, text="Bayi no (8 hane)").pack(side="left", padx=(4, 12)) + self.wave_var = tk.StringVar() + self.wave_combo = ttk.Combobox( + bar, textvariable=self.wave_var, values=list(WAVES), width=14, state="readonly" + ) + self.wave_combo.pack(side="left") + ttk.Label(bar, text="Dalga").pack(side="left", padx=(4, 12)) + self.all_var = tk.IntVar(value=0) + ttk.Checkbutton( + bar, text="Tüm filo (dikkat)", variable=self.all_var, + command=self._on_target_change, + ).pack(side="left") + self.target_status = ttk.Label(bar, text="", foreground="#a00000") + self.target_status.pack(side="left", padx=10) + + def _build_footer(self) -> None: + footer = ttk.Frame(self.root) + footer.pack(fill="x", padx=8, pady=(0, 8)) + self.run_button = ttk.Button( + footer, text="Komutu göster ve çalıştır", command=self._on_run_clicked + ) + self.run_button.pack(side="left") + ttk.Button(footer, text="Çıktıyı temizle", command=self._clear_output).pack( + side="left", padx=6 + ) + self.cancel_button = ttk.Button( + footer, text="Çalışanı iptal et", command=self._cancel, state="disabled" + ) + self.cancel_button.pack(side="left", padx=6) + self.check_var = tk.IntVar(value=0) + ttk.Checkbutton( + footer, text="Yalnız denetim (--check)", variable=self.check_var + ).pack(side="left", padx=6) + ttk.Button(footer, text="Çıkış", command=self.root.destroy).pack(side="right") + self.status = ttk.Label(footer, text="Hazır.") + self.status.pack(side="right", padx=12) + + # -- basit mod --------------------------------------------------------- + def _build_simple_mode(self, parent) -> None: + box = ttk.Frame(parent) + box.pack(expand=True, fill="both", padx=24, pady=24) + ttk.Label( + box, + text="Ne yapmak istiyorsunuz?", + font=("Sans", 13, "bold"), + ).pack(anchor="w", pady=(0, 12)) + for label in SIMPLE_MODE_BUTTONS: + command = { + "Kur": self._simple_install, + "Durum": self._simple_status, + "Çıkış": self.root.destroy, + }[label] + button = ttk.Button(box, text=label, command=command) + button.configure(width=18) + button.pack(fill="x", pady=8, ipady=10) + ttk.Label( + box, + text=( + "Kur: bu cihazı kurar (bayi no gerekir). Durum: salt-okunur özet.\n" + "Daha fazlası için üstteki '" + SIMPLE_MODE_LABEL + "' düğmesini kullanın." + ), + foreground="#555", + justify="left", + ).pack(anchor="w", pady=(12, 0)) + + def _simple_install(self) -> None: + choice = self._choice_dialog( + "Kurulum", + "Bu cihazda ne yapılacak?\n\n" + "Ön kontrol (--check) hiçbir şey yazmaz; kurulum cihazı değiştirir.", + (("Ön kontrol (--check)", "install-preflight"), + ("Kurulumu başlat", "install-local"), + ("Vazgeç", None)), + ) + if not choice: + return + operation = self._ops_by_id.get(choice) + if operation is not None: + self._execute(operation) + + def _simple_status(self) -> None: + operation = self._ops_by_id.get("info-summary") + if operation is None: + return + self._execute(operation) + + # -- detayli mod ------------------------------------------------------- + def _build_detail_mode(self, parent) -> None: + self.notebook = ttk.Notebook(parent) + self.notebook.pack(fill="both", expand=True, padx=8, pady=8) + for category in CATEGORIES: + frame = ttk.Frame(self.notebook) + self.notebook.add(frame, text=category) + self._build_category_tab(frame, category) + + def _build_category_tab(self, frame, category: str) -> None: + left = ttk.Frame(frame) + left.pack(side="left", fill="both", expand=True, padx=6, pady=6) + columns = ("islem", "kapsam", "tur") + tree = ttk.Treeview(left, columns=columns, show="headings", height=14) + tree.heading("islem", text="İşlem") + tree.heading("kapsam", text="Kapsam") + tree.heading("tur", text="Tür") + tree.column("islem", width=360) + tree.column("kapsam", width=200) + tree.column("tur", width=110, anchor="center") + scroll = ttk.Scrollbar(left, orient="vertical", command=tree.yview) + tree.configure(yscrollcommand=scroll.set) + tree.pack(side="left", fill="both", expand=True) + scroll.pack(side="left", fill="y") + tree.bind( + "<>", + lambda _e, t=tree, c=category: self._on_operation_selected(t, c), + ) + setattr(self, f"_tree_{category}", tree) + for op in operations_by_category(category): + tree.insert( + "", + "end", + iid=op.id, + values=( + op.label, + SCOPE_LABELS.get(op.scope, op.scope), + "YIKICI" if op.destructive else "salt-okunur", + ), + tags=("destructive",) if op.destructive else (), + ) + tree.tag_configure("destructive", foreground="#a00000") + + right = ttk.LabelFrame(frame, text="Seçenekler ve açıklama") + right.pack(side="left", fill="both", expand=True, padx=6, pady=6) + detail_label = ttk.Label(right, text="Bir işlem seçin.", wraplength=380, + justify="left") + detail_label.pack(anchor="w", padx=8, pady=8) + options_frame = ttk.Frame(right) + options_frame.pack(fill="x", padx=8, pady=4) + # Sekme basina ayri secenek alani: her sekme kendi islemini gosterir. + self._panes = getattr(self, "_panes", {}) + self._panes[category] = (detail_label, options_frame) + ttk.Label( + right, + text="Komut her zaman önce gösterilir ve onaysız çalıştırılmaz.", + foreground="#555", + wraplength=380, + justify="left", + ).pack(anchor="w", padx=8, pady=(8, 4)) + + def _on_operation_selected(self, tree, category: str) -> None: + selection = tree.selection() + if not selection: + return + operation = self._ops_by_id.get(selection[0]) + if operation is None: + return + self._selected_operation = operation + self._render_options(operation, category) + + def _render_options(self, operation: Operation, category: str) -> None: + detail_label, options_frame = self._panes[category] + for child in options_frame.winfo_children(): + child.destroy() + self.option_widgets = {} + text = operation.label + "\n\n" + (operation.description or "Açıklama yok.") + if operation.playbook: + text += f"\n\nAnsible playbook: {PLAYBOOK_DIR_REL}/{operation.playbook}" + text += f"\nKapsam: {SCOPE_LABELS.get(operation.scope, operation.scope)}" + if operation.destructive: + text += "\n\nBU İŞLEM YIKICIDIR: iki kez onay istenir." + detail_label.configure(text=text) + for spec in operation.options: + row = ttk.Frame(options_frame) + row.pack(fill="x", pady=3) + ttk.Label(row, text=spec.label + ":", width=32, anchor="w").pack(side="left") + if spec.kind == "bool": + var = tk.BooleanVar(value=spec.default == "1") + ttk.Checkbutton(row, variable=var).pack(side="left") + self.option_widgets[spec.name] = ("bool", var) + widget = None + elif spec.kind == "choice": + var = tk.StringVar(value=spec.default or (spec.choices[0] if spec.choices else "")) + widget = ttk.Combobox(row, textvariable=var, values=list(spec.choices), + width=24, state="readonly") + self.option_widgets[spec.name] = ("str", var) + else: + var = tk.StringVar(value=spec.default) + widget = ttk.Entry(row, textvariable=var, width=26) + self.option_widgets[spec.name] = ("str", var) + if widget is not None: + widget.pack(side="left") + if spec.hint: + ttk.Label(row, text=f" {spec.hint}", foreground="#666").pack(side="left") + + # -- hedef ------------------------------------------------------------- + def _load_inventory(self) -> None: + waves = self.backend.list_waves() + self.wave_combo.configure(values=list(waves)) + if waves: + self.wave_var.set(waves[0]) + note = self.backend.inventory_note() + self.output.write_now(f"[i] {note}") + self.status.configure(text="Hazır.") + + def _on_dealer_change(self) -> None: + value = self.dealer_var.get().strip() + problem = dealer_error(value) if value else None + self.target_status.configure(text=problem or "") + if not problem: + self.status.configure(text=f"Bayi no geçerli: {dealer_to_device_id(value)}") + + def _on_target_change(self) -> None: + if self.all_var.get(): + self.target_status.configure( + text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler." + ) + else: + self._on_dealer_change() + + def current_target(self) -> Target | None: + if self.all_var.get(): + return Target("all", "all") + dealer = self.dealer_var.get().strip() + if dealer: + return Target("dealer", dealer) + wave = self.wave_var.get().strip() + if wave: + return Target("group", wave) + return None + + # -- mod degistirme ---------------------------------------------------- + def show_mode(self, *, full: bool) -> None: + self.simple_frame.pack_forget() + self.detail_frame.pack_forget() + if full: + self.detail_frame.pack(fill="both", expand=True) + self.mode_button.configure(text=SIMPLE_MODE_BACK_LABEL) + else: + self.simple_frame.pack(fill="both", expand=True) + self.mode_button.configure(text=SIMPLE_MODE_LABEL) + self.full_mode = full + + def toggle_mode(self) -> None: + self.show_mode(full=not self.full_mode) + + # -- calistirma akisi -------------------------------------------------- + def _on_run_clicked(self) -> None: + if self._selected_operation is None: + messagebox.showinfo( + "Seçim gerekli", + "Önce listeden bir işlem seçin, sonra 'Komutu göster ve çalıştır' düğmesine basın.", + ) + return + self._execute(self._selected_operation) + + def _collect_options(self, operation: Operation) -> dict[str, str]: + options: dict[str, str] = {} + for spec in operation.options: + entry = self.option_widgets.get(spec.name) + if not entry: + continue + kind, variable = entry + if kind == "bool": + options[spec.name] = "1" if variable.get() else "0" + else: + options[spec.name] = str(variable.get()).strip() + return options + + def _execute(self, operation: Operation) -> None: + if self.running: + messagebox.showinfo("Bekleyin", "Zaten çalışan bir işlem var.") + return + target = self.current_target() if operation.is_fleet or operation.needs_dealer else None + if operation.needs_dealer and (target is None or target.kind != "dealer"): + messagebox.showerror( + "Bayi numarası gerekli", + "Bu işlem için bayi numarası girin (8 hane, örn. 12010193).", + ) + return + if target is not None and target.kind == "dealer": + problem = dealer_error(target.value) + if problem: + messagebox.showerror("Geçersiz bayi numarası", problem) + return + options = self._collect_options(operation) + check_mode = bool(self.check_var.get()) + try: + draft = self.backend.build_command( + operation, target, options, check_mode=check_mode + ) + except CommandError as exc: + messagebox.showerror("Komut üretilemedi", str(exc)) + return + except BackendUnavailable as exc: + messagebox.showerror("Backend hatası", str(exc)) + return + + # Salt-okunur rapor (komut yok): dogrudan cikti alanina yazilir. + if draft.is_report_only: + self._current_plan = draft + self._show_plan(draft, title=f"Salt-okunur ekran: {operation.label}") + self._start_run(draft) + return + + # 1) Taslak komut GORSTERILIR ve onaylanir: onaysiz hicbir sey calismaz. + if not self._confirm_plan(draft, operation, target, title="Komutu onaylayın"): + self.output.write_now("[i] Kullanıcı vazgeçti; komut çalıştırılmadı.") + return + + # 2) Acik kapilar: dalga adi, production, filo geneli, yikici ikinci onay. + grants = self._collect_grants(operation, target) + if grants is None: + self.output.write_now("[i] Kapı onayı verilmedi; işlem iptal edildi.") + return + + # 3) Kapilar eklendikten sonraki KESIN komut yeniden gosterilir. + try: + plan = self.backend.build_command( + operation, target, options, grants=grants, check_mode=check_mode + ) + except CommandError as exc: + messagebox.showerror("Komut üretilemedi", str(exc)) + return + if plan.lines != draft.lines: + if not self._confirm_plan( + plan, operation, target, + title="KESİN KOMUT (onayların eklendiği son hâl)", + ): + self.output.write_now("[i] Kesin komut onaylanmadı; işlem iptal edildi.") + return + + self._current_plan = plan + self._show_plan(plan, title=f"Çalıştırılıyor: {operation.label}") + self._start_run(plan) + + def _confirm_plan( + self, plan: CommandPlan, operation: Operation, + target: Target | None, title: str, + ) -> bool: + """Komutu gosteren onay penceresi (yikici islemde uyari vurgusu).""" + preview = preview_text(plan, operation=operation, target=target) + return bool( + messagebox.askyesno( + title, + preview + "\n\nBu komut çalıştırılsın mı?", + icon="warning" if plan.destructive else "question", + default="no" if plan.destructive else "yes", + ) + ) + + def _collect_grants( + self, operation: Operation, target: Target | None + ) -> dict[str, bool] | None: + """Backend'in istedigi acik onaylari sorar; iptalde None doner.""" + try: + steps = self.backend.gate_steps(operation, target) + except CommandError as exc: + messagebox.showerror("Kapı onayları alınamadı", str(exc)) + return None + grants: dict[str, bool] = {} + for step in steps: + if step.kind == "token": + answer = self._token_dialog(step.prompt or "Onay", step.message, step.token) + if answer is None or answer.strip() != step.token: + return None + else: + if not messagebox.askyesno( + step.prompt or "Onay", + step.message or "Devam edilsin mi?", + icon="warning", + default="no", + ): + return None + for name in step.unlocks: + grants[name] = True + return grants + + def _show_plan(self, plan: CommandPlan, title: str) -> None: + """Komutu/raporu cikti alanina yazar (kayit ve gozden gecirme icin).""" + self.output.write_now("") + self.output.write_now("=" * 72) + self.output.write_now(f" {title}") + if plan.report_lines and not plan.lines: + for line in plan.report_lines: + self.output.write_now(" " + line) + else: + for line in plan.lines: + self.output.write_now(" " + line) + self.output.write_now("=" * 72) + + def _start_run(self, plan: CommandPlan) -> None: + self.running = True + self.cancel_event.clear() + self.run_button.configure(state="disabled") + self.cancel_button.configure(state="normal") + self.status.configure(text="Çalışıyor… (iptal için 'Çalışanı iptal et')") + + def worker() -> None: + try: + result = self.backend.run( + plan, on_line=self.output.append, cancel=self.cancel_event + ) + except Exception as exc: # backend hatasi arayuzu kilitlemesin + self.output.append(f"[HATA] Çalıştırma başarısız: {exc}") + result = RunResult(1, False, "") + self.root.after(0, lambda: self._finish_run(result)) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_run(self, result: RunResult) -> None: + self.running = False + self.run_button.configure(state="normal") + self.cancel_button.configure(state="disabled") + if result.ok: + self.output.append("[OK] Komut başarıyla tamamlandı (çıkış kodu 0).") + self.status.configure(text="Tamamlandı (çıkış kodu 0).") + else: + self.output.append(f"[HATA] Komut {result.exit_code} koduyla bitti.") + self.status.configure(text=f"Hata: çıkış kodu {result.exit_code}") + + def _cancel(self) -> None: + self.cancel_event.set() + self.status.configure(text="İptal istendi…") + + def _clear_output(self) -> None: + self.output.clear() + + # -- kucuk diyaloglar -------------------------------------------------- + def _choice_dialog(self, title: str, message: str, + choices: Sequence[tuple[str, str | None]]) -> str | None: + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.transient(self.root) + dialog.grab_set() + result: dict[str, str | None] = {"value": None} + + ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( + padx=16, pady=16, anchor="w" + ) + + def choose(value: str | None) -> None: + result["value"] = value + dialog.destroy() + + row = ttk.Frame(dialog) + row.pack(fill="x", padx=16, pady=(0, 16)) + for label, value in choices: + ttk.Button(row, text=label, command=lambda v=value: choose(v)).pack( + side="left", padx=4 + ) + dialog.wait_window() + return result["value"] + + def _token_dialog(self, title: str, message: str, token: str) -> str | None: + dialog = tk.Toplevel(self.root) + dialog.title(title) + dialog.transient(self.root) + dialog.grab_set() + result: dict[str, str | None] = {"value": None} + ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( + padx=16, pady=16, anchor="w" + ) + var = tk.StringVar() + entry = ttk.Entry(dialog, textvariable=var, width=32) + entry.pack(padx=16, anchor="w") + entry.focus_set() + + def confirm() -> None: + result["value"] = var.get().strip() + dialog.destroy() + + def cancel() -> None: + result["value"] = None + dialog.destroy() + + row = ttk.Frame(dialog) + row.pack(fill="x", padx=16, pady=16) + ttk.Button(row, text="Onayla", command=confirm).pack(side="left", padx=4) + ttk.Button(row, text="Vazgeç", command=cancel).pack(side="left", padx=4) + dialog.bind("", lambda _e: confirm()) + dialog.wait_window() + return result["value"] + + +# --------------------------------------------------------------------------- +# Katalog yazdirma (pencere ACMADAN dogrulama icin) +# --------------------------------------------------------------------------- +def print_catalogue(backend, stream=None) -> None: + out = stream or sys.stdout + operations = backend.list_operations() + print(f"Blueforce Field OS — işlem kataloğu ({len(operations)} işlem)", file=out) + for category in CATEGORIES: + items = [op for op in operations if op.category == category] + print(f"\n== {category} ({len(items)} işlem) ==", file=out) + for op in items: + kind = "YIKICI" if op.destructive else "salt-okunur" + playbook = op.playbook or "-" + print( + f" {op.id:<24} {op.label:<46} kapsam={op.scope:<8} {kind:<11} playbook={playbook}", + file=out, + ) + + +# --------------------------------------------------------------------------- +# Baslatma +# --------------------------------------------------------------------------- +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="python3 -m bfos.ui_gui", + description="Blueforce Field OS masaüstü arayüzü (Türkçe, tkinter).", + ) + parser.add_argument("--full", action="store_true", + help="Doğrudan detaylı modda başlat (kategori sekmeleri).") + parser.add_argument("--repo", default=None, help="Depo kökü (varsayılan: bfos'un üst dizini).") + parser.add_argument("--inventory", default=None, help="Ansible envanteri (varsayılan: otomatik).") + parser.add_argument("--dry-run", action="store_true", + help="Komutları göster, hiçbir şey çalıştırma.") + parser.add_argument("--list", action="store_true", + help="Pencere açmadan işlem kataloğunu yazdır ve çık.") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + ctx = GuiContext.detect(repo=args.repo, inventory=args.inventory, dry_run=args.dry_run) + backend, note = load_backend(ctx) + + if args.list: + print_catalogue(backend) + print(f"\n[i] {note}") + return 0 + + if not HAVE_TKINTER: + print(tkinter_missing_message(TK_IMPORT_ERROR), file=sys.stderr) + return 3 + problem = headless_error() + if problem: + print(problem, file=sys.stderr) + return 3 + + root = tk.Tk() + try: + BfGui(root, backend, ctx, full_mode=args.full) + except tk.TclError as exc: # DISPLAY var ama erisilemiyor + print(window_start_error(exc), file=sys.stderr) + return 3 + root.mainloop() + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/provisioning/offline-repo/README.md b/provisioning/offline-repo/README.md index 8f16b52..ed4a059 100644 --- a/provisioning/offline-repo/README.md +++ b/provisioning/offline-repo/README.md @@ -1,73 +1,367 @@ -# Offline APT repository +# Offline APT deposu (paketler git ile taşınır) -Reviewed `.deb` files are not stored in this repository. Supply them locally on the LAB build machine; the tooling never fetches a package, never resolves a dependency from a remote source, and never signs anything. +Saha cihazı internetsiz kuruluyor. Bu dizin, kurulumda kullanılacak Ubuntu +paketlerini ve bunların APT indeksini **bu git deposunun içinde** taşır. -## Current state (read this first) +İki kural: -- **This repository contains no packages and no built repository.** `packages/` holds only `.gitkeep`, and there is no `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv` or `SHA256SUMS` anywhere in the tree. -- `manifests/*.txt` is the **package contract**. Every line is `package=version`. The package names are final and complete; the version field still reads the sentinel `UNPINNED` because no package has been acquired or reviewed yet. -- The versions must be filled on the LAB build machine from the reviewed `.deb` files. Until then everything fails closed: `--check` and a real build refuse a pin whose version is still `UNPINNED`, and the offline installer refuses a bundle whose lock does not satisfy the manifests. -- Nothing here was built on a workstation: `apt-ftparchive` and `dpkg-deb` exist only on the Ubuntu 26.04 build host, so no bundle was produced and no `.deb` was downloaded. `--dry-run-plan` is the only mode that runs anywhere. +1. **Paketleri repo sorumlusu toplar** ve bu dizine commit eder (Ubuntu makinesi gerekir). +2. **Kuran kişi hiçbir şey indirmez.** Cihazdaki kurucu yalnızca git'ten gelen + yerel depoyu okur; internet, apt kaynağı, anahtar indirme, `apt download` + gibi hiçbir yol kullanılmaz. -## Contract files +## Roller -| File | Role | -|---|---| -| `manifests/base-packages.txt` | Base system pins: tooling, SSH, WireGuard, SMART, firewall, node exporter (installer modules 02, 06, 07, 12, 13) | -| `manifests/remote-access.txt` | Remote access and desktop pins: xRDP, GNOME session, minimal desktop, RustDesk (modules 08, 09, 16) | -| `manifests/docker.txt` | Docker CE pins (module 10) | -| `manifests/requirements.tsv` | Audit table: which manifest must pin which package, and which installer module or diagnostic needs it | -| `manifests/packages.lock.schema.tsv` | Documented layout of the `packages.lock.tsv` SBOM that a build writes into the bundle | +| Rol | Kimdir | Ne yapar | +|---|---|---| +| Repo sorumlusu | bu depoyu yöneten kişi | Ubuntu 26.04 makinesinde paketleri toplar, doğrular, indeksi üretir, commit eder | +| Kuran kişi | sahada cihazı kuran teknisyen | ISO/seed ile kurar, `blueforce-install.sh --offline` çalıştırır; paket aramaz, indirmez | -`scripts/install/blueforce-install.sh --offline` reads **every** `manifests/*.txt` as a pin manifest: an empty or comment-only manifest, an invalid pin, or a pin that is missing from the bundle stops the installer before any package module runs. Adding a `*.txt` file to `manifests/` is therefore a change to that contract, not a local convenience — `manifests/requirements.tsv` must be updated in the same edit. +## Dosya haritası -## Modes +| Dosya / dizin | Ne işe yarar | Kim çalıştırır | +|---|---|---| +| `packages/` | Doğrulanmış `.deb` dosyaları (kaynak havuz) | repo sorumlusu (`add-packages.sh` ile doldurur) | +| `add-packages.sh` | `.deb` doğrular, `packages/` altına ekler, Türkçe rapor verir | repo sorumlusu (Ubuntu) | +| `build-index.sh` | `packages/`tan offline indeksi üretir (`built/`), `repo-manifest.yaml` yazar | repo sorumlusu (Ubuntu) | +| `built/` | Git'e giren hazır depo: `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS` | üretilir | +| `repo-manifest.yaml` | Deponun künyesi: sürüm, mimari, paket listesi, boyut, tarih | `build-index.sh` üretir | +| `build-offline-repo.sh` | Sözleşme doğrulayıcı (`--dry-run-plan`) ve alternatif derleyici | repo sorumlusu / CI | +| `manifests/*.txt` | Pin listesi: `paket=sürüm`. Cihazda **ne olması gerektiği** | repo sorumlusu doldurur | +| `manifests/requirements.tsv` | Hangi paket neden gerekli (denetim tablosu) | insan okur | +| `manifests/packages.lock.schema.tsv` | `packages.lock.tsv` biçiminin tanımı | insan okur | +| `packages/.gitkeep` | Boş dizinin git'te durması için yer tutucu | — | + +--- + +# 1. Repo sorumlusu: sıfırdan adım adım + +## 1.0 Gereksinimler + +* Ubuntu 26.04 LTS makinesi, mimari **amd64**, internet erişimi var (paketleri + toplamak için). Docker CE ve RustDesk paketleri Ubuntu arşivinde değildir; + aşağıda ayrı anlatılıyor. +* Şu iki araç kurulu olmalı: + + ```bash + sudo apt-get install -y dpkg apt-utils + dpkg-deb --version # okunur: .deb künyesini okur + apt-ftparchive --version # okunur: APT indeksini üretir + ``` + + CachyOS/Arch gibi bir makinede `apt-ftparchive` ve `dpkg-deb` yoktur; orada + paket toplanamaz ve indeks üretilemez. Toplama işi Ubuntu makinesinde yapılır, + sonuç bu depoya commit edilir. + +## 1.1 Paketleri indir + +Çalışma dizini açın: ```bash -# Static contract validation: no apt-ftparchive, no dpkg-deb, no pool, no network. -./build-offline-repo.sh --dry-run-plan - -# LAB only: verify that the supplied pool satisfies every pin. -./build-offline-repo.sh --packages /secure/reviewed-debs --output /tmp/blueforce-apt --check - -# LAB only: build the bundle. -./build-offline-repo.sh --packages /secure/reviewed-debs --output /srv/blueforce-apt +mkdir -p ~/bf-debs && cd ~/bf-debs ``` -`--dry-run-plan` validates the pin format, duplicate and whitespace errors, placeholder leakage, the audit table, the SBOM schema, and the presence of every required package, then prints the missing versions and missing packages as explicit lists. It exits non-zero while anything is unfilled, and it needs no build tooling, so it runs on any machine. It never writes to the manifests. +Hangi sürümün "aday" olduğunu görün (pini bu sürümle dolduracaksınız): -`--check` and a real build require `apt-ftparchive` and `dpkg-deb`; without them the tooling refuses and names the missing tool. A real build also refuses to overwrite an existing output directory. +```bash +apt-cache policy openssh-server | head -n 3 +``` -## LAB build machine requirements +Paketleri **ve bağımlılıklarını** indirin (`--download-only` hiçbir şey kurmaz): -1. Ubuntu 26.04 LTS build host, the release named in every manifest, `amd64`. -2. `apt-ftparchive` (package `apt-utils`) and `dpkg-deb` (package `dpkg`) installed. -3. A connected acquisition host to obtain and review the `.deb` files for that release, including the packages that are **not** in the Ubuntu archive: `rustdesk` (vendor build) and the Docker CE set (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`). -4. A review step that records exactly what was accepted: package, version, architecture, SHA256 and source file name, in the layout of `manifests/packages.lock.schema.tsv`. +```bash +sudo apt-get install --download-only --reinstall -y \ + curl wget ca-certificates gnupg lsb-release apt-transport-https \ + openssh-server wireguard-tools smartmontools ufw iptables \ + prometheus-node-exporter xrdp xorgxrdp gnome-session ubuntu-desktop-minimal -### Fill procedure +cp /var/cache/apt/archives/*.deb ~/bf-debs/ +rm -f ~/bf-debs/*partial* +``` -1. Put the reviewed `.deb` files in one directory, for example `/secure/reviewed-debs`. -2. For each manifest line, read the exact version with `dpkg-deb -f .deb Package Version` and replace `UNPINNED` with it. One pin per package across all manifests. -3. Run `./build-offline-repo.sh --dry-run-plan` until it prints `PLAN READY` (`missing_packages=0` and `errors=0`). -4. Run `--check` against the reviewed pool, then build into a fresh output directory. -5. Archive the bundle, the ISO manifest and the reviewed pool together, since the bundle is the only proof of what a device installed. +`ubuntu-desktop-minimal` çok sayıda bağımlılık getirir; bu normaldir ve offline +kurulum için gereklidir. -## What a build produces +**Docker CE** (Ubuntu arşivinde yok, Docker deposundan gelir): -`/pool/*.deb`, `Packages`, `Packages.gz`, `packages.lock.tsv` (package, version, architecture, sha256, source) and `SHA256SUMS` covering the indexes and the lock file. +```bash +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list +sudo apt-get update +sudo apt-get install --download-only --reinstall -y \ + docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +cp /var/cache/apt/archives/*.deb ~/bf-debs/ +``` -## Air-gapped target +`containerd.io` Docker'ın kendi çalışma zamanıdır; Ubuntu'nun `containerd` +paketi ile karıştırmayın. -- The bundle is copied to the target device as `/opt/blueforce/offline-repo`, the default of `BF_OFFLINE_REPO`. -- `scripts/install/blueforce-install.sh --offline` requires that directory to exist with `Packages`, `Packages.gz`, `packages.lock.tsv` and `SHA256SUMS`, runs `sha256sum -c SHA256SUMS`, verifies the lock layout, and checks every manifest pin against a lock row and a pool file. -- Only then does it write `/offline.list` containing exactly `deb [trusted=yes] file:/opt/blueforce/offline-repo ./` and export an isolated APT configuration that points at that file. No remote source, key download, `curl`, `wget` or `apt download` is used. -- An offline installation ends in `PROVISIONED_OFFLINE`. The device is not `READY` until enrollment and central channel verification have succeeded. +**RustDesk** (Ubuntu arşivinde yok, üretici `.deb` dosyası): -## LAB checklist +* Projenin sürüm sayfasından amd64 paketini indirin: + `https://github.com/rustdesk/rustdesk/releases` → `rustdesk--x86_64.deb` +* Dosyayı `~/bf-debs/` içine kopyalayın. Kullanılacak sürüm, incelenmiş + (self-hosted sunucuya bağlanan) istemci sürümü olmalıdır. -- [ ] Every `manifests/*.txt` pin carries a real version from a reviewed `.deb`; `--dry-run-plan` prints `PLAN READY`. -- [ ] `manifests/requirements.tsv` still matches the manifests (the plan fails on drift in either direction). -- [ ] `--check` passes against the reviewed pool and the built bundle verifies with `sha256sum -c SHA256SUMS`. -- [ ] The reviewed pool and the bundle are archived together with the ISO release manifest. -- [ ] `tests/check-offline-repo-static.sh` passes in CI. +## 1.2 Paketleri depoya ekle + +```bash +cd provisioning/offline-repo + +# Ön izleme: hiçbir dosya kopyalanmaz, ne olacağını söyler +./add-packages.sh --from ~/bf-debs --check + +# Gerçek ekleme (pinlenen + bağımlılıklar) +./add-packages.sh --from ~/bf-debs +``` + +Yalnız manifestte pinlenen paketleri eklemek isterseniz: + +```bash +./add-packages.sh --from ~/bf-debs --manifest manifests/base-packages.txt +# bağımlılıkları da eklemek için sonuna --include-all ekleyin +``` + +Araç her dosyayı denetler ve Türkçe yazar: + +* `EKLENDİ:` → eklendi, +* `ATLANDI: aynı dosya zaten repoda` → tekrar eklemeye çalıştınız (zararsız), +* `HATA: mimari uyumsuz` → paket `amd64`/`all` değil; o dosyayı kullanmayın, +* `HATA: çakışma: repoda ile duruyor` → aynı paketin ikinci bir + sürümü. Manifestler tek sürüm pinler, bu yüzden otomatik üzerine yazılmaz; + ekranda yazan `rm` komutu ile eski dosyayı kaldırıp yeniden ekleyin, +* `ATLANDI: manifestte istenen sürüm farklı` → manifestteki pin başka bir sürümü + istiyor; pini güncelleyin ya da doğru sürümün `.deb` dosyasını indirin. + +Sonda `MANIFEST GÜNCELLEMESİ GEREKİYOR` listesi çıkar: bu araç manifest +dosyalarını **değiştirmez**, yalnızca yazmanız gereken satırı söyler. + +## 1.3 Manifest pinlerini doldur + +`manifests/base-packages.txt`, `remote-access.txt`, `docker.txt` içindeki her +satır şu biçimdedir: + +```text +openssh-server=UNPINNED +``` + +`UNPINNED` bir yer tutucudur, sürüm değildir. `add-packages.sh` çıktısındaki +listeyi kullanarak gerçek sürümü yazın: + +```text +openssh-server=9.6p1-3ubuntu13 +``` + +Kurallar: küçük harfli paket adı, `=` çevresinde boşluk yok, satır sonuna not +yazılmaz, bir paket yalnızca bir manifestte pinlenir. Yeni paket ekler/çıkarırsanız +`manifests/requirements.tsv` tablosunu **aynı işlemde** güncelleyin. + +Kontrol: + +```bash +./build-offline-repo.sh --dry-run-plan # PLAN READY görmelisiniz +``` + +`PLAN READY` görünene kadar derleme yapmayın. Bir sürümden emin değilseniz +elinizdeki dosyadan okuyun: + +```bash +dpkg-deb -f ~/bf-debs/openssh-server_*.deb Package Version Architecture +``` + +## 1.4 İndeksi üret + +```bash +./build-index.sh --check # yalnızca doğrular, hiçbir şey yazmaz +./build-index.sh # built/ altına indeksi yazar +``` + +Çıktı: + +```text +built/pool/*.deb paketlerin kopyası +built/Packages APT indeksi (apt-ftparchive) +built/Packages.gz sıkıştırılmış indeks (zaman damgasız) +built/packages.lock.tsv SBOM: package/version/architecture/sha256/source +built/SHA256SUMS indeks dosyalarının özetleri +repo-manifest.yaml deponun künyesi (güncellenir) +``` + +Araç fail-closed çalışır: + +* manifestteki bir pin repoda yoksa **hiçbir şey yazmaz** ve eksikleri liste + hâlinde söyler (`manifestlerde istenen şu paketler eksik:`), +* bir pin hâlâ `UNPINNED` ise yine yazmaz; önce manifesti doldurun, +* aynı havuzla tekrar çalıştırıldığında indeks birebir aynı çıkar; içerik + değişmediyse `DEĞİŞİKLİK YOK` yazar ve dizini yeniden yazmaz. + +## 1.5 Git'e ekle ve commit et + +```bash +# Paketler ve manifestler: +git add provisioning/offline-repo/packages provisioning/offline-repo/manifests + +# İndeks: .gitignore, Packages / Packages.gz / packages.lock.tsv adlarını her +# dizinde yok sayar (eski LAB akışından kalma kural), bu yüzden -f gerekir: +git add -f provisioning/offline-repo/built + +git add provisioning/offline-repo/repo-manifest.yaml +git commit -m "offline repo: paket, offline APT indeksi" +``` + +`build-index.sh` çalıştığınızda bu komutları ekrana da yazar (kopyala-yapıştır). +`.gitignore` dosyasının sahibi isterse bu üç ad için daha sonra istisna kuralı +ekleyebilir; o zaman `-f` gereksizleşir. + +## 1.6 Bitirmeden önce kontrol + +```bash +bash tests/check-offline-repo-static.sh +./build-offline-repo.sh --dry-run-plan +cd built && sha256sum -c SHA256SUMS # OK görmelisiniz +``` + +--- + +# 2. Kuran kişi neden hiç indirme yapmaz + +* Saha cihazında internet yok (ya da güvenilmez). Bu yüzden kurulumun ihtiyaç + duyduğu her şey **medyanın ve git deposunun içinde** taşınır. +* Kurucu `--offline` ile çalıştığında tek kaynak şudur: + `/opt/blueforce/offline-repo` dizinine konan yerel depo + (`deb [trusted=yes] file:/opt/blueforce/offline-repo ./`). Uzak APT kaynağı, + anahtar indirme, `curl`, `wget`, `apt download` kullanılmaz. +* Bu dizin (git deposundaki `built/`) ISO ile hedefe taşınır ve kurucu şu + dosyaları arar ve doğrular: `Packages`, `Packages.gz`, `packages.lock.tsv`, + `SHA256SUMS` ve `pool/` içinde manifestlerdeki pinlere karşılık gelen `.deb` + dosyaları. +* Kurucu kurulum sonunda cihazı `PROVISIONED_OFFLINE` durumuna getirir. Cihaz, + bir kerelik enrollment ve merkezi kanal doğrulaması yapılmadan `READY` olmaz. +* Sonuç: kuran kişinin yaptığı tek şey `git`/ISO'dan gelen dosyaları yerine + koymak ve kurucuyu çalıştırmaktır. Hangi sürümün kurulduğu + `packages.lock.tsv` ve `repo-manifest.yaml` ile kanıtlanır. + +Kuran kişinin komutu: + +```bash +sudo ./scripts/install/blueforce-install.sh --offline --dealer-id <8 haneli numara> +``` + +--- + +# 3. Eksik paket olursa ne olur? (`OFFLINE BLOCKED`) + +Cihazdaki kurucu, paket modüllerinden **önce** yerel depoyu doğrular. Bir şey +eksik ya da bozuksa kurulum durur ve ekrana `OFFLINE BLOCKED` ile başlayan bir +satır yazar. `OFFLINE BLOCKED` demek: "cihazda kurulum yapılmadı, depo +tamamlanmadı, sorumluya haber verin". + +| Ekranda ne yazar (kısaltılmış) | Ne anlama gelir | Sorumlu tarafında çözüm | +|---|---|---| +| `BF_OFFLINE_REPO must be an existing absolute local directory` | Depo dizini hiç yok: medya ya da kopyalama eksik | `built/` içeriğini `/opt/blueforce/offline-repo` altına koyun | +| `local APT index, lock, or checksums are missing; ISO bundle is incomplete` | `Packages`, `Packages.gz`, `packages.lock.tsv` veya `SHA256SUMS` yok. Genelde indeks üretilmeden commit edilmiştir | `./build-index.sh` çalıştırıp `built/` dizinini commit edin | +| `local APT repository checksum validation failed` | Dosyalar elle değişmiş ya da kopyalama bozulmuş (`sha256sum -c SHA256SUMS` hatası) | Depoyu git'ten yeniden alın; indeksi yeniden üretin | +| `package lock is empty or invalid` | `packages.lock.tsv` başlığı/kolonları bozuk ya da dosya boş | `./build-index.sh` ile yeniden üretin, elle düzenlemeyin | +| `pinned package absent from bundle: ` | Manifestteki sürüm depoda yok: pin ile paket eşleşmiyor | `add-packages.sh` ile doğru sürümü ekleyip pini güncelleyin | + +Yani "eksik paket" sahada değil, **commit öncesinde** çözülür: +`./build-index.sh` eksikleri listeler ve yazmadan durur. Sahada `OFFLINE BLOCKED` +görürseniz ISO, bilgi eksik bir depoyla üretilmiş demektir; çözüm depoyu düzeltip +yeni ISO üretmektir. + +--- + +# 4. Hangi paketler gerekli ve neden? + +Liste `manifests/requirements.tsv` dosyasının okunabilir hâlidir; kaynak +doğrudur. Her satır bir `paket=sürüm` pinidir ve kurucu bu pinlerin **tamamını** +depoda arar; biri eksikse `OFFLINE BLOCKED` ile durur. + +## Temel araçlar — `manifests/base-packages.txt` + +| Paket | Neden gerekli | Nerede kullanılır | +|---|---|---| +| `curl` | HTTPS aktarımı yapan temel araç | `02-system.sh` | +| `wget` | Saha runbook'larının beklediği dosya çekme aracı | `02-system.sh` | +| `ca-certificates` | TLS doğrulaması için kök sertifika deposu | `02-system.sh` | +| `gnupg` | İncelenmiş dosyaların imza işlemleri | `02-system.sh` | +| `lsb-release` | Dağıtım sürümünü tanıma | `02-system.sh` | +| `apt-transport-https` | apt 2.0+ üzerinde geçiş (dummy) paket; referans derlemeyle uyum için tutuluyor | `02-system.sh` | +| `openssh-server` | Yönetilen SSH erişim kanalı | `07-ssh.sh` | +| `wireguard-tools` | `wg` komutu (VPN tüneli) | `06-wireguard.sh` | +| `smartmontools` | Disk sağlığı kanıtı (SMART) | `scripts/diagnostics/bf-diagnostics` | +| `ufw` | Güvenlik duvarı temeli | `13-firewall.sh` | +| `iptables` | Güvenlik duvarı kural arka ucu | `13-firewall.sh` | +| `prometheus-node-exporter` | Cihaz metrikleri (textfile collector paketin içinde) | `12-monitoring.sh` | + +## Uzak erişim ve masaüstü — `manifests/remote-access.txt` + +| Paket | Neden gerekli | Nerede kullanılır | +|---|---|---| +| `xrdp` | RDP erişim kanalı | `08-rdp.sh` | +| `xorgxrdp` | xRDP için X11 arka ucu (bağımlılık kapatması LAB'da doğrulanır) | `08-rdp.sh` | +| `gnome-session` | xRDP oturumunda başlatılan masaüstü oturumu | `08-rdp.sh` | +| `ubuntu-desktop-minimal` | En küçük masaüstü yığını | `16-gui.sh` | +| `rustdesk` | Uzak masaüstü istemcisi. **Ubuntu arşivinde yoktur**: üreticinin incelenmiş `.deb` dosyası | `09-rustdesk.sh` | + +## Docker CE — `manifests/docker.txt` + +| Paket | Neden gerekli | Nerede kullanılır | +|---|---|---| +| `docker-ce` | Konteyner motoru | `10-docker.sh` | +| `docker-ce-cli` | `docker` komutu | `10-docker.sh` | +| `containerd.io` | Docker CE ile gelen çalışma zamanı (Ubuntu'nun `containerd` paketi değil) | `10-docker.sh` | +| `docker-buildx-plugin` | buildx eklentisi | `10-docker.sh` | +| `docker-compose-plugin` | Compose v2 eklentisi | `10-docker.sh` | + +Notlar: + +* Pinler sürüm doldurulmadan `UNPINNED` görünür ve bu hâlde derleme yapılmaz. +* Bu 22 paketin dışındaki paketler (bağımlılıklar) da depoda bulunur; ancak + yalnız **pinlenen 22 paket** sözleşmedir. +* Çekirdek modülleri gibi `.deb` olarak taşınmayan dosyalar bu deponun kapsamı + dışındadır. + +--- + +# 5. `repo-manifest.yaml` ve `built/` nedir? + +* `repo-manifest.yaml` = deponun künyesi (kimlik kartı): hedef Ubuntu sürümü, + mimari, kaç paket, toplam boyut, üretim tarihi ve her paketin adı/sürümü/ + sha256'sı. ISO release manifesti ve denetim kaydı ile birlikte saklanır. +* `built/` = cihaza gidecek depo. Hedef cihazda `/opt/blueforce/offline-repo` + olur; içinde `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv`, + `SHA256SUMS` bulunur. Kurucu bu dosyaları `sha256sum -c SHA256SUMS` ile ve + manifestteki her pini `pool/` içindeki bir dosyayla eşleştirerek doğrular. + +--- + +# 6. Sık karşılaşılan durumlar + +| Belirti | Neden / çözüm | +|---|---| +| `apt-ftparchive bulunamadı` | Ubuntu makinesinde kurun: `sudo apt-get install -y apt-utils` | +| `dpkg-deb bulunamadı` | `sudo apt-get install -y dpkg` (Arch/CachyOS'ta yoktur; orada derleme yapılamaz) | +| `git add` indeks dosyalarını almıyor | `.gitignore` bu adları yok sayıyor; `git add -f provisioning/offline-repo/built` kullanın | +| `HATA: çakışma` | Aynı paketin ikinci sürümü eklenmeye çalışıldı. Manifestler tek sürüm pinler; ekrandaki `rm` ile eskiyi kaldırın | +| `mimari uyumsuz` | Yalnız `amd64` ve `all` kabul edilir; `arm64`/`i386` paketi eklemeyin | +| `manifestlerde istenen şu paketler eksik` | Paketi indirip `add-packages.sh` ile ekleyin; pin hâlâ `UNPINNED` ise sürümü yazın | +| ISO betiği depoyu bulamıyor | `provisioning/iso/build-blueforce-iso.sh` şu an `provisioning/offline-repo/build` adını arıyor; `built/` kullanacaksanız `./build-index.sh --output build` ile de üretin ya da ISO betiğinin otomatik bulma yolunu güncelleyin | +| Depo boyutu beklenenden büyük | `packages/` ve `built/pool/` aynı `.deb` dosyalarını iki kez tutar (git içeriği bir kez saklar, çalışma kopyasında iki dosya durur). Kasıtlıdır: biri kaynak, diğeri teslim depo | + +--- + +# 7. Commit öncesi kontrol listesi + +- [ ] `./add-packages.sh --from --check` temiz geçti. +- [ ] `manifests/*.txt` içinde `UNPINNED` kalmadı. +- [ ] `./build-offline-repo.sh --dry-run-plan` → `PLAN READY`. +- [ ] `./build-index.sh` eksiksiz geçti, eksik paket listesi boş. +- [ ] `cd built && sha256sum -c SHA256SUMS` → `OK`. +- [ ] `repo-manifest.yaml` güncel (paket sayısı ve tarih yazılmış). +- [ ] `bash tests/check-offline-repo-static.sh` geçti. +- [ ] Commit: paketler, manifestler, `built/` (`-f` ile) ve `repo-manifest.yaml`. diff --git a/provisioning/offline-repo/add-packages.sh b/provisioning/offline-repo/add-packages.sh new file mode 100755 index 0000000..cfcf99f --- /dev/null +++ b/provisioning/offline-repo/add-packages.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# Offline APT repository intake. +# +# Validate reviewed Debian packages and copy them into +# provisioning/offline-repo/packages/ so that they can be committed to git. The +# repository owner runs this on an Ubuntu machine; the device that later +# installs from the bundle never downloads anything. +# +# Guarantees: +# * nothing is downloaded and nothing is resolved from a remote source, +# * a file that is not a readable Debian binary package is refused, +# * a package for a foreign architecture is refused (the target is amd64, +# packages marked "all" are accepted), +# * a second version of a package that is already accepted is refused instead +# of silently overwritten: manifests/*.txt pin exactly one version, +# * manifests/*.txt is never edited here. For every pin that still reads the +# UNPINNED sentinel the exact replacement line is printed for the owner. +# +# --check performs the same validation without copying anything. +# +# User-facing output is Turkish; code and comments are English. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGES_DIR="$SCRIPT_DIR/packages" +TARGET_ARCH='amd64' +ALLOWED_ARCH_RE='^(amd64|all)$' +PACKAGE_NAME_RE='^[a-z0-9][a-z0-9+.-]*$' +PIN_RE='^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$' +UNPINNED='UNPINNED' + +usage() { + cat <<'EOF' +Kullanım: add-packages.sh [seçenekler] [ ...] + add-packages.sh --from DIZIN [--manifest DOSYA] + +İncelenmiş .deb dosyalarını provisioning/offline-repo/packages/ altına ekler. +Hiçbir indirme yapmaz: dosyaları siz verirsiniz, bu araç yalnızca doğrular. + +Seçenekler: + --from DIZIN DIZIN içindeki tüm *.deb dosyalarını aday olarak alır + --manifest DOSYA Yalnızca bu manifestteki pinlere uyan adayları ekler + (birden fazla --manifest verilebilir) + --include-all --manifest ile birlikte: pin dışı adayları da ekler + (bağımlılık paketleri için gerekir) + --packages DIZIN Hedef dizin (varsayılan: