Blueforce-Linux/bfos/tui.py
emirhanmamak-blueforce 1387186b03 feat(bfos): Python operator console, Turkish UI, committable offline packages
Switches the operator path from bash-only to a Python console, keeps the bash
menu as a fallback, and makes the offline package set travel with the repository
so the person installing a device never downloads anything.

bfos/ (stdlib only, no textual/rich: those would need a download, which defeats
the offline goal):
- simple mode offers three choices (Kur / Durum / Çıkış); the detailed mode with
  all 21 fleet operations and the local installer is reachable from the menu or
  with --full, so a first-time operator is not buried in options
- gate parity with the bash console: scope read from the playbook file and
  refused on mismatch, production needs a typed token plus a separate approval,
  release-gated runs need their wave name typed, destructive runs need a second
  approval, "all" walks wave by wave and stops at the first failure
- Turkish throughout; audit log at /var/log/blueforce-console.log (600, falls
  back to the user state dir) with a single redaction choke point
- bfos/ui_gui.py adds an optional tkinter window over the same operations layer;
  headless and missing-tkinter cases explain themselves in Turkish

admin/bf-bootstrap.sh: links the Python console as `bf` (primary) and keeps
`bf-menu` (fallback), reports both, and recommends `sudo bf`.

Offline packages are committable now (this was the blocking contradiction):
- .gitignore ignored `Packages`, `Packages.gz` and `packages.lock.tsv` everywhere,
  so a built bundle could never be committed and the installer would always have
  been told to download. provisioning/offline-repo/built/ is now allowed through
  while scratch output stays untracked
- provisioning/offline-repo/{add-packages.sh,build-index.sh,repo-manifest.yaml}
  plus a Turkish step-by-step README for the repository owner
- the over-broad `*token*` ignore rule is narrowed to `*[-_.]token*` so ordinary
  filenames are not silently excluded while token files still are

Tests: run-all.sh now runs the Python suites too (previously it collected only
*.sh, so 212 Python assertions were silently skipped) and executes from the repo
root. 11/11 pass, exit 0.

Two test expectations were corrected rather than the code: a read-only
wave-scoped report legitimately asks for no gate token (the bash console never
did either), so that assertion now checks the command carries operation_wave and
operation_confirmed, and a new test asserts release-gated runs still require
their token. The bootstrap completion contract moved from `sudo bf-menu` to
`sudo bf`.
2026-09-16 16:15:27 +03:00

499 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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