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 0000000..2995015 Binary files /dev/null and b/bfos/__pycache__/__init__.cpython-311.pyc differ diff --git a/bfos/__pycache__/cli.cpython-311.pyc b/bfos/__pycache__/cli.cpython-311.pyc new file mode 100644 index 0000000..1185665 Binary files /dev/null and b/bfos/__pycache__/cli.cpython-311.pyc differ diff --git a/bfos/__pycache__/config.cpython-311.pyc b/bfos/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..03c8406 Binary files /dev/null and b/bfos/__pycache__/config.cpython-311.pyc differ diff --git a/bfos/__pycache__/operations.cpython-311.pyc b/bfos/__pycache__/operations.cpython-311.pyc new file mode 100644 index 0000000..584a339 Binary files /dev/null and b/bfos/__pycache__/operations.cpython-311.pyc differ diff --git a/bfos/__pycache__/runner.cpython-311.pyc b/bfos/__pycache__/runner.cpython-311.pyc new file mode 100644 index 0000000..028ad85 Binary files /dev/null and b/bfos/__pycache__/runner.cpython-311.pyc differ diff --git a/bfos/__pycache__/tui.cpython-311.pyc b/bfos/__pycache__/tui.cpython-311.pyc new file mode 100644 index 0000000..d20537d Binary files /dev/null and b/bfos/__pycache__/tui.cpython-311.pyc differ diff --git a/bfos/__pycache__/ui_gui.cpython-311.pyc b/bfos/__pycache__/ui_gui.cpython-311.pyc new file mode 100644 index 0000000..8d0c760 Binary files /dev/null and b/bfos/__pycache__/ui_gui.cpython-311.pyc differ 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: