Blueforce-Linux/bfos/ui_gui.py
emirhanmamak-blueforce e28783e563 feat(offline-repo): build the APT index so installs work without network
The package pool was committed but the installer still stopped at "no local
package repository": it requires the compiled bundle (Packages, Packages.gz,
packages.lock.tsv, SHA256SUMS), and building that had never run.

The bundle
- 1321 packages, 884 MiB, Ubuntu 26.04 amd64, built with apt-ftparchive on the
  fleet's own release so the closure matches the devices.
- built/pool/ is gitignored: those .deb files are already tracked once under
  provisioning/offline-repo/packages/, and the same ~890 MB does not need a
  second path. What the installer reads from the bundle is the index.
- The output directory is chmod 0755: mktemp -d created it 0700 and left it
  so, which made a valid bundle unreadable to the installer's probe.

dpkg-deb output was parsed wrong in four places
- `dpkg-deb -f <file> Package Version` prints "<Field>: <value>", and the
  callers kept the whole line, so the value carried its label. build-index.sh
  therefore accepted no package at all and reported an empty pool;
  add-packages.sh rejected every real .deb as an invalid name, missed version
  conflicts, and copied a duplicate; build-offline-repo.sh never matched a pin.
- The test suite could not see any of this: its dpkg-deb stub emitted bare
  values. The stub now prints the labels exactly as the real tool does, which
  is what surfaced the remaining three sites.

Tests
- The secret scan covers the files we author, not apt-ftparchive's index over
  third-party packages: polkit's own description reads "...asked for their
  password:", which is prose in the packages, not a leak. Any secret reaching a
  manifest, README or tool is still caught.
- The "test build must not touch the bundle" assertion now compares the
  directory before and after instead of demanding it be empty, so a
  maintainer's real build is not reported as test spill.
2026-09-16 21:42:27 +03:00

3111 lines
126 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.

"""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"
# Grafik arayüz açılamadığında kullanılacak yol: aynı bfos uygulamasının
# terminal konsolu. Bash menü (bf-menu) hâlâ yedek olarak durur ama Python
# konsolu ana yoldur, o yüzden önce o önerilir.
TERMINAL_MENU_HINT = "sudo bf --tui"
# ---------------------------------------------------------------------------
# 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, ...] = ()
# None means "not stated": the value is then derived from `destructive` by
# the property below, so a write operation cannot be marked read-only by
# omission. An operation that genuinely differs states the value.
requires_root: bool | None = None
needs_dealer: bool = False
@property
def needs_root(self) -> bool:
"""Whether this operation must run as root to be allowed."""
if self.requires_root is None:
return bool(self.destructive)
return bool(self.requires_root)
@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 | None = None
operation_id: str = ""
target_label: str = ""
notes: tuple[str, ...] = ()
report_lines: tuple[str, ...] = ()
dry_run: bool = False
check_mode: bool = False
@property
def needs_root(self) -> bool:
"""Whether running this plan requires root (derived when not stated)."""
if self.requires_root is None:
return bool(self.destructive)
return bool(self.requires_root)
@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.needs_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-<bayi-no> 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")
if plan.needs_root and not write_operations_enabled():
# Last line of defence: every execution path funnels through this
# function, so the root rule holds even if a future caller builds the
# plan itself instead of going through the GUI's run button.
emit("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT)
return RunResult(2, False, NON_ROOT_DISABLED_HINT + "\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.needs_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.needs_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-<bayi-no> 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-<no> (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 = str(get("scope") or get("declared_scope") or "simple")
prompt = get("prompt") or "none"
destructive = bool(get("destructive") or get("is_destructive") or False)
# requires_root is left None unless the source actually states it, so the
# Operation property derives it from `destructive`. Reading the field
# with a False default marked every real operation read-only and the
# root gate never fired; the catalogue's `destructive` flag is the
# honest signal. An operation that states the value keeps that answer.
stated_root = None
if isinstance(item, dict):
if "requires_root" in item:
stated_root = bool(item["requires_root"])
elif getattr(item, "requires_root", None) is not None:
stated_root = bool(item.requires_root)
return Operation(
id=str(op_id),
label=str(label),
category=str(category),
destructive=destructive,
scope=scope,
description=str(get("description") or ""),
playbook=str(get("playbook") or ""),
options=_prompt_options(str(prompt)),
requires_root=stated_root,
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.needs_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.needs_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")
if not is_root():
# Söylenmezse operatör kurulumu başlatır ve yarı yolda yetki
# hatasıyla karşılaşır; kapı bu yüzden başlangıçta görünür.
ttk.Label(
header,
text=ROOT_REQUIRED_NOTICE,
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("<KeyRelease>", 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(
"<<TreeviewSelect>>",
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:
if plan.needs_root and not write_operations_enabled():
# The button is disabled for this case, so reaching here means the
# gate was bypassed (stale widget state, a future caller). Refusing
# in the one place every run goes through keeps the rule true for
# the whole surface instead of one control.
self.output.write_now("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT)
self.status.configure(text="Yönetici yetkisi gerekli.")
return
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("<Return>", 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,
)
# ---------------------------------------------------------------------------
# Yetki kapisi
# ---------------------------------------------------------------------------
def is_root() -> bool:
"""True when the process can install, write state and drive Ansible.
The installer writes to the filesystem, manages services and applies the
firewall baseline, so the GUI is expected to run under sudo. Reports read
the repository and the inventory and work without it, which is why the
console still starts as a normal user and explains the difference instead
of refusing to launch.
"""
geteuid = getattr(os, "geteuid", None)
return True if geteuid is None else geteuid() == 0
ROOT_REQUIRED_NOTICE = (
"Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik "
"gerektiren işlemler kapalıdır. Tam yetki için: sudo bf"
)
NON_ROOT_DISABLED_HINT = "Bu işlem yönetici yetkisi gerektirir (sudo bf)."
def write_operations_enabled() -> bool:
"""Whether operations that change the device may be offered."""
return is_root()
# ---------------------------------------------------------------------------
# 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())