feat(bfos): Python operator console, Turkish UI, committable offline packages
Switches the operator path from bash-only to a Python console, keeps the bash
menu as a fallback, and makes the offline package set travel with the repository
so the person installing a device never downloads anything.
bfos/ (stdlib only, no textual/rich: those would need a download, which defeats
the offline goal):
- simple mode offers three choices (Kur / Durum / Çıkış); the detailed mode with
all 21 fleet operations and the local installer is reachable from the menu or
with --full, so a first-time operator is not buried in options
- gate parity with the bash console: scope read from the playbook file and
refused on mismatch, production needs a typed token plus a separate approval,
release-gated runs need their wave name typed, destructive runs need a second
approval, "all" walks wave by wave and stops at the first failure
- Turkish throughout; audit log at /var/log/blueforce-console.log (600, falls
back to the user state dir) with a single redaction choke point
- bfos/ui_gui.py adds an optional tkinter window over the same operations layer;
headless and missing-tkinter cases explain themselves in Turkish
admin/bf-bootstrap.sh: links the Python console as `bf` (primary) and keeps
`bf-menu` (fallback), reports both, and recommends `sudo bf`.
Offline packages are committable now (this was the blocking contradiction):
- .gitignore ignored `Packages`, `Packages.gz` and `packages.lock.tsv` everywhere,
so a built bundle could never be committed and the installer would always have
been told to download. provisioning/offline-repo/built/ is now allowed through
while scratch output stays untracked
- provisioning/offline-repo/{add-packages.sh,build-index.sh,repo-manifest.yaml}
plus a Turkish step-by-step README for the repository owner
- the over-broad `*token*` ignore rule is narrowed to `*[-_.]token*` so ordinary
filenames are not silently excluded while token files still are
Tests: run-all.sh now runs the Python suites too (previously it collected only
*.sh, so 212 Python assertions were silently skipped) and executes from the repo
root. 11/11 pass, exit 0.
Two test expectations were corrected rather than the code: a read-only
wave-scoped report legitimately asks for no gate token (the bash console never
did either), so that assertion now checks the command carries operation_wave and
operation_confirmed, and a new test asserts release-gated runs still require
their token. The bootstrap completion contract moved from `sudo bf-menu` to
`sudo bf`.
This commit is contained in:
parent
f8cdd1ed01
commit
1387186b03
30 changed files with 10671 additions and 106 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -15,12 +15,21 @@ dist/
|
||||||
*.iso.sha256
|
*.iso.sha256
|
||||||
*.iso.boot-report.txt
|
*.iso.boot-report.txt
|
||||||
|
|
||||||
# Offline APT repository output (generated by provisioning/offline-repo)
|
# Offline APT repository.
|
||||||
|
#
|
||||||
|
# IMPORTANT: the built bundle IS committed on purpose. The whole point of this
|
||||||
|
# repository is that an operator installing a field device never downloads
|
||||||
|
# anything: the packages travel with the repo. `provisioning/offline-repo/built/`
|
||||||
|
# is therefore allowed through the rules below, while a scratch bundle built
|
||||||
|
# anywhere else stays untracked.
|
||||||
provisioning/offline-repo/out/
|
provisioning/offline-repo/out/
|
||||||
provisioning/offline-repo/pool/
|
provisioning/offline-repo/pool/
|
||||||
Packages
|
Packages
|
||||||
Packages.gz
|
Packages.gz
|
||||||
packages.lock.tsv
|
packages.lock.tsv
|
||||||
|
# ...but never inside the committed bundle.
|
||||||
|
!provisioning/offline-repo/built/
|
||||||
|
!provisioning/offline-repo/built/**
|
||||||
|
|
||||||
# Documentation site (generated)
|
# Documentation site (generated)
|
||||||
docs-site/node_modules/
|
docs-site/node_modules/
|
||||||
|
|
@ -39,7 +48,7 @@ fetched-logs/
|
||||||
id_rsa*
|
id_rsa*
|
||||||
id_ed25519*
|
id_ed25519*
|
||||||
*credentials*
|
*credentials*
|
||||||
*token*
|
*[-_.]token*
|
||||||
*.secret
|
*.secret
|
||||||
|
|
||||||
# Credential files delivered by `admin/bf-creds --fetch` (<dealer-number>.txt).
|
# Credential files delivered by `admin/bf-creds --fetch` (<dealer-number>.txt).
|
||||||
|
|
|
||||||
64
admin/bf
Executable file
64
admin/bf
Executable file
|
|
@ -0,0 +1,64 @@
|
||||||
|
#!/bin/sh
|
||||||
|
''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" >&2; exit 3; }; exec python3 "$0" "$@" # '''
|
||||||
|
"""bfos operator console — thin entry point (sh/Python polyglot).
|
||||||
|
|
||||||
|
The file is valid POSIX shell *and* valid Python:
|
||||||
|
|
||||||
|
* run as ``admin/bf`` (or as the ``bf`` symlink that
|
||||||
|
``admin/bf-bootstrap.sh`` creates), the shell line above checks that
|
||||||
|
``python3`` exists and, when it does not, prints a Turkish error and points at
|
||||||
|
the proven bash console (``admin/bf-menu``) — a device without Python is still
|
||||||
|
operational,
|
||||||
|
* everything after that line is the Python entry point: it puts the repository
|
||||||
|
root on ``sys.path`` and calls :func:`bfos.cli.main`. Nothing else lives here;
|
||||||
|
all logic is in the ``bfos`` package next to it.
|
||||||
|
|
||||||
|
Usage: sudo bfos [--full] [--dry-run] [--check] [--repo YOL] [--version] [--lang tr]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
MIN_PYTHON = (3, 8)
|
||||||
|
|
||||||
|
if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpreter
|
||||||
|
sys.stderr.write(
|
||||||
|
"HATA: Python sürümü çok eski: {0}. bfos için Python {1}.{2}+ gerekir.\n".format(
|
||||||
|
sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.stderr.write("Yedek yol (bash): sudo admin/bf-menu\n")
|
||||||
|
raise SystemExit(3)
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_root():
|
||||||
|
"""First candidate directory that actually contains the bfos package."""
|
||||||
|
here = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
candidates = [
|
||||||
|
os.environ.get("BF_REPO", ""),
|
||||||
|
os.path.dirname(here), # <repo>/admin/bf -> <repo>
|
||||||
|
here,
|
||||||
|
os.getcwd(),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate and os.path.isfile(os.path.join(candidate, "bfos", "__init__.py")):
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = _repository_root()
|
||||||
|
if not ROOT:
|
||||||
|
sys.stderr.write(
|
||||||
|
"HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n"
|
||||||
|
"ya da --repo YOL ile depo yolunu belirtin.\n"
|
||||||
|
"Yedek yol (bash): sudo admin/bf-menu\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(3)
|
||||||
|
|
||||||
|
if ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
from bfos.cli import main # noqa: E402 (sys.path is prepared above)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -297,20 +297,44 @@ elif [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 6. bf-menu: the on-device installer menu entry point.
|
# 6. Operator console entry point.
|
||||||
|
#
|
||||||
|
# The Python console (`admin/bf` -> bfos) is the primary path; the bash
|
||||||
|
# `bf-menu` stays linked as a fallback so a host without python3 can still be
|
||||||
|
# operated.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
PY_CONSOLE="$DIR/admin/bf"
|
||||||
MENU_SOURCE="$DIR/admin/bf-menu"
|
MENU_SOURCE="$DIR/admin/bf-menu"
|
||||||
|
MENU_READY=0
|
||||||
|
|
||||||
|
if [[ -f "$PY_CONSOLE" ]]; then
|
||||||
|
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
|
say "check: would make $PY_CONSOLE executable and link $BIN_DIR/bf"
|
||||||
|
else
|
||||||
|
chmod 0755 "$PY_CONSOLE"
|
||||||
|
ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf"
|
||||||
|
say "bf ready: $BIN_DIR/bf (Python console)"
|
||||||
|
fi
|
||||||
|
MENU_READY=1
|
||||||
|
else
|
||||||
|
warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -f "$MENU_SOURCE" ]]; then
|
if [[ -f "$MENU_SOURCE" ]]; then
|
||||||
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu"
|
say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu"
|
||||||
else
|
else
|
||||||
chmod 0755 "$MENU_SOURCE"
|
chmod 0755 "$MENU_SOURCE"
|
||||||
ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu"
|
ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu"
|
||||||
say "bf-menu ready: $BIN_DIR/bf-menu"
|
say "bf-menu ready: $BIN_DIR/bf-menu (bash fallback)"
|
||||||
fi
|
fi
|
||||||
MENU_READY=1
|
MENU_READY=1
|
||||||
else
|
else
|
||||||
warn "admin/bf-menu is not present in $DIR; 'sudo bf-menu' will work once the menu module is in the checkout"
|
warn "admin/bf-menu is not present in $DIR; the bash fallback will work once it is in the checkout"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$MENU_READY" -eq 0 ]]; then
|
||||||
|
warn "no operator console was found in $DIR; link one of admin/bf or admin/bf-menu manually"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
@ -336,11 +360,22 @@ if [[ -n "$REVISION" ]]; then
|
||||||
printf ' revision : %s\n' "$REVISION"
|
printf ' revision : %s\n' "$REVISION"
|
||||||
fi
|
fi
|
||||||
printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR"
|
printf ' tools : %s linked into %s\n' "$LINKED" "$BIN_DIR"
|
||||||
if [[ "$MENU_READY" -eq 1 ]]; then
|
if [[ -x "$BIN_DIR/bf" ]]; then
|
||||||
printf ' bf-menu : %s\n' "$BIN_DIR/bf-menu"
|
printf ' console : %s (Python, primary)\n' "$BIN_DIR/bf"
|
||||||
else
|
else
|
||||||
printf ' bf-menu : pending (admin/bf-menu is not in the checkout yet)\n'
|
printf ' console : pending (admin/bf is not in the checkout yet)\n'
|
||||||
fi
|
fi
|
||||||
printf ' next : sudo bf-menu\n'
|
if [[ -x "$BIN_DIR/bf-menu" ]]; then
|
||||||
printf '\nInstallation complete. To get started: sudo bf-menu\n'
|
printf ' fallback : %s (bash)\n' "$BIN_DIR/bf-menu"
|
||||||
printf 'Kurulum tamam. Başlamak için: sudo bf-menu\n'
|
else
|
||||||
|
printf ' fallback : pending (admin/bf-menu is not in the checkout yet)\n'
|
||||||
|
fi
|
||||||
|
# The Python console is the recommended entry point; the bash menu stays as the
|
||||||
|
# fallback for a host without python3.
|
||||||
|
if [[ -x "$BIN_DIR/bf" ]]; then
|
||||||
|
printf ' next : sudo bf\n'
|
||||||
|
else
|
||||||
|
printf ' next : sudo bf-menu\n'
|
||||||
|
fi
|
||||||
|
printf '\nInstallation complete. To get started: sudo bf\n'
|
||||||
|
printf 'Kurulum tamam. Başlamak için: sudo bf\n'
|
||||||
|
|
|
||||||
17
bfos/__init__.py
Normal file
17
bfos/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""bfos — Blueforce Field OS operator console.
|
||||||
|
|
||||||
|
Python operator console for the Blueforce field fleet, in Turkish, with a
|
||||||
|
simple default view (``1) Kur``, ``2) Durum``, ``3) Çıkış``) and a detailed view
|
||||||
|
(``--full``) that exposes the full operation catalogue.
|
||||||
|
|
||||||
|
The console is a thin front end: it drives the existing installer, the existing
|
||||||
|
Ansible playbooks and the existing ``bf-*`` diagnostics tools. It never invents
|
||||||
|
a playbook, never edits the inventory and never talks to a device itself.
|
||||||
|
|
||||||
|
No third-party dependency: standard library only (Python 3.8+).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import VERSION
|
||||||
|
|
||||||
|
__version__ = VERSION
|
||||||
|
__all__ = ["VERSION", "__version__"]
|
||||||
6
bfos/__main__.py
Normal file
6
bfos/__main__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""``python3 -m bfos`` — module entry point for the operator console."""
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
BIN
bfos/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/cli.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/cli.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/config.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/operations.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/operations.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/runner.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/runner.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/tui.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/tui.cpython-311.pyc
Normal file
Binary file not shown.
BIN
bfos/__pycache__/ui_gui.cpython-311.pyc
Normal file
BIN
bfos/__pycache__/ui_gui.cpython-311.pyc
Normal file
Binary file not shown.
1104
bfos/cli.py
Normal file
1104
bfos/cli.py
Normal file
File diff suppressed because it is too large
Load diff
381
bfos/config.py
Normal file
381
bfos/config.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""Static configuration for the bfos operator console.
|
||||||
|
|
||||||
|
Everything an operator can read lives here, in Turkish, so the wording can be
|
||||||
|
reviewed and changed in one place. Identifiers and comments stay English.
|
||||||
|
|
||||||
|
Rules kept in this module:
|
||||||
|
* standard library only (Python 3.8+), no third-party import,
|
||||||
|
* no side effect at import time (no directory is created, nothing is read),
|
||||||
|
* thresholds and allowlists are shared with the Ansible playbooks/docs: the
|
||||||
|
canonical wave order and the dealer-number pattern come from docs/02 and
|
||||||
|
docs/10 exactly as admin/lib/targets.sh defines them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Identity and version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PROGRAM_NAME = "bfos"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
SCREEN_TITLE = "BLUEFORCE FIELD OS"
|
||||||
|
SCREEN_SUBTITLE = "Saha cihazı operatör konsolu"
|
||||||
|
PROGRAM_DESCRIPTION = "Blueforce saha cihazı operatör konsolu (Türkçe)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_LOG_FILE = "/var/log/blueforce-console.log"
|
||||||
|
LOG_FALLBACK_SUBDIR = ("blueforce", "blueforce-console.log")
|
||||||
|
LOG_FILE_MODE = 0o600
|
||||||
|
LOG_DIR_MODE = 0o700
|
||||||
|
|
||||||
|
INSTALLER_REL = "scripts/install/blueforce-install.sh"
|
||||||
|
PLAYBOOK_DIR_REL = "ansible/playbooks"
|
||||||
|
DIAGNOSTICS_DIR_REL = "scripts/diagnostics"
|
||||||
|
GROUP_VARS_REL = "ansible/inventory/group_vars/all.yml"
|
||||||
|
INVENTORY_DIR_REL = "ansible/inventory"
|
||||||
|
INVENTORY_EXAMPLE_REL = "ansible/inventory/hosts.example.yml"
|
||||||
|
INVENTORY_CANDIDATES = ("hosts.yml", "hosts.yaml", "hosts", "hosts.ini")
|
||||||
|
|
||||||
|
STATE_FILE = "/var/lib/blueforce/state.json" # nosec - read-only whitelist
|
||||||
|
INSTALL_STATE_FILE = "/var/lib/blueforce/install-state"
|
||||||
|
INSTALL_LOG_FILE = "/var/log/blueforce-install.log"
|
||||||
|
TOOL_BIN_DIR = "/usr/local/bin"
|
||||||
|
ADMIN_KEY_FILE = "~/.ssh/blueforce_id_ed25519"
|
||||||
|
SUPPORT_BUNDLE_OUT_DIR = "/var/tmp"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rules, allowlists and thresholds (mirrors admin/lib/targets.sh + docs/10)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEALER_PATTERN = r"^[0-9]{8}$"
|
||||||
|
HOST_PATTERN = r"^bf-[0-9]{8}$"
|
||||||
|
|
||||||
|
# Canonical rollout order. One place, so the menu, the gate checks and the
|
||||||
|
# fleet-wide loop can never disagree.
|
||||||
|
WAVES = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production")
|
||||||
|
PRODUCTION_WAVE = "production"
|
||||||
|
|
||||||
|
# Target selection modes.
|
||||||
|
MODE_ANY = "any"
|
||||||
|
MODE_WAVE = "wave"
|
||||||
|
MODE_RELEASE = "release"
|
||||||
|
|
||||||
|
# Scope names derived from the playbook file itself.
|
||||||
|
SCOPE_SIMPLE = "simple"
|
||||||
|
SCOPE_WAVE = "wave"
|
||||||
|
SCOPE_RELEASE = "release"
|
||||||
|
|
||||||
|
SERVICE_ALLOWLIST = ("blueforce-agent", "docker", "ssh", "sshd", "systemd-journald", "cron")
|
||||||
|
SCRIPT_DIRS = ("/opt/blueforce/bin", "/usr/local/sbin")
|
||||||
|
|
||||||
|
DISK_WARN_MIN = 1
|
||||||
|
DISK_WARN_MAX = 100
|
||||||
|
DEFAULT_REBOOT_GRACE = 300
|
||||||
|
DEFAULT_LOG_LINES = 500
|
||||||
|
|
||||||
|
# Non-secret identity/lifecycle keys of state.json (whitelist, mirrors bf-menu).
|
||||||
|
STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id")
|
||||||
|
|
||||||
|
# Central endpoint keys read from group_vars/all.yml for the preparation screens.
|
||||||
|
CENTRAL_ENDPOINT_KEYS = (
|
||||||
|
"wg_subnet",
|
||||||
|
"wg_interface",
|
||||||
|
"wg_hub_endpoint",
|
||||||
|
"monitoring_endpoint",
|
||||||
|
"monitoring_job",
|
||||||
|
"update_repo_url",
|
||||||
|
"update_repo_suite",
|
||||||
|
"meg_image_tag",
|
||||||
|
"support_bundle_dir",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Local diagnostics tools the console can drive (resolved in /usr/local/bin
|
||||||
|
# first, then in the checkout under scripts/diagnostics/).
|
||||||
|
DIAGNOSTIC_TOOLS = (
|
||||||
|
"bf-status",
|
||||||
|
"bf-diagnostics",
|
||||||
|
"bf-check-local",
|
||||||
|
"bf-check-enrollment",
|
||||||
|
"bf-check-ready",
|
||||||
|
"bf-release",
|
||||||
|
"bf-remote-status",
|
||||||
|
"bf-support-bundle",
|
||||||
|
"bf-hardware-inventory.sh",
|
||||||
|
)
|
||||||
|
|
||||||
|
TOOL_ENROLL_STATUS = "bf-enrollment-status"
|
||||||
|
TOOL_LIVE_HW_CHECK = "bf-live-hw-check"
|
||||||
|
|
||||||
|
# Language: this console is Turkish-only on purpose (user decision). The option
|
||||||
|
# exists so a future translation has a single switch to grow into.
|
||||||
|
SUPPORTED_LANGS = ("tr",)
|
||||||
|
DEFAULT_LANG = "tr"
|
||||||
|
|
||||||
|
# Layout and exit codes (the bash console uses the same contract).
|
||||||
|
CONSOLE_WIDTH = 78
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_FAIL = 1
|
||||||
|
EXIT_USAGE = 2
|
||||||
|
|
||||||
|
# Minimal Python version this console supports.
|
||||||
|
MIN_PYTHON = (3, 8)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ANSI palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ANSI = {
|
||||||
|
"reset": "\033[0m",
|
||||||
|
"bold": "\033[1m",
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"red": "\033[31m",
|
||||||
|
"green": "\033[32m",
|
||||||
|
"yellow": "\033[33m",
|
||||||
|
"blue": "\033[34m",
|
||||||
|
"magenta": "\033[35m",
|
||||||
|
"cyan": "\033[36m",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Palette:
|
||||||
|
"""ANSI codes, or empty strings when colour is switched off."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"enabled",
|
||||||
|
"reset",
|
||||||
|
"bold",
|
||||||
|
"dim",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool) -> None:
|
||||||
|
self.enabled = enabled
|
||||||
|
for name, code in ANSI.items():
|
||||||
|
setattr(self, name, code if enabled else "")
|
||||||
|
|
||||||
|
def paint(self, text: str, *names: str) -> str:
|
||||||
|
"""Wrap TEXT in the given colour names (ignored when disabled)."""
|
||||||
|
if not self.enabled or not names:
|
||||||
|
return text
|
||||||
|
prefix = "".join(getattr(self, name) for name in names)
|
||||||
|
return "{0}{1}{2}".format(prefix, text, self.reset)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Turkish messages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Generic messages
|
||||||
|
MSG_QUIT = "Konsoldan çıkılıyor."
|
||||||
|
MSG_BACK = "Geri"
|
||||||
|
MSG_QUIT_ITEM = "Çıkış"
|
||||||
|
MSG_AT_TOP = "Zaten ana menüdesiniz."
|
||||||
|
MSG_UNKNOWN_CHOICE = "Geçersiz seçim: '{choice}'. Lütfen menüdeki numaralardan birini yazın."
|
||||||
|
MSG_MENU_RANGE = "Lütfen 1 ile {max} arasında bir numara yazın."
|
||||||
|
MSG_PRESS_ENTER = "Devam etmek için Enter'a basın"
|
||||||
|
MSG_CANCELLED = "İptal edildi; hiçbir şey çalıştırılmadı."
|
||||||
|
MSG_CANCELLED_RUN = "İşlem iptal edildi, komut çalıştırılmadı."
|
||||||
|
MSG_INVALID_INPUT = "Girdi anlaşılamadı: {value}"
|
||||||
|
MSG_REQUIRED = "Bu alan boş bırakılamaz."
|
||||||
|
MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor."
|
||||||
|
|
||||||
|
# Interpreter / environment
|
||||||
|
MSG_PYTHON_MISSING = (
|
||||||
|
"HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n"
|
||||||
|
"Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu"
|
||||||
|
)
|
||||||
|
MSG_PYTHON_OLD = (
|
||||||
|
"HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n"
|
||||||
|
"Yedek yol (bash): sudo admin/bf-menu"
|
||||||
|
)
|
||||||
|
MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}."
|
||||||
|
MSG_REPO_INVALID = (
|
||||||
|
"HATA: {path} bir Blueforce deposu gibi görünmüyor (ansible/ ve scripts/ bulunamadı).\n"
|
||||||
|
"Depoyu klonlayıp konsolu oradan çalıştırın veya --repo YOL ile yolu belirtin."
|
||||||
|
)
|
||||||
|
MSG_ANSIBLE_MISSING = (
|
||||||
|
"ansible-playbook bu makinede kurulu değil; filo işlemleri çalıştırılamaz.\n"
|
||||||
|
"Merkez makinaya kurun: sudo apt install ansible (veya: pipx install ansible-core)"
|
||||||
|
)
|
||||||
|
MSG_INVENTORY_MISSING = (
|
||||||
|
"Ansible envanteri bulunamadı: {dir}\n"
|
||||||
|
"Gerçek envanteri oluşturun: cp ansible/inventory/hosts.example.yml ansible/inventory/hosts.yml "
|
||||||
|
"ve bf-<8 haneli bayi no> cihazları doldurun."
|
||||||
|
)
|
||||||
|
MSG_INVENTORY_EXAMPLE_ONLY = (
|
||||||
|
"Yalnızca örnek envanter var (örnek cihaz kimlikleri içerir): {path}\n"
|
||||||
|
"Gerçek cihazlara karşı kullanmayın; kopyalayıp gerçek kimlikleri yazın."
|
||||||
|
)
|
||||||
|
MSG_INVENTORY_UNREADABLE = "Envanter okunamıyor: {path}"
|
||||||
|
MSG_ROOT_REQUIRED = (
|
||||||
|
"Bu işlem BU cihazda root yetkisi gerektirir.\n"
|
||||||
|
"Konsolu tekrar sudo ile başlatın: sudo admin/bf"
|
||||||
|
)
|
||||||
|
MSG_TOOL_MISSING = (
|
||||||
|
"{tool} bulunamadı (/usr/local/bin altında ve depo içinde yok).\n"
|
||||||
|
"Kurulacak komut: sudo install -m 0755 scripts/diagnostics/{tool} /usr/local/bin/{tool}"
|
||||||
|
)
|
||||||
|
MSG_INSTALLER_MISSING = (
|
||||||
|
"Kurulum betiği bulunamadı: {path}\n"
|
||||||
|
"--repo ile doğru depo yolunu gösterin."
|
||||||
|
)
|
||||||
|
MSG_PLAYBOOK_MISSING = (
|
||||||
|
"Playbook bulunamadı: {path}\n"
|
||||||
|
"Konsolu deponun içinden çalıştırın (--repo YOL)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Target selection
|
||||||
|
MSG_TARGET_NONE = "Hedef seçilmedi; komut üretilmedi."
|
||||||
|
MSG_TARGET_EMPTY = "Cihaz seçilmedi."
|
||||||
|
MSG_TARGET_DEALER_INVALID = (
|
||||||
|
"Geçersiz bayi numarası: '{value}'. 8 hane olmalı, boşluk ve harf olmamalı (örn. 12010101)."
|
||||||
|
)
|
||||||
|
MSG_TARGET_UNKNOWN = "'{value}' bir bayi numarası (8 hane), grup ya da 'all' değil."
|
||||||
|
MSG_TARGET_DEVICE_UNKNOWN = (
|
||||||
|
"{host} envanterde yok ({inventory}).\n"
|
||||||
|
"Cihazı önce ilgili dalga grubuna ekleyin."
|
||||||
|
)
|
||||||
|
MSG_TARGET_DEVICE_NO_WAVE = (
|
||||||
|
"{host} onaylı bir dalgada değil. Dalga kapsamlı işlemler böyle bir cihazı reddeder (fail-closed)."
|
||||||
|
)
|
||||||
|
MSG_TARGET_GROUP_UNKNOWN = "'{group}' grubu envanterde yok. Bilinen gruplar: {groups}"
|
||||||
|
MSG_TARGET_GROUP_NOT_WAVE = (
|
||||||
|
"'{group}' onaylı bir dalga değil. Dalga kapsamlı işlemler için: {waves}"
|
||||||
|
)
|
||||||
|
MSG_TARGET_GROUP_EMPTY = "'{group}' grubu boş; boş hedef reddedildi."
|
||||||
|
MSG_TARGET_ALL_EMPTY = "Envanterde hiç cihaz yok; boş hedef reddedildi."
|
||||||
|
MSG_TARGET_RELEASE_DEVICE = (
|
||||||
|
"Sürüm kapılı işlemler dalga kapsamlıdır: tek cihaz seçilemez, dalga grubu seçin."
|
||||||
|
)
|
||||||
|
MSG_TARGET_RELEASE_ALL = (
|
||||||
|
"Sürüm kapılı işlemler için filo geneli reddedildi: güncelleme dalga dalga yapılır."
|
||||||
|
)
|
||||||
|
MSG_TARGET_KNOWN_GROUPS = "Bilinen gruplar: {groups}"
|
||||||
|
|
||||||
|
# Gates
|
||||||
|
MSG_GATE_PLAN_TITLE = "Güvenlik kapıları"
|
||||||
|
MSG_GATE_ALREADY = "Bu işlem için ek kapı onayı gerekmiyor."
|
||||||
|
MSG_GATE_WAVE_TOKEN = (
|
||||||
|
"Dalga onayı istendi: '{wave}'. Sürüm kapılı işlemler yalnızca yazılı dalga onayıyla koşar."
|
||||||
|
)
|
||||||
|
MSG_GATE_WAVE_TOKEN_ASK = "Onay için dalga adını aynen yazın ({wave}): "
|
||||||
|
MSG_GATE_WAVE_REFUSED = "Dalga onayı verilmedi; işlem yapılmadı."
|
||||||
|
MSG_GATE_GATE_FLAG = "Onayla birlikte playbook'a -e wave_gate_confirmed=true iletilecek."
|
||||||
|
MSG_GATE_PRODUCTION = (
|
||||||
|
"DALGA 'production' CANLI FİLODUR. Playbook, operation/üretim onayı olmadan koşmayı reddeder."
|
||||||
|
)
|
||||||
|
MSG_GATE_PRODUCTION_ASK = "Canlı filo onayı için 'production' yazın: "
|
||||||
|
MSG_GATE_PRODUCTION_APPROVAL = (
|
||||||
|
"ÜRETİM ONAYI: bu komut canlı filoyu değiştirir ve -e production_override=true iletilecek. Onaylıyor musunuz?"
|
||||||
|
)
|
||||||
|
MSG_GATE_PRODUCTION_REFUSED = "Üretim onayı verilmedi; işlem yapılmadı."
|
||||||
|
MSG_GATE_FLEET_TOKEN = (
|
||||||
|
"FİLO GENELİ: komut envanterdeki tüm cihazları ({count}) hedefliyor. Bu en geniş kapsamdır."
|
||||||
|
)
|
||||||
|
MSG_GATE_FLEET_TOKEN_ASK = "Filo geneli onayı için 'all' yazın: "
|
||||||
|
MSG_GATE_FLEET_REFUSED = "Filo geneli onayı verilmedi; işlem yapılmadı."
|
||||||
|
MSG_GATE_WAVE_BY_WAVE = (
|
||||||
|
"Bu playbook dalga kapsamlı: filo, dalga dalga işlenir ve ilk hatada DURUR.\n"
|
||||||
|
"Sıra: {order}"
|
||||||
|
)
|
||||||
|
MSG_GATE_WAVE_LOOP_ASK = "Dalga '{wave}' ({count} cihaz) için onay vermek üzere dalga adını yazın: "
|
||||||
|
MSG_GATE_WAVE_SKIPPED = "'{wave}' dalgası operatör tarafından atlandı."
|
||||||
|
MSG_GATE_WAVE_FAILED = "'{wave}' dalgası başarısız oldu (çıkış kodu {rc}); sıra DURDURULDU, sonraki dalgaya dokunulmadı."
|
||||||
|
MSG_GATE_WAVE_NOT_RUN = "'{wave}' dalgası tamamlanmadı; sıra DURDURULDU."
|
||||||
|
MSG_GATE_START_FIRST = "İlk dalga ile başlansın mı?"
|
||||||
|
MSG_GATE_ORDER = "lab → pilot_1 → pilot_2 → wave_1 → wave_2 → production"
|
||||||
|
MSG_GATE_SCOPE_MISMATCH = (
|
||||||
|
"RED: {playbook} için konsol kapsamı '{declared}' bekliyor, playbook '{actual}' diyor.\n"
|
||||||
|
"Playbook kendi kapılarının kaynağıdır; konsol tablosu güncellenmeden bu playbook çalıştırılmaz."
|
||||||
|
)
|
||||||
|
MSG_GATE_NO_TARGET = "Hedef seçilmedi; komut üretilmedi (kapı kuralı)."
|
||||||
|
|
||||||
|
# Prompts / extra variables
|
||||||
|
MSG_PROMPT_DEALER = "Bayi numarası (8 hane): "
|
||||||
|
MSG_PROMPT_TARGET = "Hedef (bayi no, grup ya da 'all'): "
|
||||||
|
MSG_PROMPT_CHOICE = "Seçim > "
|
||||||
|
MSG_PROMPT_PACKAGES = "Paketler (virgülle ayrılmış, paket=sürüm): "
|
||||||
|
MSG_PROMPT_SERVICE = "Servis adı: "
|
||||||
|
MSG_PROMPT_SCRIPT = "Betik yolu (mutlak): "
|
||||||
|
MSG_PROMPT_SCRIPT_ARGS = "Betik argümanları (isteğe bağlı): "
|
||||||
|
MSG_PROMPT_GRACE = "Yeniden başlatma için bekleme süresi (saniye) [{default}]: "
|
||||||
|
MSG_PROMPT_DISK = "Uyarı eşiği yüzdesi (boş = playbook varsayılanı 80): "
|
||||||
|
MSG_PROMPT_LOG_LINES = "Cihaz başına log satırı [{default}]: "
|
||||||
|
MSG_PROMPT_VERSION = "Beklenen Field OS sürümü (boş = sapma denetimi yok): "
|
||||||
|
|
||||||
|
MSG_PKG_HINT = "Yalnız kesin sürüm: paket=sürüm (aralık, 'latest' ve boş liste reddedilir)."
|
||||||
|
MSG_PKG_REQUIRED = "En az bir sabitlenmiş paket gerekir."
|
||||||
|
MSG_PKG_INVALID = "Geçersiz pin: '{value}'. Beklenen biçim: paket=sürüm (örn. docker-ce=5:27.0.3-1~ubuntu.24.04~noble)."
|
||||||
|
MSG_PKG_LATEST = (
|
||||||
|
"Reddedildi: '{value}' kesin bir sürüm değil. 'latest' politikası yoktur; "
|
||||||
|
"pinlenmiş sürüm yazın (docs/10)."
|
||||||
|
)
|
||||||
|
MSG_SERVICE_HINT = "İzinli servisler: {allowlist}"
|
||||||
|
MSG_SERVICE_INVALID = "Reddedildi: '{value}' izinli servis listesinde değil (playbook da reddederdi)."
|
||||||
|
MSG_SCRIPT_ABSOLUTE = "Betik yolu mutlak olmalı (/ ile başlamalı)."
|
||||||
|
MSG_SCRIPT_OUTSIDE = "Reddedildi: '{value}' izinli dizinlerin dışında ({dirs})."
|
||||||
|
MSG_GRACE_INVALID = "Bekleme süresi tam sayı saniye olmalı."
|
||||||
|
MSG_PCT_INVALID = "Eşik {min} ile {max} arasında olmalı."
|
||||||
|
MSG_LINES_INVALID = "Satır sayısı pozitif bir tam sayı olmalı."
|
||||||
|
|
||||||
|
# Command / run flow
|
||||||
|
MSG_COMMAND_TITLE = "Çalıştırılacak komut"
|
||||||
|
MSG_RUN_ASK = "Bu komut çalıştırılsın mı? [y=çalıştır / c=--check ile çalıştır / N=iptal]: "
|
||||||
|
MSG_DESTRUCTIVE_WARN = "DURUM DEĞİŞTİREN İŞLEM: {label}"
|
||||||
|
MSG_DESTRUCTIVE_SUB = "Onaylamadan önce yukarıdaki komutu okuyun; seçilen cihazları değiştirir."
|
||||||
|
MSG_DESTRUCTIVE_GATE = (
|
||||||
|
"Yıkıcı işlem kapısı: {label} seçilen cihazları değiştirir. "
|
||||||
|
"Bu onaydan sonra komut üretilir ve çalıştırmadan önce ayrıca sorulur."
|
||||||
|
)
|
||||||
|
MSG_ASK_YES_NO = "{question} [e/H]: "
|
||||||
|
MSG_CHECK_MODE = "Check modu: komut --check ile çalışacak, hiçbir değişiklik uygulanmayacak."
|
||||||
|
MSG_DRY_RUN = "DENEME (dry-run) MODU: komutlar yazılır, asla çalıştırılmaz."
|
||||||
|
MSG_DRY_RUN_WARN = "dry-run: yukarıdaki komut ÇALIŞTIRILMADI."
|
||||||
|
MSG_RUN_OK = "Komut başarıyla tamamlandı (çıkış kodu 0)."
|
||||||
|
MSG_RUN_FAIL = "Komut {rc} çıkış koduyla başarısız oldu."
|
||||||
|
MSG_RUN_ABORTED = "Komut {signal} ile kesildi."
|
||||||
|
MSG_LOG_FALLBACK = "{requested} yazılamıyor; denetim kaydı {fallback} dosyasına yazılıyor (mod 600)."
|
||||||
|
MSG_LOG_NONE = "Yazılabilir denetim kaydı bulunamadı; bu oturum kaydedilmeyecek."
|
||||||
|
|
||||||
|
# Simple (default) menu
|
||||||
|
SIMPLE_MENU_TITLE = SCREEN_TITLE
|
||||||
|
SIMPLE_MENU_SUBTITLE = "Bu cihazı kurmak ve durumunu görmek için 1 veya 2 yazın"
|
||||||
|
SIMPLE_ITEM_INSTALL_LABEL = "Kur"
|
||||||
|
SIMPLE_ITEM_INSTALL_DESC = "Bu cihazı Field OS standardına getir (kurulum ekranı)"
|
||||||
|
SIMPLE_ITEM_STATUS_LABEL = "Durum"
|
||||||
|
SIMPLE_ITEM_STATUS_DESC = "Kurulum ve cihaz durumunu göster (salt okunur)"
|
||||||
|
SIMPLE_ITEM_EXIT_LABEL = "Çıkış"
|
||||||
|
SIMPLE_ITEM_EXIT_DESC = "Konsoldan çık"
|
||||||
|
SIMPLE_ADVANCED_LABEL = "Detaylı mod"
|
||||||
|
SIMPLE_ADVANCED_DESC = "Filo işlemleri, merkez hazırlığı, araçlar ve 21 playbook"
|
||||||
|
|
||||||
|
# Full (detailed) menu
|
||||||
|
FULL_MENU_TITLE = "Detaylı mod"
|
||||||
|
FULL_MENU_SUBTITLE = "Tüm operasyonlar; güvenlik kapıları her işlemde geçerlidir"
|
||||||
|
CAT_INSTALL = "Kurulum"
|
||||||
|
CAT_DEVICES = "Cihaz işlemleri"
|
||||||
|
CAT_CENTRAL = "Merkez hazırlığı"
|
||||||
|
CAT_TOOLS = "Araçlar"
|
||||||
|
CAT_INFO = "Cihaz bilgisi"
|
||||||
|
|
||||||
|
MSG_FULL_MODE_HINT = "Detaylı mod açık: filo işlemleri ve araçlar görünür."
|
||||||
|
MSG_SIMPLE_MODE_HINT = "Basit mod: 1) Kur, 2) Durum, 3) Çıkış. Detaylı özellikler için 'd' yazın ya da --full ile başlatın."
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AUDIT_SESSION = "SESSION"
|
||||||
|
AUDIT_TARGET = "TARGET"
|
||||||
|
AUDIT_RUN = "RUN"
|
||||||
|
AUDIT_DRYRUN = "DRYRUN"
|
||||||
|
AUDIT_OK = "OK"
|
||||||
|
AUDIT_FAIL = "FAIL"
|
||||||
|
AUDIT_CANCEL = "CANCEL"
|
||||||
|
AUDIT_STOP = "STOP"
|
||||||
|
|
||||||
|
# Non-interactive listings
|
||||||
|
LIST_TITLE_REPORTS = "Salt okunur filo raporları (cihazlara dokunmaz)"
|
||||||
|
LIST_TITLE_MAINTENANCE = "Filo bakım işlemleri (yıkıcı olanlar iki kez onay ister)"
|
||||||
|
LIST_NOTE_WAVE = "scope=wave -> -e operation_wave=<dalga> -e operation_confirmed=true"
|
||||||
|
LIST_NOTE_RELEASE = "scope=release -> -e release_wave=<dalga> + yazılı dalga onayı"
|
||||||
|
LIST_NOTE_PLAYBOOK = "Kapsam her playbook dosyasından okunur; uyuşmazsa çalıştırılmaz."
|
||||||
1193
bfos/operations.py
Normal file
1193
bfos/operations.py
Normal file
File diff suppressed because it is too large
Load diff
309
bfos/runner.py
Normal file
309
bfos/runner.py
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
"""Command execution and the audit log for the bfos console.
|
||||||
|
|
||||||
|
Separation of duties:
|
||||||
|
|
||||||
|
* :mod:`bfos.operations` decides *what* may run and produces an argv list,
|
||||||
|
* this module decides *whether* it runs (confirmation, dry-run, check mode),
|
||||||
|
executes it with :mod:`subprocess`, reports the exit code, and writes the
|
||||||
|
audit record.
|
||||||
|
|
||||||
|
Guarantees kept from the bash console (``admin/bf-menu``):
|
||||||
|
|
||||||
|
* the command is always printed **before** it is confirmed and run,
|
||||||
|
* ``--dry-run`` executes nothing at all,
|
||||||
|
* the audit log is mode 600 (directories 700) and falls back to
|
||||||
|
``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable,
|
||||||
|
* no secret reaches the log: a single sanitizer redacts
|
||||||
|
``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=``
|
||||||
|
style values and whole PEM blocks,
|
||||||
|
* raw command output is shown on screen and never copied into the log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, List, Optional, Sequence
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secret redaction -- the single choke point before anything is logged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)"
|
||||||
|
_SECRET_ASSIGN_RE = re.compile(
|
||||||
|
r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY")
|
||||||
|
_PEM_BLOCK_RE = re.compile(
|
||||||
|
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDACTED = "<redacted>"
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text: str) -> str:
|
||||||
|
"""Return TEXT with secret-looking values and PEM blocks removed.
|
||||||
|
|
||||||
|
Deliberately blunt: log records are written for every command, so the
|
||||||
|
sanitizer must be safe for text that was never meant to be a secret. A PEM
|
||||||
|
header without its matching footer (truncated line, interrupted paste) is
|
||||||
|
treated as the start of a secret: everything from the header on is dropped,
|
||||||
|
because a half-copied key is still a key.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
cleaned = _PEM_BLOCK_RE.sub(REDACTED, text)
|
||||||
|
header = _PEM_LINE_RE.search(cleaned)
|
||||||
|
if header:
|
||||||
|
cleaned = cleaned[:header.start()] + REDACTED
|
||||||
|
cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str:
|
||||||
|
base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join(
|
||||||
|
home or os.path.expanduser("~"), ".local", "state"
|
||||||
|
)
|
||||||
|
return os.path.join(base, *config.LOG_FALLBACK_SUBDIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_log_file(path: str) -> bool:
|
||||||
|
"""Create or tighten PATH to mode 600; True when it is usable."""
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
if not os.path.isfile(path) or not os.access(path, os.W_OK):
|
||||||
|
return False
|
||||||
|
os.chmod(path, config.LOG_FILE_MODE)
|
||||||
|
else:
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True)
|
||||||
|
if not os.access(directory, os.W_OK):
|
||||||
|
return False
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE)
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(path, config.LOG_FILE_MODE)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return _is_private(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private(path: str) -> bool:
|
||||||
|
"""True when only the owner can read the log (never log into a world-readable file)."""
|
||||||
|
try:
|
||||||
|
mode = os.stat(path).st_mode
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return (mode & 0o077) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditLog:
|
||||||
|
"""Append-only audit trail. Never receives raw command output."""
|
||||||
|
|
||||||
|
requested: str = config.DEFAULT_LOG_FILE
|
||||||
|
state_home: Optional[str] = None
|
||||||
|
home: Optional[str] = None
|
||||||
|
path: Optional[str] = None
|
||||||
|
ready: bool = False
|
||||||
|
warning: str = ""
|
||||||
|
|
||||||
|
def initialise(self) -> None:
|
||||||
|
for candidate in (self.requested, fallback_log_path(self.state_home, self.home)):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if _prepare_log_file(candidate):
|
||||||
|
self.path = candidate
|
||||||
|
self.ready = True
|
||||||
|
if candidate != self.requested:
|
||||||
|
self.warning = config.MSG_LOG_FALLBACK.format(
|
||||||
|
requested=self.requested, fallback=candidate
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.ready = False
|
||||||
|
self.warning = config.MSG_LOG_NONE
|
||||||
|
|
||||||
|
def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None:
|
||||||
|
if not self.ready or not self.path:
|
||||||
|
return
|
||||||
|
stamp = timestamp or _utc_now()
|
||||||
|
record = "{0} [{1}] {2}\n".format(stamp, level, redact(message))
|
||||||
|
try:
|
||||||
|
with open(self.path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(record)
|
||||||
|
except OSError:
|
||||||
|
# A failing log must never break an operator's session.
|
||||||
|
self.ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Execution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RUN_MODE_RUN = "run"
|
||||||
|
RUN_MODE_CHECK = "check"
|
||||||
|
RUN_MODE_CANCEL = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunResult:
|
||||||
|
argv: List[str] = field(default_factory=list)
|
||||||
|
executed: bool = False
|
||||||
|
dry_run: bool = False
|
||||||
|
check: bool = False
|
||||||
|
cancelled: bool = False
|
||||||
|
rc: Optional[int] = None
|
||||||
|
signal_name: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.executed and self.rc == 0
|
||||||
|
|
||||||
|
|
||||||
|
def command_string(argv: Sequence[str]) -> str:
|
||||||
|
"""A copy/pasteable shell line (like ``printf %q`` in the bash console)."""
|
||||||
|
return " ".join(shlex.quote(part) for part in argv)
|
||||||
|
|
||||||
|
|
||||||
|
def default_spawn(argv: Sequence[str]) -> int:
|
||||||
|
"""Run ARGV with inherited stdio and return its exit code."""
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(list(argv), check=False)
|
||||||
|
return completed.returncode
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 127
|
||||||
|
except PermissionError:
|
||||||
|
return 126
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
class Runner:
|
||||||
|
"""Show, confirm and execute commands; record every decision."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
console,
|
||||||
|
audit: Optional[AuditLog] = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
check_mode: bool = False,
|
||||||
|
spawn: Optional[Callable[[Sequence[str]], int]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.console = console
|
||||||
|
self.audit = audit or AuditLog()
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.check_mode = check_mode
|
||||||
|
self._spawn = spawn or default_spawn
|
||||||
|
|
||||||
|
# -- helpers -----------------------------------------------------------
|
||||||
|
def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str:
|
||||||
|
printable = command_string(argv)
|
||||||
|
self.console.section(title)
|
||||||
|
self.console.write(" " + printable)
|
||||||
|
self.console.write("")
|
||||||
|
return printable
|
||||||
|
|
||||||
|
def _check_flag(self, argv: List[str]) -> List[str]:
|
||||||
|
if "--check" not in argv:
|
||||||
|
return argv + ["--check"]
|
||||||
|
return argv
|
||||||
|
|
||||||
|
def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult:
|
||||||
|
try:
|
||||||
|
rc = self._spawn(argv)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT")
|
||||||
|
return RunResult(argv=list(argv), executed=True, rc=rc)
|
||||||
|
|
||||||
|
# -- public API --------------------------------------------------------
|
||||||
|
def run_readonly(self, argv: Sequence[str], title: str) -> RunResult:
|
||||||
|
"""Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd)."""
|
||||||
|
self.show_command(argv, title=title)
|
||||||
|
if self.dry_run:
|
||||||
|
self.console.warn(config.MSG_DRY_RUN_WARN)
|
||||||
|
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
|
||||||
|
return RunResult(argv=list(argv), dry_run=True)
|
||||||
|
self.audit.write(config.AUDIT_RUN, command_string(argv))
|
||||||
|
result = self._spawn_exit_code(argv)
|
||||||
|
if result.rc == 0:
|
||||||
|
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv))
|
||||||
|
else:
|
||||||
|
self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc))
|
||||||
|
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def confirm_and_run(
|
||||||
|
self,
|
||||||
|
argv: Sequence[str],
|
||||||
|
label: str,
|
||||||
|
destructive: bool = False,
|
||||||
|
check_mode: Optional[bool] = None,
|
||||||
|
) -> RunResult:
|
||||||
|
"""Print the command, ask, then run it (or cancel / dry-run)."""
|
||||||
|
self.show_command(argv)
|
||||||
|
if self.dry_run:
|
||||||
|
self.console.warn(config.MSG_DRY_RUN_WARN)
|
||||||
|
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
|
||||||
|
return RunResult(argv=list(argv), dry_run=True)
|
||||||
|
|
||||||
|
if destructive:
|
||||||
|
self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label))
|
||||||
|
self.console.warn(config.MSG_DESTRUCTIVE_SUB)
|
||||||
|
|
||||||
|
mode = self.console.ask_run(config.MSG_RUN_ASK)
|
||||||
|
if mode == RUN_MODE_RUN:
|
||||||
|
final = list(argv)
|
||||||
|
elif mode == RUN_MODE_CHECK:
|
||||||
|
final = self._check_flag(list(argv))
|
||||||
|
self.console.info(config.MSG_CHECK_MODE)
|
||||||
|
else:
|
||||||
|
self.console.info(config.MSG_CANCELLED_RUN)
|
||||||
|
self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv))
|
||||||
|
return RunResult(argv=list(argv), cancelled=True)
|
||||||
|
|
||||||
|
self.audit.write(config.AUDIT_RUN, command_string(final))
|
||||||
|
result = self._spawn_exit_code(final)
|
||||||
|
result.check = "--check" in final
|
||||||
|
if result.rc == 0:
|
||||||
|
self.console.ok(config.MSG_RUN_OK)
|
||||||
|
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final))
|
||||||
|
else:
|
||||||
|
self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc))
|
||||||
|
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- checks ------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def have_ansible() -> bool:
|
||||||
|
return _which("ansible-playbook") is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def have_python() -> bool:
|
||||||
|
return sys.version_info >= config.MIN_PYTHON
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_root() -> bool:
|
||||||
|
return os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _which(name: str) -> Optional[str]:
|
||||||
|
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
||||||
|
candidate = os.path.join(directory, name)
|
||||||
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
499
bfos/tui.py
Normal file
499
bfos/tui.py
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
"""ANSI terminal interface for the bfos operator console.
|
||||||
|
|
||||||
|
Two presentations, one contract:
|
||||||
|
|
||||||
|
* **simple mode** (default): one screen with three numbered actions —
|
||||||
|
``1) Kur``, ``2) Durum``, ``3) Çıkış`` — plus a single shortcut that opens
|
||||||
|
the detailed mode. A beginner is never shown 21 playbooks, wave tooling or
|
||||||
|
central preparation,
|
||||||
|
* **detailed mode** (``--full`` or the ``d`` shortcut): the five categories of
|
||||||
|
the bash console — Kurulum, Cihaz işlemleri, Merkez hazırlığı, Araçlar,
|
||||||
|
Cihaz bilgisi.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* every screen ends with ``0) Geri`` and ``q) Çıkış``,
|
||||||
|
* every operator message is Turkish, every code identifier stays English,
|
||||||
|
* colours are pure ANSI escapes written by hand (no textual/rich/curses),
|
||||||
|
* input is validated; a wrong answer gets a polite Turkish warning instead of
|
||||||
|
a traceback or a silent default,
|
||||||
|
* rendering is a pure function of the screen description, so the menu
|
||||||
|
contract can be unit-tested without a terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io # noqa: F401 - kept for callers that build an in-memory Console
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
# Values returned by :meth:`Console.ask_run`. They match
|
||||||
|
# bfos.runner.RUN_MODE_* on purpose (asserted by the test suite).
|
||||||
|
RUN = "run"
|
||||||
|
CHECK = "check"
|
||||||
|
CANCEL = "cancel"
|
||||||
|
|
||||||
|
# Outcomes of :func:`interpret_menu_choice`.
|
||||||
|
CHOICE_QUIT = "quit"
|
||||||
|
CHOICE_BACK = "back"
|
||||||
|
CHOICE_ITEM = "item"
|
||||||
|
CHOICE_INVALID = "invalid"
|
||||||
|
|
||||||
|
ENTRY_ITEM = "item"
|
||||||
|
ENTRY_SHORTCUT = "shortcut"
|
||||||
|
ENTRY_SECTION = "section"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def color_enabled(no_color: bool = False, mode: str = "auto", is_tty: bool = True) -> bool:
|
||||||
|
"""Decide whether ANSI colour may be written to the stream."""
|
||||||
|
if no_color:
|
||||||
|
return False
|
||||||
|
if mode == "never":
|
||||||
|
return False
|
||||||
|
if mode == "always":
|
||||||
|
return True
|
||||||
|
return is_tty and not bool(_env("NO_COLOR"))
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
return os.environ.get(name, "")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_is_tty(stream) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(stream.isatty())
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Screen description (pure data, unit-testable)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Entry:
|
||||||
|
"""One line of a screen.
|
||||||
|
|
||||||
|
``kind=ENTRY_ITEM`` is numbered by its position among the items;
|
||||||
|
``kind=ENTRY_SHORTCUT`` carries its own key (``d``) and is rendered next to
|
||||||
|
the footer; ``kind=ENTRY_SECTION`` is a heading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
label: str = ""
|
||||||
|
description: str = ""
|
||||||
|
key: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Screen:
|
||||||
|
title: str
|
||||||
|
subtitle: str = ""
|
||||||
|
entries: Tuple[Entry, ...] = ()
|
||||||
|
notes: Tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def items(self) -> Tuple[Tuple[str, Entry], ...]:
|
||||||
|
"""(key, entry) for every numbered item, key = "1", "2", ... """
|
||||||
|
numbered = [entry for entry in self.entries if entry.kind == ENTRY_ITEM]
|
||||||
|
return tuple((str(index + 1), entry) for index, entry in enumerate(numbered))
|
||||||
|
|
||||||
|
def shortcuts(self) -> Tuple[Entry, ...]:
|
||||||
|
return tuple(entry for entry in self.entries if entry.kind == ENTRY_SHORTCUT)
|
||||||
|
|
||||||
|
def keys(self) -> Tuple[str, ...]:
|
||||||
|
"""Every selectable key: numbered items first, then shortcuts."""
|
||||||
|
return tuple(key for key, _ in self.items()) + tuple(
|
||||||
|
entry.key for entry in self.shortcuts()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def item(label: str, description: str = "") -> Entry:
|
||||||
|
return Entry(ENTRY_ITEM, label=label, description=description)
|
||||||
|
|
||||||
|
|
||||||
|
def shortcut(key: str, label: str, description: str = "") -> Entry:
|
||||||
|
return Entry(ENTRY_SHORTCUT, label=label, description=description, key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def section(label: str) -> Entry:
|
||||||
|
return Entry(ENTRY_SECTION, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The menus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def simple_menu() -> Screen:
|
||||||
|
"""Default view: exactly three basic actions, nothing else.
|
||||||
|
|
||||||
|
The only extra key is the ``d`` shortcut that opens the detailed mode; it is
|
||||||
|
rendered next to the numbered actions as a clearly-marked advanced entry, so
|
||||||
|
a beginner is never dropped into 21 playbooks and wave tooling by accident.
|
||||||
|
"""
|
||||||
|
return Screen(
|
||||||
|
title=config.SIMPLE_MENU_TITLE,
|
||||||
|
subtitle=config.SIMPLE_MENU_SUBTITLE,
|
||||||
|
entries=(
|
||||||
|
item(config.SIMPLE_ITEM_INSTALL_LABEL, config.SIMPLE_ITEM_INSTALL_DESC),
|
||||||
|
item(config.SIMPLE_ITEM_STATUS_LABEL, config.SIMPLE_ITEM_STATUS_DESC),
|
||||||
|
item(config.SIMPLE_ITEM_EXIT_LABEL, config.SIMPLE_ITEM_EXIT_DESC),
|
||||||
|
shortcut("d", config.SIMPLE_ADVANCED_LABEL, config.SIMPLE_ADVANCED_DESC),
|
||||||
|
),
|
||||||
|
notes=(config.MSG_SIMPLE_MODE_HINT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detailed_menu() -> Screen:
|
||||||
|
"""Detailed mode: the five categories of the bash console."""
|
||||||
|
return Screen(
|
||||||
|
title=config.FULL_MENU_TITLE,
|
||||||
|
subtitle=config.FULL_MENU_SUBTITLE,
|
||||||
|
entries=(
|
||||||
|
section("Kategoriler"),
|
||||||
|
item(config.CAT_INSTALL, "Bu cihazı yerel olarak kur ve doğrula"),
|
||||||
|
item(config.CAT_DEVICES, "Filo playbook'ları, her zaman açık hedefle"),
|
||||||
|
item(config.CAT_CENTRAL, "Merkez entegrasyonunu doğrula ve hazırla"),
|
||||||
|
item(config.CAT_TOOLS, "Depo ve yerel yardımcı araçlar"),
|
||||||
|
item(config.CAT_INFO, "Salt okunur bilgi ekranları"),
|
||||||
|
shortcut("s", "Basit mod", "Yalnız Kur / Durum / Çıkış"),
|
||||||
|
),
|
||||||
|
notes=(config.MSG_FULL_MODE_HINT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def install_menu(items: Sequence[Tuple[str, str]]) -> Screen:
|
||||||
|
"""Kurulum category (this device only)."""
|
||||||
|
return Screen(
|
||||||
|
title="{0} — {1}".format(config.CAT_INSTALL, "bu cihaz"),
|
||||||
|
subtitle="Yalnız bu cihaz kurulur; hiçbir filo cihazına dokunulmaz",
|
||||||
|
entries=(section("Yerel kurulum"),) + tuple(item(label, desc) for label, desc in items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def devices_menu(report_count: int, maintenance_count: int) -> Screen:
|
||||||
|
return Screen(
|
||||||
|
title=config.CAT_DEVICES,
|
||||||
|
subtitle="Yalnızca depodaki mevcut playbook'lar, her zaman açık hedefle",
|
||||||
|
entries=(
|
||||||
|
section("Filo işlemleri"),
|
||||||
|
item("Salt okunur raporlar", "{0} işlem, hiçbir cihazı değiştirmez".format(report_count)),
|
||||||
|
item("Bakım işlemleri", "{0} işlem; yıkıcı olanlar iki kez onay ister".format(maintenance_count)),
|
||||||
|
item("Envanter hedeflerini göster", "dalgalar, gruplar ve cihaz sayıları"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def central_menu() -> Screen:
|
||||||
|
return Screen(
|
||||||
|
title=config.CAT_CENTRAL,
|
||||||
|
subtitle="Merkez entegrasyonunu doğrular ve hazırlar; merkez sunucu kurmaz",
|
||||||
|
entries=(
|
||||||
|
section("Hazırlık ve doğrulama"),
|
||||||
|
item("Merkez hazırlık durumu", "envanter, yer tutucular, yönetici anahtarı"),
|
||||||
|
item("Merkez uç noktalarını göster", "WireGuard hub, izleme, paket aynası"),
|
||||||
|
item("Merkez uç noktalarını dene", "DNS + TCP, salt okunur"),
|
||||||
|
item("Dalga geçiş kapısı durumu", "dalga başına cihaz sayısı ve kapı kuralı"),
|
||||||
|
item("Merkez hazırlık listesi", "kayıt öncesi gerekenler"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tools_menu() -> Screen:
|
||||||
|
return Screen(
|
||||||
|
title=config.CAT_TOOLS,
|
||||||
|
subtitle="Yerel yardımcılar; ISO derleme bu konsolun kapsamı dışındadır",
|
||||||
|
entries=(
|
||||||
|
section("Depo"),
|
||||||
|
item("Depoyu güncelle", "git pull --ff-only"),
|
||||||
|
item("Ortam ve sürüm bilgisi", "yollar, sürümler, çözümlenen ayarlar"),
|
||||||
|
item("İşlem kataloğunu göster", "--list çıktısı"),
|
||||||
|
section("Bu cihazın araçları"),
|
||||||
|
item("Yerel çevrimdışı kapı", "bf-check-local"),
|
||||||
|
item("Kayıt (enrollment) kapısı", "bf-check-enrollment"),
|
||||||
|
item("Hazır (READY) kapısı", "bf-check-ready"),
|
||||||
|
item("Derin tanılama", "bf-diagnostics (yoksa bf-status)"),
|
||||||
|
item("Canlı donanım / OS kontrolü", "bf-live-hw-check, salt okunur"),
|
||||||
|
item("Uzak erişim kanalları", "bf-remote-status --check"),
|
||||||
|
item("Field OS sürüm kimliği", "bf-release"),
|
||||||
|
item("Donanım envanteri", "bf-hardware-inventory.sh"),
|
||||||
|
item("Destek paketi", "bf-support-bundle --out-dir"),
|
||||||
|
section("Bu konsol"),
|
||||||
|
item("Konsol denetim kaydını göster", "denetim kaydının son 40 satırı"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def info_menu() -> Screen:
|
||||||
|
return Screen(
|
||||||
|
title=config.CAT_INFO,
|
||||||
|
subtitle="Salt okunur: buradaki hiçbir işlem cihazı değiştirmez",
|
||||||
|
entries=(
|
||||||
|
section("Bu cihaz"),
|
||||||
|
item("Kimlik", "ana makine adı, cihaz kimliği, bayi numarası"),
|
||||||
|
item("Sağlık özeti", "bf-status"),
|
||||||
|
item("Kurulum ve kayıt durumu", "state.json beyaz listesi"),
|
||||||
|
item("Ağ", "adresler, rota, DNS, WireGuard eş sayısı"),
|
||||||
|
item("Disk, bellek, CPU", "df, free, yük"),
|
||||||
|
item("Kurulu sabit paketler", "dpkg-query"),
|
||||||
|
item("Servis durumu", "ssh, xrdp, rustdesk, docker, wg"),
|
||||||
|
item("Son journal hataları", "journalctl -p err"),
|
||||||
|
section("Filo"),
|
||||||
|
item("Envanter özeti", "dalgalar, gruplar, sayılar"),
|
||||||
|
item("Hedefi çözümle", "bayi numarası veya grubu doğrula"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def target_menu() -> Screen:
|
||||||
|
return Screen(
|
||||||
|
title="Hedef seçimi",
|
||||||
|
subtitle="Hedef sorulmadan hiçbir komut üretilmez",
|
||||||
|
entries=(
|
||||||
|
item("Tek cihaz (bayi numarası)", "8 hane, örn. 12010101"),
|
||||||
|
item("Grup / dalga", ", ".join(config.WAVES)),
|
||||||
|
item("Filo geneli", "dalga kapsamlı playbook'lar dalga dalga sorar"),
|
||||||
|
item("Hedefi doğrudan yaz", "bayi numarası, grup ya da 'all'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def yes_no_suffix(default_yes: bool = False) -> str:
|
||||||
|
return "[e/H]" if not default_yes else "[E/h]"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rendering (pure functions)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def horizontal_rule(char: str = "-", width: int = config.CONSOLE_WIDTH) -> str:
|
||||||
|
return char * width
|
||||||
|
|
||||||
|
|
||||||
|
def render_item(key: str, label: str, description: str = "", palette: Optional[config.Palette] = None) -> str:
|
||||||
|
palette = palette or config.Palette(False)
|
||||||
|
head = " {0:>2}) {1:<46}".format(key, label)
|
||||||
|
if description:
|
||||||
|
return head + " " + palette.paint(description, "dim")
|
||||||
|
return head.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def render_screen(screen: Screen, palette: Optional[config.Palette] = None, width: int = config.CONSOLE_WIDTH) -> str:
|
||||||
|
"""The complete text of one screen (banner, items, footer)."""
|
||||||
|
palette = palette or config.Palette(False)
|
||||||
|
lines: List[str] = [""]
|
||||||
|
lines.append(palette.paint(horizontal_rule("=", width), "dim"))
|
||||||
|
lines.append(" " + palette.paint(screen.title, "bold", "cyan"))
|
||||||
|
if screen.subtitle:
|
||||||
|
lines.append(" " + palette.paint(screen.subtitle, "dim"))
|
||||||
|
lines.append(palette.paint(horizontal_rule("=", width), "dim"))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
item_keys = dict((entry, key) for key, entry in screen.items())
|
||||||
|
for entry in screen.entries:
|
||||||
|
if entry.kind == ENTRY_SECTION:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" " + palette.paint(entry.label, "magenta"))
|
||||||
|
elif entry.kind == ENTRY_ITEM:
|
||||||
|
lines.append(render_item(item_keys[entry], entry.label, entry.description, palette))
|
||||||
|
for entry in screen.shortcuts():
|
||||||
|
lines.append(render_item(entry.key, entry.label, entry.description, palette))
|
||||||
|
|
||||||
|
for note in screen.notes:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" " + palette.paint(note, "dim"))
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(palette.paint(horizontal_rule("-", width), "dim"))
|
||||||
|
footer = " {0:>2}) {1:<46} {2:>2}) {3}".format(
|
||||||
|
"0", config.MSG_BACK, "q", config.MSG_QUIT_ITEM
|
||||||
|
)
|
||||||
|
lines.append(footer)
|
||||||
|
lines.append(palette.paint(horizontal_rule("-", width), "dim"))
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_menu_choice(choice: str, keys: Sequence[str]) -> Tuple[str, str]:
|
||||||
|
"""Classify what the operator typed on a menu screen.
|
||||||
|
|
||||||
|
``""``/``0`` -> back, ``q``/``Q`` -> quit, a known key -> item, everything
|
||||||
|
else -> invalid (the caller prints a polite Turkish warning).
|
||||||
|
"""
|
||||||
|
value = (choice or "").strip()
|
||||||
|
if value == "" or value == "0":
|
||||||
|
return CHOICE_BACK, ""
|
||||||
|
if value.lower() == "q":
|
||||||
|
return CHOICE_QUIT, ""
|
||||||
|
for key in keys:
|
||||||
|
if value == key:
|
||||||
|
return CHOICE_ITEM, key
|
||||||
|
return CHOICE_INVALID, value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Console I/O
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class Console:
|
||||||
|
"""Thin ANSI console: writes to a stream, reads lines, validates input.
|
||||||
|
|
||||||
|
``reader`` can be injected (a callable taking the prompt and returning a
|
||||||
|
line, or ``None`` for EOF) which is how the console is tested without a
|
||||||
|
terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
out=None,
|
||||||
|
inp=None,
|
||||||
|
palette: Optional[config.Palette] = None,
|
||||||
|
interactive: Optional[bool] = None,
|
||||||
|
reader: Optional[Callable[[str], Optional[str]]] = None,
|
||||||
|
clear: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.out = out if out is not None else sys.stdout
|
||||||
|
self.inp = inp if inp is not None else sys.stdin
|
||||||
|
self.reader = reader
|
||||||
|
tty = stream_is_tty(self.inp) and stream_is_tty(self.out)
|
||||||
|
self.interactive = tty if interactive is None else interactive
|
||||||
|
self.palette = palette or config.Palette(False)
|
||||||
|
self.clear = clear
|
||||||
|
|
||||||
|
# -- output ------------------------------------------------------------
|
||||||
|
def write(self, text: str = "") -> None:
|
||||||
|
self.out.write(text + "\n")
|
||||||
|
|
||||||
|
def paint(self, text: str, *names: str) -> str:
|
||||||
|
return self.palette.paint(text, *names)
|
||||||
|
|
||||||
|
def section(self, label: str) -> None:
|
||||||
|
self.write("")
|
||||||
|
self.write(" " + self.paint(label, "magenta"))
|
||||||
|
|
||||||
|
def kv(self, key: str, value: str) -> None:
|
||||||
|
self.write(" {0:<24} {1}".format(key, value))
|
||||||
|
|
||||||
|
def note(self, text: str) -> None:
|
||||||
|
self.write(self.paint(text, "dim"))
|
||||||
|
|
||||||
|
def info(self, text: str) -> None:
|
||||||
|
self.write("{0} {1}".format(self.paint("[i]", "cyan"), text))
|
||||||
|
|
||||||
|
def ok(self, text: str) -> None:
|
||||||
|
self.write("{0} {1}".format(self.paint("[OK]", "green"), text))
|
||||||
|
|
||||||
|
def warn(self, text: str) -> None:
|
||||||
|
for line in str(text).splitlines() or [""]:
|
||||||
|
self.write("{0} {1}".format(self.paint("[UYARI]", "yellow"), line))
|
||||||
|
|
||||||
|
def error(self, text: str) -> None:
|
||||||
|
for line in str(text).splitlines() or [""]:
|
||||||
|
self.write("{0} {1}".format(self.paint("[HATA]", "red"), line))
|
||||||
|
|
||||||
|
def banner(self, title: str, subtitle: str = "") -> None:
|
||||||
|
self.write(render_screen(Screen(title=title, subtitle=subtitle), self.palette))
|
||||||
|
|
||||||
|
def screen(self, screen: Screen) -> None:
|
||||||
|
if self.interactive and self.clear:
|
||||||
|
self.out.write("\033[H\033[2J")
|
||||||
|
self.write(render_screen(screen, self.palette))
|
||||||
|
|
||||||
|
def show_lines(self, lines: Iterable[str]) -> None:
|
||||||
|
for line in lines:
|
||||||
|
self.write(line)
|
||||||
|
|
||||||
|
def exception(self, refused) -> None:
|
||||||
|
"""Report a :class:`bfos.operations.Refused` nicely."""
|
||||||
|
self.error(refused.message)
|
||||||
|
if getattr(refused, "detail", ""):
|
||||||
|
for line in refused.detail.splitlines():
|
||||||
|
self.note(" " + line)
|
||||||
|
|
||||||
|
# -- input -------------------------------------------------------------
|
||||||
|
def read(self, prompt: str = "") -> Optional[str]:
|
||||||
|
"""Read one line; ``None`` means EOF (stdin closed / non-interactive)."""
|
||||||
|
if self.reader is not None:
|
||||||
|
self.out.write(prompt)
|
||||||
|
return self.reader(prompt)
|
||||||
|
self.out.write(prompt)
|
||||||
|
self.out.flush()
|
||||||
|
try:
|
||||||
|
line = self.inp.readline()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
if line == "":
|
||||||
|
return None
|
||||||
|
return line.rstrip("\n")
|
||||||
|
|
||||||
|
def ask(self, prompt: str, allow_empty: bool = False) -> Optional[str]:
|
||||||
|
"""Read a non-empty answer; re-asks politely on an empty line."""
|
||||||
|
while True:
|
||||||
|
value = self.read(prompt)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if value or allow_empty:
|
||||||
|
return value
|
||||||
|
self.warn(config.MSG_REQUIRED)
|
||||||
|
|
||||||
|
def ask_with_default(self, prompt: str, default: str) -> Optional[str]:
|
||||||
|
value = self.read(prompt)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
return value or default
|
||||||
|
|
||||||
|
def confirm(self, question: str, default_yes: bool = False) -> Optional[bool]:
|
||||||
|
"""Turkish yes/no question. Only an explicit yes proceeds."""
|
||||||
|
while True:
|
||||||
|
value = self.read("{0} {1}: ".format(question, yes_no_suffix(default_yes)))
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
answer = value.strip().lower()
|
||||||
|
if answer in ("e", "evet", "y", "yes"):
|
||||||
|
return True
|
||||||
|
if answer in ("h", "hayir", "hayır", "n", "no"):
|
||||||
|
return False
|
||||||
|
if answer == "":
|
||||||
|
return default_yes
|
||||||
|
self.warn(config.MSG_INVALID_INPUT.format(value=value))
|
||||||
|
|
||||||
|
def confirm_token(self, message: str, token: str, prompt: str) -> Optional[bool]:
|
||||||
|
"""The operator must type TOKEN exactly (highest-risk confirmations)."""
|
||||||
|
if message:
|
||||||
|
self.warn(message)
|
||||||
|
value = self.read(prompt)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() == token
|
||||||
|
|
||||||
|
def ask_run(self, question: str = config.MSG_RUN_ASK) -> str:
|
||||||
|
"""y = run, c = run with --check, anything else = cancel."""
|
||||||
|
value = self.read(question)
|
||||||
|
if value is None:
|
||||||
|
return CANCEL
|
||||||
|
answer = value.strip().lower()
|
||||||
|
if answer in ("y", "yes", "e", "evet"):
|
||||||
|
return RUN
|
||||||
|
if answer in ("c", "check"):
|
||||||
|
return CHECK
|
||||||
|
return CANCEL
|
||||||
|
|
||||||
|
def pause(self, message: str = config.MSG_PRESS_ENTER) -> None:
|
||||||
|
if self.interactive:
|
||||||
|
self.read(self.paint(message + " ", "dim"))
|
||||||
|
|
||||||
|
def ask_menu_choice(self, screen: Screen) -> Tuple[str, str]:
|
||||||
|
"""Read and classify a menu answer, warning on invalid input."""
|
||||||
|
value = self.read(config.MSG_PROMPT_CHOICE)
|
||||||
|
if value is None:
|
||||||
|
return CHOICE_QUIT, ""
|
||||||
|
outcome, payload = interpret_menu_choice(value, screen.keys())
|
||||||
|
if outcome == CHOICE_INVALID:
|
||||||
|
self.warn(config.MSG_UNKNOWN_CHOICE.format(choice=payload))
|
||||||
|
self.write(" " + config.MSG_MENU_RANGE.format(max=len(screen.items())))
|
||||||
|
return CHOICE_INVALID, payload
|
||||||
|
return outcome, payload
|
||||||
3029
bfos/ui_gui.py
Normal file
3029
bfos/ui_gui.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,73 +1,367 @@
|
||||||
# Offline APT repository
|
# Offline APT deposu (paketler git ile taşınır)
|
||||||
|
|
||||||
Reviewed `.deb` files are not stored in this repository. Supply them locally on the LAB build machine; the tooling never fetches a package, never resolves a dependency from a remote source, and never signs anything.
|
Saha cihazı internetsiz kuruluyor. Bu dizin, kurulumda kullanılacak Ubuntu
|
||||||
|
paketlerini ve bunların APT indeksini **bu git deposunun içinde** taşır.
|
||||||
|
|
||||||
## Current state (read this first)
|
İki kural:
|
||||||
|
|
||||||
- **This repository contains no packages and no built repository.** `packages/` holds only `.gitkeep`, and there is no `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv` or `SHA256SUMS` anywhere in the tree.
|
1. **Paketleri repo sorumlusu toplar** ve bu dizine commit eder (Ubuntu makinesi gerekir).
|
||||||
- `manifests/*.txt` is the **package contract**. Every line is `package=version`. The package names are final and complete; the version field still reads the sentinel `UNPINNED` because no package has been acquired or reviewed yet.
|
2. **Kuran kişi hiçbir şey indirmez.** Cihazdaki kurucu yalnızca git'ten gelen
|
||||||
- The versions must be filled on the LAB build machine from the reviewed `.deb` files. Until then everything fails closed: `--check` and a real build refuse a pin whose version is still `UNPINNED`, and the offline installer refuses a bundle whose lock does not satisfy the manifests.
|
yerel depoyu okur; internet, apt kaynağı, anahtar indirme, `apt download`
|
||||||
- Nothing here was built on a workstation: `apt-ftparchive` and `dpkg-deb` exist only on the Ubuntu 26.04 build host, so no bundle was produced and no `.deb` was downloaded. `--dry-run-plan` is the only mode that runs anywhere.
|
gibi hiçbir yol kullanılmaz.
|
||||||
|
|
||||||
## Contract files
|
## Roller
|
||||||
|
|
||||||
| File | Role |
|
| Rol | Kimdir | Ne yapar |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `manifests/base-packages.txt` | Base system pins: tooling, SSH, WireGuard, SMART, firewall, node exporter (installer modules 02, 06, 07, 12, 13) |
|
| Repo sorumlusu | bu depoyu yöneten kişi | Ubuntu 26.04 makinesinde paketleri toplar, doğrular, indeksi üretir, commit eder |
|
||||||
| `manifests/remote-access.txt` | Remote access and desktop pins: xRDP, GNOME session, minimal desktop, RustDesk (modules 08, 09, 16) |
|
| Kuran kişi | sahada cihazı kuran teknisyen | ISO/seed ile kurar, `blueforce-install.sh --offline` çalıştırır; paket aramaz, indirmez |
|
||||||
| `manifests/docker.txt` | Docker CE pins (module 10) |
|
|
||||||
| `manifests/requirements.tsv` | Audit table: which manifest must pin which package, and which installer module or diagnostic needs it |
|
|
||||||
| `manifests/packages.lock.schema.tsv` | Documented layout of the `packages.lock.tsv` SBOM that a build writes into the bundle |
|
|
||||||
|
|
||||||
`scripts/install/blueforce-install.sh --offline` reads **every** `manifests/*.txt` as a pin manifest: an empty or comment-only manifest, an invalid pin, or a pin that is missing from the bundle stops the installer before any package module runs. Adding a `*.txt` file to `manifests/` is therefore a change to that contract, not a local convenience — `manifests/requirements.tsv` must be updated in the same edit.
|
## Dosya haritası
|
||||||
|
|
||||||
## Modes
|
| Dosya / dizin | Ne işe yarar | Kim çalıştırır |
|
||||||
|
|---|---|---|
|
||||||
|
| `packages/` | Doğrulanmış `.deb` dosyaları (kaynak havuz) | repo sorumlusu (`add-packages.sh` ile doldurur) |
|
||||||
|
| `add-packages.sh` | `.deb` doğrular, `packages/` altına ekler, Türkçe rapor verir | repo sorumlusu (Ubuntu) |
|
||||||
|
| `build-index.sh` | `packages/`tan offline indeksi üretir (`built/`), `repo-manifest.yaml` yazar | repo sorumlusu (Ubuntu) |
|
||||||
|
| `built/` | Git'e giren hazır depo: `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS` | üretilir |
|
||||||
|
| `repo-manifest.yaml` | Deponun künyesi: sürüm, mimari, paket listesi, boyut, tarih | `build-index.sh` üretir |
|
||||||
|
| `build-offline-repo.sh` | Sözleşme doğrulayıcı (`--dry-run-plan`) ve alternatif derleyici | repo sorumlusu / CI |
|
||||||
|
| `manifests/*.txt` | Pin listesi: `paket=sürüm`. Cihazda **ne olması gerektiği** | repo sorumlusu doldurur |
|
||||||
|
| `manifests/requirements.tsv` | Hangi paket neden gerekli (denetim tablosu) | insan okur |
|
||||||
|
| `manifests/packages.lock.schema.tsv` | `packages.lock.tsv` biçiminin tanımı | insan okur |
|
||||||
|
| `packages/.gitkeep` | Boş dizinin git'te durması için yer tutucu | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Repo sorumlusu: sıfırdan adım adım
|
||||||
|
|
||||||
|
## 1.0 Gereksinimler
|
||||||
|
|
||||||
|
* Ubuntu 26.04 LTS makinesi, mimari **amd64**, internet erişimi var (paketleri
|
||||||
|
toplamak için). Docker CE ve RustDesk paketleri Ubuntu arşivinde değildir;
|
||||||
|
aşağıda ayrı anlatılıyor.
|
||||||
|
* Şu iki araç kurulu olmalı:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y dpkg apt-utils
|
||||||
|
dpkg-deb --version # okunur: .deb künyesini okur
|
||||||
|
apt-ftparchive --version # okunur: APT indeksini üretir
|
||||||
|
```
|
||||||
|
|
||||||
|
CachyOS/Arch gibi bir makinede `apt-ftparchive` ve `dpkg-deb` yoktur; orada
|
||||||
|
paket toplanamaz ve indeks üretilemez. Toplama işi Ubuntu makinesinde yapılır,
|
||||||
|
sonuç bu depoya commit edilir.
|
||||||
|
|
||||||
|
## 1.1 Paketleri indir
|
||||||
|
|
||||||
|
Çalışma dizini açın:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Static contract validation: no apt-ftparchive, no dpkg-deb, no pool, no network.
|
mkdir -p ~/bf-debs && cd ~/bf-debs
|
||||||
./build-offline-repo.sh --dry-run-plan
|
|
||||||
|
|
||||||
# LAB only: verify that the supplied pool satisfies every pin.
|
|
||||||
./build-offline-repo.sh --packages /secure/reviewed-debs --output /tmp/blueforce-apt --check
|
|
||||||
|
|
||||||
# LAB only: build the bundle.
|
|
||||||
./build-offline-repo.sh --packages /secure/reviewed-debs --output /srv/blueforce-apt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--dry-run-plan` validates the pin format, duplicate and whitespace errors, placeholder leakage, the audit table, the SBOM schema, and the presence of every required package, then prints the missing versions and missing packages as explicit lists. It exits non-zero while anything is unfilled, and it needs no build tooling, so it runs on any machine. It never writes to the manifests.
|
Hangi sürümün "aday" olduğunu görün (pini bu sürümle dolduracaksınız):
|
||||||
|
|
||||||
`--check` and a real build require `apt-ftparchive` and `dpkg-deb`; without them the tooling refuses and names the missing tool. A real build also refuses to overwrite an existing output directory.
|
```bash
|
||||||
|
apt-cache policy openssh-server | head -n 3
|
||||||
|
```
|
||||||
|
|
||||||
## LAB build machine requirements
|
Paketleri **ve bağımlılıklarını** indirin (`--download-only` hiçbir şey kurmaz):
|
||||||
|
|
||||||
1. Ubuntu 26.04 LTS build host, the release named in every manifest, `amd64`.
|
```bash
|
||||||
2. `apt-ftparchive` (package `apt-utils`) and `dpkg-deb` (package `dpkg`) installed.
|
sudo apt-get install --download-only --reinstall -y \
|
||||||
3. A connected acquisition host to obtain and review the `.deb` files for that release, including the packages that are **not** in the Ubuntu archive: `rustdesk` (vendor build) and the Docker CE set (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`).
|
curl wget ca-certificates gnupg lsb-release apt-transport-https \
|
||||||
4. A review step that records exactly what was accepted: package, version, architecture, SHA256 and source file name, in the layout of `manifests/packages.lock.schema.tsv`.
|
openssh-server wireguard-tools smartmontools ufw iptables \
|
||||||
|
prometheus-node-exporter xrdp xorgxrdp gnome-session ubuntu-desktop-minimal
|
||||||
|
|
||||||
### Fill procedure
|
cp /var/cache/apt/archives/*.deb ~/bf-debs/
|
||||||
|
rm -f ~/bf-debs/*partial*
|
||||||
|
```
|
||||||
|
|
||||||
1. Put the reviewed `.deb` files in one directory, for example `/secure/reviewed-debs`.
|
`ubuntu-desktop-minimal` çok sayıda bağımlılık getirir; bu normaldir ve offline
|
||||||
2. For each manifest line, read the exact version with `dpkg-deb -f <file>.deb Package Version` and replace `UNPINNED` with it. One pin per package across all manifests.
|
kurulum için gereklidir.
|
||||||
3. Run `./build-offline-repo.sh --dry-run-plan` until it prints `PLAN READY` (`missing_packages=0` and `errors=0`).
|
|
||||||
4. Run `--check` against the reviewed pool, then build into a fresh output directory.
|
|
||||||
5. Archive the bundle, the ISO manifest and the reviewed pool together, since the bundle is the only proof of what a device installed.
|
|
||||||
|
|
||||||
## What a build produces
|
**Docker CE** (Ubuntu arşivinde yok, Docker deposundan gelir):
|
||||||
|
|
||||||
`<output>/pool/*.deb`, `Packages`, `Packages.gz`, `packages.lock.tsv` (package, version, architecture, sha256, source) and `SHA256SUMS` covering the indexes and the lock file.
|
```bash
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||||
|
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/docker.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --download-only --reinstall -y \
|
||||||
|
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
cp /var/cache/apt/archives/*.deb ~/bf-debs/
|
||||||
|
```
|
||||||
|
|
||||||
## Air-gapped target
|
`containerd.io` Docker'ın kendi çalışma zamanıdır; Ubuntu'nun `containerd`
|
||||||
|
paketi ile karıştırmayın.
|
||||||
|
|
||||||
- The bundle is copied to the target device as `/opt/blueforce/offline-repo`, the default of `BF_OFFLINE_REPO`.
|
**RustDesk** (Ubuntu arşivinde yok, üretici `.deb` dosyası):
|
||||||
- `scripts/install/blueforce-install.sh --offline` requires that directory to exist with `Packages`, `Packages.gz`, `packages.lock.tsv` and `SHA256SUMS`, runs `sha256sum -c SHA256SUMS`, verifies the lock layout, and checks every manifest pin against a lock row and a pool file.
|
|
||||||
- Only then does it write `<STATE_DIR>/offline.list` containing exactly `deb [trusted=yes] file:/opt/blueforce/offline-repo ./` and export an isolated APT configuration that points at that file. No remote source, key download, `curl`, `wget` or `apt download` is used.
|
|
||||||
- An offline installation ends in `PROVISIONED_OFFLINE`. The device is not `READY` until enrollment and central channel verification have succeeded.
|
|
||||||
|
|
||||||
## LAB checklist
|
* Projenin sürüm sayfasından amd64 paketini indirin:
|
||||||
|
`https://github.com/rustdesk/rustdesk/releases` → `rustdesk-<sürüm>-x86_64.deb`
|
||||||
|
* Dosyayı `~/bf-debs/` içine kopyalayın. Kullanılacak sürüm, incelenmiş
|
||||||
|
(self-hosted sunucuya bağlanan) istemci sürümü olmalıdır.
|
||||||
|
|
||||||
- [ ] Every `manifests/*.txt` pin carries a real version from a reviewed `.deb`; `--dry-run-plan` prints `PLAN READY`.
|
## 1.2 Paketleri depoya ekle
|
||||||
- [ ] `manifests/requirements.tsv` still matches the manifests (the plan fails on drift in either direction).
|
|
||||||
- [ ] `--check` passes against the reviewed pool and the built bundle verifies with `sha256sum -c SHA256SUMS`.
|
```bash
|
||||||
- [ ] The reviewed pool and the bundle are archived together with the ISO release manifest.
|
cd provisioning/offline-repo
|
||||||
- [ ] `tests/check-offline-repo-static.sh` passes in CI.
|
|
||||||
|
# Ön izleme: hiçbir dosya kopyalanmaz, ne olacağını söyler
|
||||||
|
./add-packages.sh --from ~/bf-debs --check
|
||||||
|
|
||||||
|
# Gerçek ekleme (pinlenen + bağımlılıklar)
|
||||||
|
./add-packages.sh --from ~/bf-debs
|
||||||
|
```
|
||||||
|
|
||||||
|
Yalnız manifestte pinlenen paketleri eklemek isterseniz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./add-packages.sh --from ~/bf-debs --manifest manifests/base-packages.txt
|
||||||
|
# bağımlılıkları da eklemek için sonuna --include-all ekleyin
|
||||||
|
```
|
||||||
|
|
||||||
|
Araç her dosyayı denetler ve Türkçe yazar:
|
||||||
|
|
||||||
|
* `EKLENDİ:` → eklendi,
|
||||||
|
* `ATLANDI: aynı dosya zaten repoda` → tekrar eklemeye çalıştınız (zararsız),
|
||||||
|
* `HATA: mimari uyumsuz` → paket `amd64`/`all` değil; o dosyayı kullanmayın,
|
||||||
|
* `HATA: çakışma: <paket> repoda <sürüm> ile duruyor` → aynı paketin ikinci bir
|
||||||
|
sürümü. Manifestler tek sürüm pinler, bu yüzden otomatik üzerine yazılmaz;
|
||||||
|
ekranda yazan `rm` komutu ile eski dosyayı kaldırıp yeniden ekleyin,
|
||||||
|
* `ATLANDI: manifestte istenen sürüm farklı` → manifestteki pin başka bir sürümü
|
||||||
|
istiyor; pini güncelleyin ya da doğru sürümün `.deb` dosyasını indirin.
|
||||||
|
|
||||||
|
Sonda `MANIFEST GÜNCELLEMESİ GEREKİYOR` listesi çıkar: bu araç manifest
|
||||||
|
dosyalarını **değiştirmez**, yalnızca yazmanız gereken satırı söyler.
|
||||||
|
|
||||||
|
## 1.3 Manifest pinlerini doldur
|
||||||
|
|
||||||
|
`manifests/base-packages.txt`, `remote-access.txt`, `docker.txt` içindeki her
|
||||||
|
satır şu biçimdedir:
|
||||||
|
|
||||||
|
```text
|
||||||
|
openssh-server=UNPINNED
|
||||||
|
```
|
||||||
|
|
||||||
|
`UNPINNED` bir yer tutucudur, sürüm değildir. `add-packages.sh` çıktısındaki
|
||||||
|
listeyi kullanarak gerçek sürümü yazın:
|
||||||
|
|
||||||
|
```text
|
||||||
|
openssh-server=9.6p1-3ubuntu13
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurallar: küçük harfli paket adı, `=` çevresinde boşluk yok, satır sonuna not
|
||||||
|
yazılmaz, bir paket yalnızca bir manifestte pinlenir. Yeni paket ekler/çıkarırsanız
|
||||||
|
`manifests/requirements.tsv` tablosunu **aynı işlemde** güncelleyin.
|
||||||
|
|
||||||
|
Kontrol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-offline-repo.sh --dry-run-plan # PLAN READY görmelisiniz
|
||||||
|
```
|
||||||
|
|
||||||
|
`PLAN READY` görünene kadar derleme yapmayın. Bir sürümden emin değilseniz
|
||||||
|
elinizdeki dosyadan okuyun:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg-deb -f ~/bf-debs/openssh-server_*.deb Package Version Architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1.4 İndeksi üret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-index.sh --check # yalnızca doğrular, hiçbir şey yazmaz
|
||||||
|
./build-index.sh # built/ altına indeksi yazar
|
||||||
|
```
|
||||||
|
|
||||||
|
Çıktı:
|
||||||
|
|
||||||
|
```text
|
||||||
|
built/pool/*.deb paketlerin kopyası
|
||||||
|
built/Packages APT indeksi (apt-ftparchive)
|
||||||
|
built/Packages.gz sıkıştırılmış indeks (zaman damgasız)
|
||||||
|
built/packages.lock.tsv SBOM: package/version/architecture/sha256/source
|
||||||
|
built/SHA256SUMS indeks dosyalarının özetleri
|
||||||
|
repo-manifest.yaml deponun künyesi (güncellenir)
|
||||||
|
```
|
||||||
|
|
||||||
|
Araç fail-closed çalışır:
|
||||||
|
|
||||||
|
* manifestteki bir pin repoda yoksa **hiçbir şey yazmaz** ve eksikleri liste
|
||||||
|
hâlinde söyler (`manifestlerde istenen şu paketler eksik:`),
|
||||||
|
* bir pin hâlâ `UNPINNED` ise yine yazmaz; önce manifesti doldurun,
|
||||||
|
* aynı havuzla tekrar çalıştırıldığında indeks birebir aynı çıkar; içerik
|
||||||
|
değişmediyse `DEĞİŞİKLİK YOK` yazar ve dizini yeniden yazmaz.
|
||||||
|
|
||||||
|
## 1.5 Git'e ekle ve commit et
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Paketler ve manifestler:
|
||||||
|
git add provisioning/offline-repo/packages provisioning/offline-repo/manifests
|
||||||
|
|
||||||
|
# İndeks: .gitignore, Packages / Packages.gz / packages.lock.tsv adlarını her
|
||||||
|
# dizinde yok sayar (eski LAB akışından kalma kural), bu yüzden -f gerekir:
|
||||||
|
git add -f provisioning/offline-repo/built
|
||||||
|
|
||||||
|
git add provisioning/offline-repo/repo-manifest.yaml
|
||||||
|
git commit -m "offline repo: <paket sayısı> paket, offline APT indeksi"
|
||||||
|
```
|
||||||
|
|
||||||
|
`build-index.sh` çalıştığınızda bu komutları ekrana da yazar (kopyala-yapıştır).
|
||||||
|
`.gitignore` dosyasının sahibi isterse bu üç ad için daha sonra istisna kuralı
|
||||||
|
ekleyebilir; o zaman `-f` gereksizleşir.
|
||||||
|
|
||||||
|
## 1.6 Bitirmeden önce kontrol
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tests/check-offline-repo-static.sh
|
||||||
|
./build-offline-repo.sh --dry-run-plan
|
||||||
|
cd built && sha256sum -c SHA256SUMS # OK görmelisiniz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Kuran kişi neden hiç indirme yapmaz
|
||||||
|
|
||||||
|
* Saha cihazında internet yok (ya da güvenilmez). Bu yüzden kurulumun ihtiyaç
|
||||||
|
duyduğu her şey **medyanın ve git deposunun içinde** taşınır.
|
||||||
|
* Kurucu `--offline` ile çalıştığında tek kaynak şudur:
|
||||||
|
`/opt/blueforce/offline-repo` dizinine konan yerel depo
|
||||||
|
(`deb [trusted=yes] file:/opt/blueforce/offline-repo ./`). Uzak APT kaynağı,
|
||||||
|
anahtar indirme, `curl`, `wget`, `apt download` kullanılmaz.
|
||||||
|
* Bu dizin (git deposundaki `built/`) ISO ile hedefe taşınır ve kurucu şu
|
||||||
|
dosyaları arar ve doğrular: `Packages`, `Packages.gz`, `packages.lock.tsv`,
|
||||||
|
`SHA256SUMS` ve `pool/` içinde manifestlerdeki pinlere karşılık gelen `.deb`
|
||||||
|
dosyaları.
|
||||||
|
* Kurucu kurulum sonunda cihazı `PROVISIONED_OFFLINE` durumuna getirir. Cihaz,
|
||||||
|
bir kerelik enrollment ve merkezi kanal doğrulaması yapılmadan `READY` olmaz.
|
||||||
|
* Sonuç: kuran kişinin yaptığı tek şey `git`/ISO'dan gelen dosyaları yerine
|
||||||
|
koymak ve kurucuyu çalıştırmaktır. Hangi sürümün kurulduğu
|
||||||
|
`packages.lock.tsv` ve `repo-manifest.yaml` ile kanıtlanır.
|
||||||
|
|
||||||
|
Kuran kişinin komutu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/install/blueforce-install.sh --offline --dealer-id <8 haneli numara>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Eksik paket olursa ne olur? (`OFFLINE BLOCKED`)
|
||||||
|
|
||||||
|
Cihazdaki kurucu, paket modüllerinden **önce** yerel depoyu doğrular. Bir şey
|
||||||
|
eksik ya da bozuksa kurulum durur ve ekrana `OFFLINE BLOCKED` ile başlayan bir
|
||||||
|
satır yazar. `OFFLINE BLOCKED` demek: "cihazda kurulum yapılmadı, depo
|
||||||
|
tamamlanmadı, sorumluya haber verin".
|
||||||
|
|
||||||
|
| Ekranda ne yazar (kısaltılmış) | Ne anlama gelir | Sorumlu tarafında çözüm |
|
||||||
|
|---|---|---|
|
||||||
|
| `BF_OFFLINE_REPO must be an existing absolute local directory` | Depo dizini hiç yok: medya ya da kopyalama eksik | `built/` içeriğini `/opt/blueforce/offline-repo` altına koyun |
|
||||||
|
| `local APT index, lock, or checksums are missing; ISO bundle is incomplete` | `Packages`, `Packages.gz`, `packages.lock.tsv` veya `SHA256SUMS` yok. Genelde indeks üretilmeden commit edilmiştir | `./build-index.sh` çalıştırıp `built/` dizinini commit edin |
|
||||||
|
| `local APT repository checksum validation failed` | Dosyalar elle değişmiş ya da kopyalama bozulmuş (`sha256sum -c SHA256SUMS` hatası) | Depoyu git'ten yeniden alın; indeksi yeniden üretin |
|
||||||
|
| `package lock is empty or invalid` | `packages.lock.tsv` başlığı/kolonları bozuk ya da dosya boş | `./build-index.sh` ile yeniden üretin, elle düzenlemeyin |
|
||||||
|
| `pinned package absent from bundle: <paket>` | Manifestteki sürüm depoda yok: pin ile paket eşleşmiyor | `add-packages.sh` ile doğru sürümü ekleyip pini güncelleyin |
|
||||||
|
|
||||||
|
Yani "eksik paket" sahada değil, **commit öncesinde** çözülür:
|
||||||
|
`./build-index.sh` eksikleri listeler ve yazmadan durur. Sahada `OFFLINE BLOCKED`
|
||||||
|
görürseniz ISO, bilgi eksik bir depoyla üretilmiş demektir; çözüm depoyu düzeltip
|
||||||
|
yeni ISO üretmektir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Hangi paketler gerekli ve neden?
|
||||||
|
|
||||||
|
Liste `manifests/requirements.tsv` dosyasının okunabilir hâlidir; kaynak
|
||||||
|
doğrudur. Her satır bir `paket=sürüm` pinidir ve kurucu bu pinlerin **tamamını**
|
||||||
|
depoda arar; biri eksikse `OFFLINE BLOCKED` ile durur.
|
||||||
|
|
||||||
|
## Temel araçlar — `manifests/base-packages.txt`
|
||||||
|
|
||||||
|
| Paket | Neden gerekli | Nerede kullanılır |
|
||||||
|
|---|---|---|
|
||||||
|
| `curl` | HTTPS aktarımı yapan temel araç | `02-system.sh` |
|
||||||
|
| `wget` | Saha runbook'larının beklediği dosya çekme aracı | `02-system.sh` |
|
||||||
|
| `ca-certificates` | TLS doğrulaması için kök sertifika deposu | `02-system.sh` |
|
||||||
|
| `gnupg` | İncelenmiş dosyaların imza işlemleri | `02-system.sh` |
|
||||||
|
| `lsb-release` | Dağıtım sürümünü tanıma | `02-system.sh` |
|
||||||
|
| `apt-transport-https` | apt 2.0+ üzerinde geçiş (dummy) paket; referans derlemeyle uyum için tutuluyor | `02-system.sh` |
|
||||||
|
| `openssh-server` | Yönetilen SSH erişim kanalı | `07-ssh.sh` |
|
||||||
|
| `wireguard-tools` | `wg` komutu (VPN tüneli) | `06-wireguard.sh` |
|
||||||
|
| `smartmontools` | Disk sağlığı kanıtı (SMART) | `scripts/diagnostics/bf-diagnostics` |
|
||||||
|
| `ufw` | Güvenlik duvarı temeli | `13-firewall.sh` |
|
||||||
|
| `iptables` | Güvenlik duvarı kural arka ucu | `13-firewall.sh` |
|
||||||
|
| `prometheus-node-exporter` | Cihaz metrikleri (textfile collector paketin içinde) | `12-monitoring.sh` |
|
||||||
|
|
||||||
|
## Uzak erişim ve masaüstü — `manifests/remote-access.txt`
|
||||||
|
|
||||||
|
| Paket | Neden gerekli | Nerede kullanılır |
|
||||||
|
|---|---|---|
|
||||||
|
| `xrdp` | RDP erişim kanalı | `08-rdp.sh` |
|
||||||
|
| `xorgxrdp` | xRDP için X11 arka ucu (bağımlılık kapatması LAB'da doğrulanır) | `08-rdp.sh` |
|
||||||
|
| `gnome-session` | xRDP oturumunda başlatılan masaüstü oturumu | `08-rdp.sh` |
|
||||||
|
| `ubuntu-desktop-minimal` | En küçük masaüstü yığını | `16-gui.sh` |
|
||||||
|
| `rustdesk` | Uzak masaüstü istemcisi. **Ubuntu arşivinde yoktur**: üreticinin incelenmiş `.deb` dosyası | `09-rustdesk.sh` |
|
||||||
|
|
||||||
|
## Docker CE — `manifests/docker.txt`
|
||||||
|
|
||||||
|
| Paket | Neden gerekli | Nerede kullanılır |
|
||||||
|
|---|---|---|
|
||||||
|
| `docker-ce` | Konteyner motoru | `10-docker.sh` |
|
||||||
|
| `docker-ce-cli` | `docker` komutu | `10-docker.sh` |
|
||||||
|
| `containerd.io` | Docker CE ile gelen çalışma zamanı (Ubuntu'nun `containerd` paketi değil) | `10-docker.sh` |
|
||||||
|
| `docker-buildx-plugin` | buildx eklentisi | `10-docker.sh` |
|
||||||
|
| `docker-compose-plugin` | Compose v2 eklentisi | `10-docker.sh` |
|
||||||
|
|
||||||
|
Notlar:
|
||||||
|
|
||||||
|
* Pinler sürüm doldurulmadan `UNPINNED` görünür ve bu hâlde derleme yapılmaz.
|
||||||
|
* Bu 22 paketin dışındaki paketler (bağımlılıklar) da depoda bulunur; ancak
|
||||||
|
yalnız **pinlenen 22 paket** sözleşmedir.
|
||||||
|
* Çekirdek modülleri gibi `.deb` olarak taşınmayan dosyalar bu deponun kapsamı
|
||||||
|
dışındadır.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. `repo-manifest.yaml` ve `built/` nedir?
|
||||||
|
|
||||||
|
* `repo-manifest.yaml` = deponun künyesi (kimlik kartı): hedef Ubuntu sürümü,
|
||||||
|
mimari, kaç paket, toplam boyut, üretim tarihi ve her paketin adı/sürümü/
|
||||||
|
sha256'sı. ISO release manifesti ve denetim kaydı ile birlikte saklanır.
|
||||||
|
* `built/` = cihaza gidecek depo. Hedef cihazda `/opt/blueforce/offline-repo`
|
||||||
|
olur; içinde `pool/`, `Packages`, `Packages.gz`, `packages.lock.tsv`,
|
||||||
|
`SHA256SUMS` bulunur. Kurucu bu dosyaları `sha256sum -c SHA256SUMS` ile ve
|
||||||
|
manifestteki her pini `pool/` içindeki bir dosyayla eşleştirerek doğrular.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Sık karşılaşılan durumlar
|
||||||
|
|
||||||
|
| Belirti | Neden / çözüm |
|
||||||
|
|---|---|
|
||||||
|
| `apt-ftparchive bulunamadı` | Ubuntu makinesinde kurun: `sudo apt-get install -y apt-utils` |
|
||||||
|
| `dpkg-deb bulunamadı` | `sudo apt-get install -y dpkg` (Arch/CachyOS'ta yoktur; orada derleme yapılamaz) |
|
||||||
|
| `git add` indeks dosyalarını almıyor | `.gitignore` bu adları yok sayıyor; `git add -f provisioning/offline-repo/built` kullanın |
|
||||||
|
| `HATA: çakışma` | Aynı paketin ikinci sürümü eklenmeye çalışıldı. Manifestler tek sürüm pinler; ekrandaki `rm` ile eskiyi kaldırın |
|
||||||
|
| `mimari uyumsuz` | Yalnız `amd64` ve `all` kabul edilir; `arm64`/`i386` paketi eklemeyin |
|
||||||
|
| `manifestlerde istenen şu paketler eksik` | Paketi indirip `add-packages.sh` ile ekleyin; pin hâlâ `UNPINNED` ise sürümü yazın |
|
||||||
|
| ISO betiği depoyu bulamıyor | `provisioning/iso/build-blueforce-iso.sh` şu an `provisioning/offline-repo/build` adını arıyor; `built/` kullanacaksanız `./build-index.sh --output build` ile de üretin ya da ISO betiğinin otomatik bulma yolunu güncelleyin |
|
||||||
|
| Depo boyutu beklenenden büyük | `packages/` ve `built/pool/` aynı `.deb` dosyalarını iki kez tutar (git içeriği bir kez saklar, çalışma kopyasında iki dosya durur). Kasıtlıdır: biri kaynak, diğeri teslim depo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Commit öncesi kontrol listesi
|
||||||
|
|
||||||
|
- [ ] `./add-packages.sh --from <dizin> --check` temiz geçti.
|
||||||
|
- [ ] `manifests/*.txt` içinde `UNPINNED` kalmadı.
|
||||||
|
- [ ] `./build-offline-repo.sh --dry-run-plan` → `PLAN READY`.
|
||||||
|
- [ ] `./build-index.sh` eksiksiz geçti, eksik paket listesi boş.
|
||||||
|
- [ ] `cd built && sha256sum -c SHA256SUMS` → `OK`.
|
||||||
|
- [ ] `repo-manifest.yaml` güncel (paket sayısı ve tarih yazılmış).
|
||||||
|
- [ ] `bash tests/check-offline-repo-static.sh` geçti.
|
||||||
|
- [ ] Commit: paketler, manifestler, `built/` (`-f` ile) ve `repo-manifest.yaml`.
|
||||||
|
|
|
||||||
365
provisioning/offline-repo/add-packages.sh
Executable file
365
provisioning/offline-repo/add-packages.sh
Executable file
|
|
@ -0,0 +1,365 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Offline APT repository intake.
|
||||||
|
#
|
||||||
|
# Validate reviewed Debian packages and copy them into
|
||||||
|
# provisioning/offline-repo/packages/ so that they can be committed to git. The
|
||||||
|
# repository owner runs this on an Ubuntu machine; the device that later
|
||||||
|
# installs from the bundle never downloads anything.
|
||||||
|
#
|
||||||
|
# Guarantees:
|
||||||
|
# * nothing is downloaded and nothing is resolved from a remote source,
|
||||||
|
# * a file that is not a readable Debian binary package is refused,
|
||||||
|
# * a package for a foreign architecture is refused (the target is amd64,
|
||||||
|
# packages marked "all" are accepted),
|
||||||
|
# * a second version of a package that is already accepted is refused instead
|
||||||
|
# of silently overwritten: manifests/*.txt pin exactly one version,
|
||||||
|
# * manifests/*.txt is never edited here. For every pin that still reads the
|
||||||
|
# UNPINNED sentinel the exact replacement line is printed for the owner.
|
||||||
|
#
|
||||||
|
# --check performs the same validation without copying anything.
|
||||||
|
#
|
||||||
|
# User-facing output is Turkish; code and comments are English.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PACKAGES_DIR="$SCRIPT_DIR/packages"
|
||||||
|
TARGET_ARCH='amd64'
|
||||||
|
ALLOWED_ARCH_RE='^(amd64|all)$'
|
||||||
|
PACKAGE_NAME_RE='^[a-z0-9][a-z0-9+.-]*$'
|
||||||
|
PIN_RE='^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$'
|
||||||
|
UNPINNED='UNPINNED'
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Kullanım: add-packages.sh [seçenekler] <dosya.deb> [<dosya.deb> ...]
|
||||||
|
add-packages.sh --from DIZIN [--manifest DOSYA]
|
||||||
|
|
||||||
|
İncelenmiş .deb dosyalarını provisioning/offline-repo/packages/ altına ekler.
|
||||||
|
Hiçbir indirme yapmaz: dosyaları siz verirsiniz, bu araç yalnızca doğrular.
|
||||||
|
|
||||||
|
Seçenekler:
|
||||||
|
--from DIZIN DIZIN içindeki tüm *.deb dosyalarını aday olarak alır
|
||||||
|
--manifest DOSYA Yalnızca bu manifestteki pinlere uyan adayları ekler
|
||||||
|
(birden fazla --manifest verilebilir)
|
||||||
|
--include-all --manifest ile birlikte: pin dışı adayları da ekler
|
||||||
|
(bağımlılık paketleri için gerekir)
|
||||||
|
--packages DIZIN Hedef dizin (varsayılan: <script>/packages)
|
||||||
|
--check Ön izleme: hiçbir dosyayı kopyalamaz, yalnızca raporlar
|
||||||
|
-h, --help Bu yardımı yazar
|
||||||
|
|
||||||
|
Çıkış kodları: 0 başarılı, 1 doğrulama hatası, 2 kullanım hatası.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
FROM_DIR=''
|
||||||
|
INCLUDE_ALL=0
|
||||||
|
declare -a MANIFEST_ARGS=() CANDIDATES=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--from)
|
||||||
|
[[ -n "${2:-}" ]] || { printf 'HATA: --from için dizin adı verilmedi.\n' >&2; exit 2; }
|
||||||
|
FROM_DIR="$2"; shift 2 ;;
|
||||||
|
--manifest)
|
||||||
|
[[ -n "${2:-}" ]] || { printf 'HATA: --manifest için dosya adı verilmedi.\n' >&2; exit 2; }
|
||||||
|
MANIFEST_ARGS+=("$2"); shift 2 ;;
|
||||||
|
--packages)
|
||||||
|
[[ -n "${2:-}" ]] || { printf 'HATA: --packages için dizin adı verilmedi.\n' >&2; exit 2; }
|
||||||
|
PACKAGES_DIR="$2"; shift 2 ;;
|
||||||
|
--include-all) INCLUDE_ALL=1; shift ;;
|
||||||
|
--check) CHECK_ONLY=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*) printf 'HATA: bilinmeyen seçenek: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||||
|
*) CANDIDATES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$FROM_DIR" ]]; then
|
||||||
|
[[ -d "$FROM_DIR" ]] || { printf 'HATA: dizin bulunamadı: %s\n' "$FROM_DIR" >&2; exit 2; }
|
||||||
|
while IFS= read -r -d '' deb; do CANDIDATES+=("$deb"); done \
|
||||||
|
< <(find "$FROM_DIR" -maxdepth 1 -type f -name '*.deb' -print0 | sort -z)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#CANDIDATES[@]} -eq 0 ]]; then
|
||||||
|
printf 'HATA: eklenecek .deb dosyası verilmedi.\n' >&2
|
||||||
|
printf ' Örnek: ./add-packages.sh --from /secure/reviewed-debs\n' >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
for manifest in "${MANIFEST_ARGS[@]}"; do
|
||||||
|
[[ -f "$manifest" ]] || { printf 'HATA: manifest dosyası bulunamadı: %s\n' "$manifest" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# dpkg-deb is the only way to read a Debian control file; it exists on
|
||||||
|
# Ubuntu/Debian only, which is why this tool runs on the repository owner's
|
||||||
|
# machine and never on an Arch/CachyOS workstation.
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
HATA: dpkg-deb bulunamadı; .deb dosyaları bu araçla okunur.
|
||||||
|
Bu araç yalnızca Ubuntu/Debian makinelerinde çalışır. Ubuntu 26.04
|
||||||
|
makinesinde şunu kurun:
|
||||||
|
sudo apt-get install -y dpkg
|
||||||
|
CachyOS/Arch gibi bir makinede paketleri TOPLAYAMAZSINIZ; toplama işi
|
||||||
|
Ubuntu makinesinde yapılır, depo dosyaları git ile taşınır.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
human_size() {
|
||||||
|
awk -v b="$1" 'BEGIN {
|
||||||
|
split("B KiB MiB GiB TiB", unit, " ")
|
||||||
|
i = 1
|
||||||
|
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
||||||
|
if (i == 1) printf "%d %s\n", b, unit[i]; else printf "%.1f %s\n", b, unit[i]
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
file_sha256() { sha256sum "$1" | awk '{print $1}'; }
|
||||||
|
file_size() { stat -c %s "$1" 2>/dev/null || wc -c < "$1"; }
|
||||||
|
|
||||||
|
# Read the essential control fields of one candidate. Sets DEB_PKG, DEB_VER,
|
||||||
|
# DEB_ARCH, DEB_SHA, DEB_SIZE and returns non-zero with a Turkish diagnostic.
|
||||||
|
inspect_deb() {
|
||||||
|
local file="$1" raw
|
||||||
|
DEB_PKG=''; DEB_VER=''; DEB_ARCH=''; DEB_SHA=''; DEB_SIZE=0
|
||||||
|
|
||||||
|
if [[ ! -e "$file" ]]; then
|
||||||
|
printf 'HATA: dosya bulunamadı: %s\n' "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
printf 'HATA: bir dosya değil: %s\n' "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$file" != *.deb ]]; then
|
||||||
|
printf 'HATA: dosya adı .deb ile bitmiyor; Debian paketi değil: %s\n' "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$file" ]]; then
|
||||||
|
printf 'HATA: dosya boş (0 bayt): %s\n' "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! raw="$(dpkg-deb -f "$file" Package Version Architecture 2>&1)"; then
|
||||||
|
printf 'HATA: geçerli bir Debian paketi okunamadı: %s\n' "$file" >&2
|
||||||
|
printf ' dpkg-deb çıktısı: %s\n' "$raw" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
DEB_PKG="$(printf '%s\n' "$raw" | sed -n '1p')"
|
||||||
|
DEB_VER="$(printf '%s\n' "$raw" | sed -n '2p')"
|
||||||
|
DEB_ARCH="$(printf '%s\n' "$raw" | sed -n '3p')"
|
||||||
|
|
||||||
|
if [[ ! "$DEB_PKG" =~ $PACKAGE_NAME_RE ]]; then
|
||||||
|
printf 'HATA: paket adı geçersiz: "%s" (%s)\n' "$DEB_PKG" "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$DEB_VER" || "$DEB_VER" == "$UNPINNED" || "$DEB_VER" == *[[:space:]]* ]]; then
|
||||||
|
printf 'HATA: paket sürümü geçersiz: "%s" (%s)\n' "$DEB_VER" "$file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$DEB_ARCH" =~ $ALLOWED_ARCH_RE ]]; then
|
||||||
|
printf 'HATA: mimari uyumsuz: %s paketi "%s" için; hedef mimari %s.\n' \
|
||||||
|
"$DEB_PKG" "$DEB_ARCH" "$TARGET_ARCH" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
DEB_SHA="$(file_sha256 "$file")"
|
||||||
|
DEB_SIZE="$(file_size "$file")"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A EXISTING_SHA=() EXISTING_PKG=() EXISTING_PKG_SHA=() EXISTING_FILE=()
|
||||||
|
|
||||||
|
scan_existing() {
|
||||||
|
local deb sha pkg ver
|
||||||
|
[[ -d "$PACKAGES_DIR" ]] || return 0
|
||||||
|
while IFS= read -r -d '' deb; do
|
||||||
|
sha="$(file_sha256 "$deb")"
|
||||||
|
pkg="$(dpkg-deb -f "$deb" Package 2>/dev/null || true)"
|
||||||
|
ver="$(dpkg-deb -f "$deb" Version 2>/dev/null || true)"
|
||||||
|
if [[ -z "$pkg" || -z "$ver" ]]; then
|
||||||
|
printf 'UYARI: packages/ içindeki okunamayan dosya yok sayıldı: %s\n' "${deb##*/}" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
EXISTING_SHA["$sha"]="$deb"
|
||||||
|
EXISTING_PKG["$pkg"]="$ver"
|
||||||
|
EXISTING_PKG_SHA["$pkg"]="$sha"
|
||||||
|
EXISTING_FILE["$pkg"]="${deb##*/}"
|
||||||
|
done < <(find "$PACKAGES_DIR" -maxdepth 1 -type f -name '*.deb' -print0 | sort -z)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pin table of the manifests given with --manifest. Keys are package names.
|
||||||
|
declare -A PIN_VERSION=() PIN_MANIFEST=() PIN_SUPPLIED=()
|
||||||
|
|
||||||
|
load_pins() {
|
||||||
|
local manifest line lineno pkg ver bad=0
|
||||||
|
for manifest in "${MANIFEST_ARGS[@]}"; do
|
||||||
|
lineno=0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
if [[ -z "$line" || "$line" == \#* ]]; then continue; fi
|
||||||
|
if [[ ! "$line" =~ $PIN_RE ]]; then
|
||||||
|
printf 'UYARI: %s:%d: pin biçimi geçersiz, yok sayıldı: %s\n' \
|
||||||
|
"$(basename "$manifest")" "$lineno" "$line" >&2
|
||||||
|
bad=$((bad + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
pkg="${line%%=*}"
|
||||||
|
ver="${line#*=}"
|
||||||
|
if [[ -n "${PIN_VERSION[$pkg]:-}" ]]; then
|
||||||
|
printf 'UYARI: %s paketi birden fazla manifestte pinli: %s ve %s\n' \
|
||||||
|
"$pkg" "${PIN_MANIFEST[$pkg]}" "$(basename "$manifest")" >&2
|
||||||
|
fi
|
||||||
|
PIN_VERSION["$pkg"]="$ver"
|
||||||
|
PIN_MANIFEST["$pkg"]="$(basename "$manifest")"
|
||||||
|
done < "$manifest"
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDED=0; WILL_ADD=0; SKIPPED=0; ERRORS=0; UNMATCHED=0; OFF_PIN=0
|
||||||
|
declare -a FILL_LINES=() UNSUPPLIED=() OFF_PIN_NAMES=()
|
||||||
|
|
||||||
|
scan_existing
|
||||||
|
if [[ ${#MANIFEST_ARGS[@]} -gt 0 ]]; then load_pins; fi
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||||
|
printf '=== Ön izleme (--check): hiçbir dosya kopyalanmaz ===\n'
|
||||||
|
else
|
||||||
|
printf '=== Paket ekleme: %s ===\n' "${PACKAGES_DIR#$SCRIPT_DIR/}"
|
||||||
|
fi
|
||||||
|
printf 'Aday sayısı: %d\n\n' "${#CANDIDATES[@]}"
|
||||||
|
|
||||||
|
for file in "${CANDIDATES[@]}"; do
|
||||||
|
base="${file##*/}"
|
||||||
|
if ! inspect_deb "$file"; then
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Identical content already accepted, under any file name.
|
||||||
|
if [[ -n "${EXISTING_SHA[$DEB_SHA]:-}" ]]; then
|
||||||
|
printf 'ATLANDI: aynı dosya zaten repoda: %s (%s)\n' \
|
||||||
|
"$base" "$(basename "${EXISTING_SHA[$DEB_SHA]}")"
|
||||||
|
SKIPPED=$((SKIPPED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One version per package: manifests/*.txt carry a single pin per package.
|
||||||
|
if [[ -n "${EXISTING_PKG[$DEB_PKG]:-}" ]]; then
|
||||||
|
if [[ "${EXISTING_PKG[$DEB_PKG]}" == "$DEB_VER" ]]; then
|
||||||
|
printf 'HATA: %s %s sürümü repoda farklı bir dosyayla duruyor: %s\n' \
|
||||||
|
"$DEB_PKG" "$DEB_VER" "${EXISTING_FILE[$DEB_PKG]}"
|
||||||
|
printf ' Aynı ad ve sürüm iki farklı içerikle kabul edilmez. Repodaki dosyayı inceleyin.\n'
|
||||||
|
else
|
||||||
|
printf 'HATA: çakışma: %s repoda %s sürümüyle duruyor (%s).\n' \
|
||||||
|
"$DEB_PKG" "${EXISTING_PKG[$DEB_PKG]}" "${EXISTING_FILE[$DEB_PKG]}"
|
||||||
|
printf ' Tek sürüm kuralı: yeni sürümü eklemek için önce eskiyi kaldırın:\n'
|
||||||
|
printf ' rm "%s"\n' "$PACKAGES_DIR/${EXISTING_FILE[$DEB_PKG]}"
|
||||||
|
fi
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#MANIFEST_ARGS[@]} -gt 0 ]]; then
|
||||||
|
if [[ -z "${PIN_VERSION[$DEB_PKG]:-}" ]]; then
|
||||||
|
if [[ "$INCLUDE_ALL" -eq 1 ]]; then
|
||||||
|
printf 'BİLGİ: pin dışı paket (bağımlılık) eklenecek: %s %s\n' "$DEB_PKG" "$DEB_VER"
|
||||||
|
else
|
||||||
|
printf 'ATLANDI: %s manifestte pinli değil (%s).\n' "$base" "$DEB_PKG"
|
||||||
|
OFF_PIN_NAMES+=("$DEB_PKG $DEB_VER")
|
||||||
|
OFF_PIN=$((OFF_PIN + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
elif [[ "${PIN_VERSION[$DEB_PKG]}" == "$UNPINNED" ]]; then
|
||||||
|
PIN_SUPPLIED["$DEB_PKG"]=1
|
||||||
|
FILL_LINES+=("${PIN_MANIFEST[$DEB_PKG]}: $DEB_PKG=$DEB_VER")
|
||||||
|
elif [[ "${PIN_VERSION[$DEB_PKG]}" != "$DEB_VER" ]]; then
|
||||||
|
printf 'ATLANDI: %s için manifestte istenen sürüm farklı: %s (manifest: %s, dosya: %s)\n' \
|
||||||
|
"$DEB_PKG" "${PIN_VERSION[$DEB_PKG]}" "$DEB_VER" "$base"
|
||||||
|
UNMATCHED=$((UNMATCHED + 1))
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
PIN_SUPPLIED["$DEB_PKG"]=1
|
||||||
|
printf 'PİN EŞLEŞTİ: %s=%s (%s)\n' "$DEB_PKG" "$DEB_VER" "${PIN_MANIFEST[$DEB_PKG]}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
dest="$PACKAGES_DIR/$base"
|
||||||
|
if [[ -e "$dest" ]] && [[ "$(file_sha256 "$dest")" != "$DEB_SHA" ]]; then
|
||||||
|
printf 'HATA: hedef dosya farklı içerikle var: %s (elle müdahale gerekli)\n' "$dest"
|
||||||
|
ERRORS=$((ERRORS + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||||
|
printf 'EKLENECEK: %s %s (%s, %s) <- %s\n' \
|
||||||
|
"$DEB_PKG" "$DEB_VER" "$DEB_ARCH" "$(human_size "$DEB_SIZE")" "$file"
|
||||||
|
WILL_ADD=$((WILL_ADD + 1))
|
||||||
|
else
|
||||||
|
mkdir -p "$PACKAGES_DIR"
|
||||||
|
install -m 0644 -- "$file" "$dest"
|
||||||
|
printf 'EKLENDİ: %s %s (%s, %s) -> packages/%s\n' \
|
||||||
|
"$DEB_PKG" "$DEB_VER" "$DEB_ARCH" "$(human_size "$DEB_SIZE")" "$base"
|
||||||
|
ADDED=$((ADDED + 1))
|
||||||
|
EXISTING_SHA["$DEB_SHA"]="$dest"
|
||||||
|
EXISTING_PKG["$DEB_PKG"]="$DEB_VER"
|
||||||
|
EXISTING_PKG_SHA["$DEB_PKG"]="$DEB_SHA"
|
||||||
|
EXISTING_FILE["$DEB_PKG"]="$base"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Pins that this run received no .deb for: reported, never silently ignored.
|
||||||
|
if [[ ${#MANIFEST_ARGS[@]} -gt 0 ]]; then
|
||||||
|
for pkg in "${!PIN_VERSION[@]}"; do
|
||||||
|
ver="${PIN_VERSION[$pkg]}"
|
||||||
|
if [[ -n "${PIN_SUPPLIED[$pkg]:-}" ]]; then continue; fi
|
||||||
|
if [[ "$ver" != "$UNPINNED" && "${EXISTING_PKG[$pkg]:-}" == "$ver" ]]; then continue; fi
|
||||||
|
if [[ "$ver" == "$UNPINNED" && -n "${EXISTING_PKG[$pkg]:-}" ]]; then
|
||||||
|
FILL_LINES+=("${PIN_MANIFEST[$pkg]}: $pkg=${EXISTING_PKG[$pkg]} (repodaki sürüm)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
UNSUPPLIED+=("${PIN_MANIFEST[$pkg]}: $pkg=$ver")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
if [[ ${#UNSUPPLIED[@]} -gt 0 ]]; then
|
||||||
|
printf 'UYARI: şu manifest pinleri için .deb verilmedi:\n'
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${UNSUPPLIED[@]}" | sort)
|
||||||
|
printf ' (Kalanları da ekleyin; build-index.sh eksik listesini tekrar gösterecek.)\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#OFF_PIN_NAMES[@]} -gt 0 ]]; then
|
||||||
|
printf 'BİLGİ: manifestte pini olmayan adaylar eklenmedi (%d):\n' "$OFF_PIN"
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${OFF_PIN_NAMES[@]}" | sort)
|
||||||
|
printf ' Bağımlılık paketleriyse şununla çalıştırın: ./add-packages.sh --from <dizin> --manifest <dosya> --include-all\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#FILL_LINES[@]} -gt 0 ]]; then
|
||||||
|
printf 'MANIFEST GÜNCELLEMESİ GEREKİYOR (bu araç manifestleri değiştirmez):\n'
|
||||||
|
printf ' Aşağıdaki satırlarda %s yerine gerçek sürümü yazın:\n' "$UNPINNED"
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${FILL_LINES[@]}" | sort -u)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||||
|
printf '\nÖZET (ön izleme): eklenecek=%d atlanan=%d eşleşmeyen=%d hata=%d\n' \
|
||||||
|
"$WILL_ADD" "$SKIPPED" "$UNMATCHED" "$ERRORS"
|
||||||
|
printf 'Hiçbir dosya kopyalanmadı.\n'
|
||||||
|
else
|
||||||
|
printf '\nÖZET: eklenen=%d atlanan=%d eşleşmeyen=%d hata=%d\n' \
|
||||||
|
"$ADDED" "$SKIPPED" "$UNMATCHED" "$ERRORS"
|
||||||
|
if [[ "$ADDED" -gt 0 ]]; then
|
||||||
|
printf 'Sonraki adım:\n'
|
||||||
|
printf ' 1) manifests/*.txt içindeki pinleri güncelleyin (yukarıdaki liste).\n'
|
||||||
|
printf ' 2) ./build-index.sh ile offline indeksi üretin.\n'
|
||||||
|
printf ' 3) git add provisioning/offline-repo/packages provisioning/offline-repo/manifests\n'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ERRORS" -gt 0 ]]; then
|
||||||
|
printf 'SONUÇ: %d hata nedeniyle paket ekleme tamamlanmadı.\n' "$ERRORS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
518
provisioning/offline-repo/build-index.sh
Executable file
518
provisioning/offline-repo/build-index.sh
Executable file
|
|
@ -0,0 +1,518 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the offline APT repository bundle that is committed to git.
|
||||||
|
#
|
||||||
|
# Input : <packages dir>/*.deb reviewed packages accepted by add-packages.sh
|
||||||
|
# Output: <output dir>/pool/*.deb copy of every package
|
||||||
|
# <output dir>/Packages APT index of the pool (apt-ftparchive)
|
||||||
|
# <output dir>/Packages.gz gzip of the index, timestamp free
|
||||||
|
# <output dir>/packages.lock.tsv SBOM: package/version/architecture/sha256/source
|
||||||
|
# <output dir>/SHA256SUMS digests of the three index files
|
||||||
|
# <repo>/repo-manifest.yaml repository record (release, arch, sizes, packages)
|
||||||
|
#
|
||||||
|
# The bundle layout is exactly what scripts/install/blueforce-install.sh --offline
|
||||||
|
# validates in /opt/blueforce/offline-repo, so the device that installs from it
|
||||||
|
# never needs a package source of its own.
|
||||||
|
#
|
||||||
|
# Guarantees:
|
||||||
|
# * nothing is downloaded and no dependency is resolved from a remote source,
|
||||||
|
# * every manifest pin must be satisfied by a pool package with the exact
|
||||||
|
# version, otherwise no bundle is written and the gaps are listed in Turkish,
|
||||||
|
# * the index is written in a canonical order and gzip carries no timestamp, so
|
||||||
|
# re-running over the same pool leaves the bundle byte-identical (the bundle
|
||||||
|
# is committed, so a stable rebuild keeps git diffs empty),
|
||||||
|
# * an existing output directory is only replaced when it holds a previous
|
||||||
|
# bundle of ours and no foreign file,
|
||||||
|
# * the result is verified with `sha256sum -c SHA256SUMS` before it is reported.
|
||||||
|
#
|
||||||
|
# User-facing output is Turkish; code and comments are English.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
PACKAGES_DIR="$SCRIPT_DIR/packages"
|
||||||
|
OUTPUT="$SCRIPT_DIR/built"
|
||||||
|
# BF_MANIFEST_DIR points a test run at a fixture directory; a real build always
|
||||||
|
# reads the reviewed manifests next to this script.
|
||||||
|
MANIFEST_DIR="${BF_MANIFEST_DIR:-$SCRIPT_DIR/manifests}"
|
||||||
|
MANIFEST_OUT="$SCRIPT_DIR/repo-manifest.yaml"
|
||||||
|
TARGET_ARCH='amd64'
|
||||||
|
ALLOWED_ARCH_RE='^(amd64|all)$'
|
||||||
|
PACKAGE_NAME_RE='^[a-z0-9][a-z0-9+.-]*$'
|
||||||
|
PIN_RE='^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$'
|
||||||
|
PIN_PLACEHOLDER_RE='(placeholder|replace|todo|tbd|fixme|changeme|fillme|example)'
|
||||||
|
LOCK_HEADER="$(printf 'package\tversion\tarchitecture\tsha256\tsource')"
|
||||||
|
UNPINNED='UNPINNED'
|
||||||
|
SCHEMA_VERSION=1
|
||||||
|
# Entries an output directory may hold without being treated as foreign.
|
||||||
|
ALLOWED_ENTRIES='pool Packages Packages.gz packages.lock.tsv SHA256SUMS .gitkeep'
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Kullanım: build-index.sh [seçenekler]
|
||||||
|
|
||||||
|
packages/ altındaki .deb dosyalarından git'e girecek offline APT deposunu üretir.
|
||||||
|
|
||||||
|
Seçenekler:
|
||||||
|
--packages DIZIN Kaynak paket dizini (varsayılan: <script>/packages)
|
||||||
|
--output DIZIN Çıktı dizini (varsayılan: <script>/built)
|
||||||
|
--manifest-dir DIZIN Pin manifestlerinin dizini (varsayılan: <script>/manifests)
|
||||||
|
--manifest-out DOSYA repo-manifest.yaml yolu (varsayılan: <script>/repo-manifest.yaml)
|
||||||
|
--check Yalnızca doğrular: hiçbir şey yazmaz, eksikleri listeler
|
||||||
|
-h, --help Bu yardımı yazar
|
||||||
|
|
||||||
|
Çıkış kodları: 0 başarılı, 1 eksik paket ya da araç, 2 kullanım hatası.
|
||||||
|
|
||||||
|
Bu araç hiçbir şey indirmez. apt-ftparchive (paket: apt-utils) ve dpkg-deb
|
||||||
|
(paket: dpkg) Ubuntu makinesinde kurulu olmalıdır.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
CHECK_ONLY=0
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--packages) [[ -n "${2:-}" ]] || { printf 'HATA: --packages için dizin verilmedi.\n' >&2; exit 2; }; PACKAGES_DIR="$2"; shift 2 ;;
|
||||||
|
--output) [[ -n "${2:-}" ]] || { printf 'HATA: --output için dizin verilmedi.\n' >&2; exit 2; }; OUTPUT="$2"; shift 2 ;;
|
||||||
|
--manifest-dir) [[ -n "${2:-}" ]] || { printf 'HATA: --manifest-dir için dizin verilmedi.\n' >&2; exit 2; }; MANIFEST_DIR="$2"; shift 2 ;;
|
||||||
|
--manifest-out) [[ -n "${2:-}" ]] || { printf 'HATA: --manifest-out için dosya verilmedi.\n' >&2; exit 2; }; MANIFEST_OUT="$2"; shift 2 ;;
|
||||||
|
--check) CHECK_ONLY=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) printf 'HATA: bilinmeyen seçenek: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
human_size() {
|
||||||
|
awk -v b="$1" 'BEGIN {
|
||||||
|
split("B KiB MiB GiB TiB", unit, " ")
|
||||||
|
i = 1
|
||||||
|
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
||||||
|
if (i == 1) printf "%d %s\n", b, unit[i]; else printf "%.1f %s\n", b, unit[i]
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
file_sha256() { sha256sum "$1" | awk '{print $1}'; }
|
||||||
|
file_size() { stat -c %s "$1" 2>/dev/null || wc -c < "$1"; }
|
||||||
|
yq() { local v="$1"; printf "'%s'" "${v//\'/\'\'}"; }
|
||||||
|
# Paths in the report are printed relative to the repository root, so the printed
|
||||||
|
# git commands work no matter which directory the build was started from.
|
||||||
|
rel_to_repo() {
|
||||||
|
local path="$1"
|
||||||
|
if [[ "$path" == "$REPO_ROOT/"* ]]; then printf '%s' "${path#"$REPO_ROOT"/}"; else printf '%s' "$path"; fi
|
||||||
|
}
|
||||||
|
now_utc() {
|
||||||
|
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
|
||||||
|
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
else
|
||||||
|
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Canonical stanza order: Package, then Version, then the order apt-ftparchive
|
||||||
|
# produced (kept as a tie breaker for two builds of one version). Paragraph
|
||||||
|
# newlines travel through one line of `sort` escaped as 0x1e and are restored
|
||||||
|
# afterwards, so no external tool has to understand Packages paragraphs.
|
||||||
|
canonicalize_stanzas() {
|
||||||
|
awk '
|
||||||
|
BEGIN { RS = ""; FS = "\n" }
|
||||||
|
{
|
||||||
|
pkg = ""; ver = ""
|
||||||
|
for (i = 1; i <= NF; i++) {
|
||||||
|
if ($i ~ /^Package: /) pkg = substr($i, 10)
|
||||||
|
else if ($i ~ /^Version: /) ver = substr($i, 10)
|
||||||
|
}
|
||||||
|
body = $0
|
||||||
|
gsub(/\n/, "\036", body)
|
||||||
|
printf "%s\t%s\t%06d\t%s\n", pkg, ver, ++n, body
|
||||||
|
}
|
||||||
|
' | LC_ALL=C sort -t $'\t' -k1,1 -k2,2 -k3,3 | awk -F '\t' '
|
||||||
|
{
|
||||||
|
body = $4
|
||||||
|
gsub(/\036/, "\n", body)
|
||||||
|
if (NR > 1) printf "\n"
|
||||||
|
printf "%s\n", body
|
||||||
|
}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
HATA: dpkg-deb bulunamadı; .deb dosyalarının künyesi bu araçla okunur.
|
||||||
|
Ubuntu 26.04 makinesinde şunu kurun: sudo apt-get install -y dpkg
|
||||||
|
CachyOS/Arch gibi bir makinede apt aracı yoktur; indeks Ubuntu
|
||||||
|
makinesinde üretilir, sonuç git ile taşınır.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$CHECK_ONLY" -eq 0 ]] && ! command -v apt-ftparchive >/dev/null 2>&1; then
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
HATA: apt-ftparchive bulunamadı; APT indeksi bu araçla üretilir.
|
||||||
|
Ubuntu 26.04 makinesinde kurulum:
|
||||||
|
sudo apt-get install -y apt-utils
|
||||||
|
Doğrulama yapmak istiyorsanız ve indeks üretmeyecekseniz:
|
||||||
|
./build-index.sh --check
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -d "$PACKAGES_DIR" ]] || { printf 'HATA: paket dizini yok: %s\n' "$PACKAGES_DIR" >&2; exit 2; }
|
||||||
|
[[ "$OUTPUT" = /* ]] || OUTPUT="$PWD/$OUTPUT"
|
||||||
|
[[ "$OUTPUT" != '/' && "$OUTPUT" != "$HOME" && "$OUTPUT" != "$SCRIPT_DIR" && "$OUTPUT" != "$SCRIPT_DIR/packages" && "$OUTPUT" != "$SCRIPT_DIR/manifests" ]] \
|
||||||
|
|| { printf 'HATA: --output güvenli olmayan bir dizini gösteriyor: %s\n' "$OUTPUT" >&2; exit 2; }
|
||||||
|
|
||||||
|
printf '=== Blueforce offline APT deposu derlemesi ===\n'
|
||||||
|
printf 'Paket dizini : %s\n' "$PACKAGES_DIR"
|
||||||
|
printf 'Manifestler : %s\n' "$MANIFEST_DIR"
|
||||||
|
printf 'Çıktı dizini : %s\n\n' "$OUTPUT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- manifest contract
|
||||||
|
declare -A PIN_VERSION=() PIN_MANIFEST=()
|
||||||
|
declare -a PIN_ORDER=() MANIFEST_NAMES=() PIN_ERRORS=() AWAITING=()
|
||||||
|
declare -a RELEASES=() DECLARED_ARCHS=()
|
||||||
|
PIN_TOTAL=0
|
||||||
|
|
||||||
|
if [[ ! -d "$MANIFEST_DIR" ]]; then
|
||||||
|
printf 'HATA: manifest dizini yok: %s\n' "$MANIFEST_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r -d '' manifest; do
|
||||||
|
name="$(basename "$manifest")"
|
||||||
|
MANIFEST_NAMES+=("$name")
|
||||||
|
if [[ ! -s "$manifest" ]]; then
|
||||||
|
PIN_ERRORS+=("$name: manifest boş; offline kurucu boş manifest ile başlamaz")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Header contract: every manifest names its release and architecture.
|
||||||
|
release_line="$(sed -n 's/^# *Target release *: *//p' "$manifest" | head -n 1)"
|
||||||
|
if [[ -n "$release_line" ]]; then
|
||||||
|
RELEASES+=("$(printf '%s' "$release_line" | grep -oE '[0-9]+\.[0-9]+' | head -n 1)")
|
||||||
|
declared_arch="$(printf '%s' "$release_line" | grep -oE 'amd64|arm64|armhf|i386' | head -n 1)"
|
||||||
|
if [[ -n "$declared_arch" ]]; then
|
||||||
|
DECLARED_ARCHS+=("$declared_arch")
|
||||||
|
if [[ "$declared_arch" != "$TARGET_ARCH" ]]; then
|
||||||
|
PIN_ERRORS+=("$name: hedef mimari $declared_arch; bu depo yalnızca $TARGET_ARCH üretir")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
PIN_ERRORS+=("$name: '# Target release :' satırı yok; hedef Ubuntu sürümü belirsiz")
|
||||||
|
fi
|
||||||
|
|
||||||
|
lineno=0
|
||||||
|
pins_in_file=0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
if [[ -z "$line" || "$line" == \#* ]]; then continue; fi
|
||||||
|
if [[ ! "$line" =~ $PIN_RE ]]; then
|
||||||
|
PIN_ERRORS+=("$name:$lineno: tam pin değil (package=version beklenir): $line")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${line,,}" =~ $PIN_PLACEHOLDER_RE ]]; then
|
||||||
|
PIN_ERRORS+=("$name:$lineno: pin içinde yer tutucu var: $line")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
package="${line%%=*}"
|
||||||
|
version="${line#*=}"
|
||||||
|
PIN_TOTAL=$((PIN_TOTAL + 1))
|
||||||
|
pins_in_file=$((pins_in_file + 1))
|
||||||
|
if [[ -n "${PIN_VERSION[$package]:-}" ]]; then
|
||||||
|
PIN_ERRORS+=("$name:$lineno: $package iki kez pinlenmiş (ilki: ${PIN_MANIFEST[$package]})")
|
||||||
|
else
|
||||||
|
PIN_VERSION["$package"]="$version"
|
||||||
|
PIN_MANIFEST["$package"]="$name"
|
||||||
|
PIN_ORDER+=("$package")
|
||||||
|
fi
|
||||||
|
if [[ "${version^^}" == "$UNPINNED" || ! "$version" =~ [0-9] ]]; then
|
||||||
|
AWAITING+=("$name: $package=$version")
|
||||||
|
fi
|
||||||
|
done < "$manifest"
|
||||||
|
if [[ "$pins_in_file" -eq 0 ]]; then
|
||||||
|
PIN_ERRORS+=("$name: hiç paket pini yok")
|
||||||
|
fi
|
||||||
|
done < <(find "$MANIFEST_DIR" -maxdepth 1 -type f -name '*.txt' -print0 | sort -z)
|
||||||
|
|
||||||
|
if [[ ${#PIN_ORDER[@]} -eq 0 ]]; then
|
||||||
|
printf 'HATA: hiç pin okunamadı (%s içinde *.txt yok).\n' "$MANIFEST_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unique_releases="$(printf '%s\n' "${RELEASES[@]:-}" | grep -v '^$' | sort -u | tr '\n' ' ')"
|
||||||
|
release_count="$(printf '%s\n' "${RELEASES[@]:-}" | grep -v '^$' | sort -u | grep -c . || true)"
|
||||||
|
if [[ "$release_count" -gt 1 ]]; then
|
||||||
|
PIN_ERRORS+=("manifestler farklı Ubuntu sürümü belirtiyor: $unique_releases")
|
||||||
|
fi
|
||||||
|
UBUNTU_RELEASE="$(printf '%s\n' "${RELEASES[@]:-}" | grep -v '^$' | sort -u | head -n 1)"
|
||||||
|
[[ -n "$UBUNTU_RELEASE" ]] || UBUNTU_RELEASE='unknown'
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- package pool
|
||||||
|
declare -A POOL_VER=() POOL_SHA=() POOL_ARCH=() POOL_SIZE=() POOL_SOURCE=()
|
||||||
|
declare -a POOL_ORDER=() POOL_ERRORS=() FOREIGN=()
|
||||||
|
POOL_TOTAL=0
|
||||||
|
TOTAL_BYTES=0
|
||||||
|
|
||||||
|
if [[ -n "${PIN_ERRORS[*]:-}" ]]; then
|
||||||
|
printf 'HATA: manifest sözleşmesi bozuk; derleme yapılmadı:\n'
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${PIN_ERRORS[@]}" | sort)
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r -d '' deb; do
|
||||||
|
base="$(basename "$deb")"
|
||||||
|
raw="$(dpkg-deb -f "$deb" Package Version Architecture 2>/dev/null || true)"
|
||||||
|
package="$(printf '%s\n' "$raw" | sed -n '1p')"
|
||||||
|
version="$(printf '%s\n' "$raw" | sed -n '2p')"
|
||||||
|
arch="$(printf '%s\n' "$raw" | sed -n '3p')"
|
||||||
|
if [[ ! "$package" =~ $PACKAGE_NAME_RE || -z "$version" ]]; then
|
||||||
|
POOL_ERRORS+=("$base: geçerli bir Debian paketi okunamadı; add-packages.sh ile ekleyin")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ ! "$arch" =~ $ALLOWED_ARCH_RE ]]; then
|
||||||
|
FOREIGN+=("$base: $package $version ($arch)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
sha="$(file_sha256 "$deb")"
|
||||||
|
size="$(file_size "$deb")"
|
||||||
|
if [[ -n "${POOL_SHA["$package:$version"]:-}" ]]; then
|
||||||
|
if [[ "${POOL_SHA["$package:$version"]}" != "$sha" ]]; then
|
||||||
|
POOL_ERRORS+=("$base: $package $version repoda farklı içerikle var (${POOL_SOURCE["$package:$version"]})")
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
POOL_SHA["$package:$version"]="$sha"
|
||||||
|
POOL_VER["$package"]="$version"
|
||||||
|
POOL_ARCH["$package:$version"]="$arch"
|
||||||
|
POOL_SIZE["$package:$version"]="$size"
|
||||||
|
POOL_SOURCE["$package:$version"]="$base"
|
||||||
|
POOL_ORDER+=("$package:$version")
|
||||||
|
POOL_TOTAL=$((POOL_TOTAL + 1))
|
||||||
|
TOTAL_BYTES=$((TOTAL_BYTES + size))
|
||||||
|
done < <(find "$PACKAGES_DIR" -maxdepth 1 -type f -name '*.deb' -print0 | sort -z)
|
||||||
|
|
||||||
|
if [[ ${#FOREIGN[@]} -gt 0 ]]; then
|
||||||
|
printf 'HATA: repoda hedef mimari (%s) dışında paket var; bu paketler indekse girmez:\n' "$TARGET_ARCH"
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${FOREIGN[@]}" | sort)
|
||||||
|
printf ' Bu dosyaları packages/ altından kaldırın ya da doğru mimari sürümünü ekleyin.\n'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$POOL_TOTAL" -eq 0 ]]; then
|
||||||
|
printf 'HATA: %s içinde .deb dosyası yok.\n' "$PACKAGES_DIR"
|
||||||
|
printf ' Önce paketleri ekleyin: ./add-packages.sh --from <dizin>\n'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- pins vs. pool matching
|
||||||
|
declare -a MISSING=() EXTRA_PACKAGES=()
|
||||||
|
for package in "${PIN_ORDER[@]}"; do
|
||||||
|
version="${PIN_VERSION[$package]}"
|
||||||
|
manifest="${PIN_MANIFEST[$package]}"
|
||||||
|
if [[ "${version^^}" == "$UNPINNED" ]]; then
|
||||||
|
MISSING+=("$manifest: $package=$version -> manifestte sürüm hâlâ doldurulmadı (diğer adı: $UNPINNED)")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -z "${POOL_SHA["$package:$version"]:-}" ]]; then
|
||||||
|
if [[ -n "${POOL_VER[$package]:-}" ]]; then
|
||||||
|
MISSING+=("$manifest: $package=$version -> repoda bu sürüm yok (repodaki sürüm: ${POOL_VER[$package]})")
|
||||||
|
else
|
||||||
|
MISSING+=("$manifest: $package=$version -> paket repoda hiç yok")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Packages in the pool that no manifest pin mentions: dependencies are expected
|
||||||
|
# here, so this is informational and never a failure.
|
||||||
|
for key in "${POOL_ORDER[@]}"; do
|
||||||
|
package="${key%%:*}"
|
||||||
|
[[ -z "${PIN_VERSION[$package]:-}" ]] || continue
|
||||||
|
EXTRA_PACKAGES+=("$package ${key#*:}")
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'Bulunan paket : %d (%s)\n' "$POOL_TOTAL" "$(human_size "$TOTAL_BYTES")"
|
||||||
|
printf 'Pin sayısı : %d\n' "${#PIN_ORDER[@]}"
|
||||||
|
printf 'Manifestler : %s\n\n' "${MANIFEST_NAMES[*]}"
|
||||||
|
|
||||||
|
if [[ ${#POOL_ERRORS[@]} -gt 0 ]]; then
|
||||||
|
printf 'HATA: paket havuzunda tutarsızlık var:\n'
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${POOL_ERRORS[@]}" | sort)
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||||
|
printf 'HATA: manifestlerde istenen şu paketler eksik (%d):\n' "${#MISSING[@]}"
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${MISSING[@]}" | sort)
|
||||||
|
printf '\nEksik paketler giderilmeden indeks YAZILMADI.\n'
|
||||||
|
printf 'Bu eksiklerle kurulum yapılırsa kurucu OFFLINE BLOCKED ile durur.\n'
|
||||||
|
printf 'Sırayla yapılacaklar:\n'
|
||||||
|
printf ' 1) ./add-packages.sh --from <incelenmiş-deb-dizini> --manifest manifests/<dosya>\n'
|
||||||
|
printf ' 2) manifests/*.txt içindeki %s satırlarını gerçek sürümle doldurun\n' "$UNPINNED"
|
||||||
|
printf ' 3) ./build-index.sh\n'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'OK: %d pinin tamamı repodaki paketlerle eşleşti.\n' "${#PIN_ORDER[@]}"
|
||||||
|
if [[ ${#EXTRA_PACKAGES[@]} -gt 0 ]]; then
|
||||||
|
printf 'BİLGİ: pin dışı %d paket var (bağımlılık kapatması için normaldir):\n' "${#EXTRA_PACKAGES[@]}"
|
||||||
|
while IFS= read -r line; do printf ' - %s\n' "$line"; done < <(printf '%s\n' "${EXTRA_PACKAGES[@]}" | sort)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" -eq 1 ]]; then
|
||||||
|
printf '\nSONUÇ (--check): sözleşme tamam; hiçbir şey yazılmadı. Derlemek için: ./build-index.sh\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- staging
|
||||||
|
STAGE="$(mktemp -d "$(dirname "$OUTPUT")/.build-index.XXXXXX")"
|
||||||
|
cleanup() { if [[ -n "${STAGE:-}" && -d "${STAGE:-}" ]]; then rm -rf "$STAGE"; fi; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/pool"
|
||||||
|
if [[ -f "$OUTPUT/.gitkeep" ]]; then cp -- "$OUTPUT/.gitkeep" "$STAGE/.gitkeep"; fi
|
||||||
|
|
||||||
|
lock="$STAGE/packages.lock.tsv"
|
||||||
|
printf '%s\n' "$LOCK_HEADER" > "$lock"
|
||||||
|
while IFS= read -r key; do
|
||||||
|
deb="$PACKAGES_DIR/${POOL_SOURCE[$key]}"
|
||||||
|
cp -- "$deb" "$STAGE/pool/${POOL_SOURCE[$key]}"
|
||||||
|
printf '%s\t%s\t%s\t%s\t%s\n' \
|
||||||
|
"${key%%:*}" "${key#*:}" "${POOL_ARCH[$key]}" "${POOL_SHA[$key]}" "${POOL_SOURCE[$key]}" >> "$lock"
|
||||||
|
done < <(printf '%s\n' "${POOL_ORDER[@]}" | LC_ALL=C sort -t: -k1,1 -k2,2)
|
||||||
|
|
||||||
|
( cd "$STAGE" && apt-ftparchive packages pool > raw-packages )
|
||||||
|
canonicalize_stanzas < "$STAGE/raw-packages" > "$STAGE/Packages"
|
||||||
|
rm -f "$STAGE/raw-packages"
|
||||||
|
gzip -n -9 -c "$STAGE/Packages" > "$STAGE/Packages.gz"
|
||||||
|
( cd "$STAGE" && sha256sum Packages Packages.gz packages.lock.tsv > SHA256SUMS )
|
||||||
|
( cd "$STAGE" && sha256sum -c SHA256SUMS >/dev/null ) \
|
||||||
|
|| { printf 'HATA: üretilen indeks kendi checksum kaydıyla doğrulanamadı.\n' >&2; exit 1; }
|
||||||
|
printf 'OK: indeks üretildi ve doğrulandı: Packages, Packages.gz, packages.lock.tsv, SHA256SUMS\n'
|
||||||
|
|
||||||
|
# ------------------------------------------------------- replace the output directory
|
||||||
|
if [[ -e "$OUTPUT" ]]; then
|
||||||
|
if [[ -L "$OUTPUT" || ! -d "$OUTPUT" ]]; then
|
||||||
|
printf 'HATA: çıktı yolu bir dizin değil ya da sembolik bağ: %s\n' "$OUTPUT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
foreign=0
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
known=0
|
||||||
|
for allowed in $ALLOWED_ENTRIES; do
|
||||||
|
[[ "$entry" == "$allowed" ]] && known=1
|
||||||
|
done
|
||||||
|
if [[ "$known" -eq 0 ]]; then
|
||||||
|
printf 'HATA: çıktı dizininde bu depoya ait olmayan dosya var: %s/%s\n' "$OUTPUT" "$entry" >&2
|
||||||
|
foreign=1
|
||||||
|
fi
|
||||||
|
done < <(find "$OUTPUT" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
|
||||||
|
if [[ "$foreign" -eq 1 ]]; then
|
||||||
|
printf ' Yanlışlıkla dosya silmemek için derleme durduruldu. Dizini boşaltın ya da --output kullanın.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if diff -rq -- "$OUTPUT" "$STAGE" >/dev/null 2>&1; then
|
||||||
|
UNCHANGED=1
|
||||||
|
else
|
||||||
|
UNCHANGED=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
UNCHANGED=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$UNCHANGED" -eq 1 ]]; then
|
||||||
|
printf 'DEĞİŞİKLİK YOK: %s zaten bu paketlerle birebir aynı; dizin yeniden yazılmadı.\n' "$(rel_to_repo "$OUTPUT")"
|
||||||
|
else
|
||||||
|
rm -rf "$OUTPUT"
|
||||||
|
mv -- "$STAGE" "$OUTPUT"
|
||||||
|
STAGE=''
|
||||||
|
printf 'OK: depo yazıldı: %s\n' "$(rel_to_repo "$OUTPUT")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PKG_PACKAGES_SHA="$(file_sha256 "$OUTPUT/Packages")"
|
||||||
|
PKG_GZ_SHA="$(file_sha256 "$OUTPUT/Packages.gz")"
|
||||||
|
PKG_LOCK_SHA="$(file_sha256 "$OUTPUT/packages.lock.tsv")"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ repo-manifest.yaml
|
||||||
|
# The repository record. Regenerated content that differs only in generated_at
|
||||||
|
# is not rewritten, so an unchanged rebuild leaves git clean.
|
||||||
|
STAMP="$(now_utc)"
|
||||||
|
YAML_TMP="$(mktemp)"
|
||||||
|
{
|
||||||
|
printf '%s\n' '# Blueforce Field OS - offline APT deposu künyesi (repository record)'
|
||||||
|
printf '%s\n' '#'
|
||||||
|
printf '%s\n' '# Bu dosyayı provisioning/offline-repo/build-index.sh üretir; elle düzenlemeyin.'
|
||||||
|
printf '%s\n' '# Depoda hangi Ubuntu sürümü, hangi mimari ve hangi paketlerin bulunduğunu'
|
||||||
|
printf '%s\n' '# kanıtlar. Kurucu bu dosyayı okumaz; denetim ve yeniden üretim içindir.'
|
||||||
|
printf '%s\n' '#'
|
||||||
|
printf '%s\n' '# Yol: provisioning/offline-repo/built/ -> hedef cihazda /opt/blueforce/offline-repo'
|
||||||
|
printf 'schema_version: %d\n' "$SCHEMA_VERSION"
|
||||||
|
printf '%s\n' 'description: "Offline APT deposunun künyesi"'
|
||||||
|
printf '%s\n' 'repository:'
|
||||||
|
printf '%s\n' " name: 'blueforce-offline-apt'"
|
||||||
|
printf '%s\n' " format: 'apt-deb'"
|
||||||
|
printf '%s\n' " state: 'BUILT'"
|
||||||
|
printf ' ubuntu_release: %s\n' "$(yq "$UBUNTU_RELEASE")"
|
||||||
|
printf ' architecture: %s\n' "$(yq "$TARGET_ARCH")"
|
||||||
|
printf ' generated_at: %s\n' "$(yq "$STAMP")"
|
||||||
|
printf '%s\n' " generated_by: 'provisioning/offline-repo/build-index.sh'"
|
||||||
|
printf ' package_count: %d\n' "$POOL_TOTAL"
|
||||||
|
printf ' total_bytes: %d\n' "$TOTAL_BYTES"
|
||||||
|
printf ' total_size: %s\n' "$(yq "$(human_size "$TOTAL_BYTES")")"
|
||||||
|
printf '%s\n' " pool: 'pool/'"
|
||||||
|
printf '%s\n' " bundle_dir: 'provisioning/offline-repo/built'"
|
||||||
|
printf '%s\n' " install_path: '/opt/blueforce/offline-repo'"
|
||||||
|
printf '%s\n' " consumer: 'scripts/install/blueforce-install.sh --offline'"
|
||||||
|
printf '%s\n' ' index_files:'
|
||||||
|
printf '%s\n' " - 'Packages'"
|
||||||
|
printf '%s\n' " - 'Packages.gz'"
|
||||||
|
printf '%s\n' " - 'packages.lock.tsv'"
|
||||||
|
printf '%s\n' " - 'SHA256SUMS'"
|
||||||
|
printf '%s\n' ' index_sha256:'
|
||||||
|
printf ' Packages: %s\n' "$(yq "$PKG_PACKAGES_SHA")"
|
||||||
|
printf ' Packages.gz: %s\n' "$(yq "$PKG_GZ_SHA")"
|
||||||
|
printf ' packages.lock.tsv: %s\n' "$(yq "$PKG_LOCK_SHA")"
|
||||||
|
printf '%s\n' ' manifests:'
|
||||||
|
while IFS= read -r manifest_name; do
|
||||||
|
printf " - '%s'\n" "$manifest_name"
|
||||||
|
done < <(printf '%s\n' "${MANIFEST_NAMES[@]}" | LC_ALL=C sort)
|
||||||
|
printf ' pinned_packages: %d\n' "${#PIN_ORDER[@]}"
|
||||||
|
printf '%s\n' 'packages:'
|
||||||
|
while IFS= read -r key; do
|
||||||
|
package="${key%%:*}"
|
||||||
|
version="${key#*:}"
|
||||||
|
printf ' - package: %s\n' "$(yq "$package")"
|
||||||
|
printf ' version: %s\n' "$(yq "$version")"
|
||||||
|
printf ' architecture: %s\n' "$(yq "${POOL_ARCH[$key]}")"
|
||||||
|
printf ' sha256: %s\n' "$(yq "${POOL_SHA[$key]}")"
|
||||||
|
printf ' size_bytes: %s\n' "${POOL_SIZE[$key]}"
|
||||||
|
printf ' source: %s\n' "$(yq "${POOL_SOURCE[$key]}")"
|
||||||
|
if [[ "${PIN_VERSION[$package]:-}" == "$version" ]]; then
|
||||||
|
printf ' pinned_in: %s\n' "[$(yq "${PIN_MANIFEST[$package]}")]"
|
||||||
|
else
|
||||||
|
printf '%s\n' ' pinned_in: []'
|
||||||
|
fi
|
||||||
|
done < <(printf '%s\n' "${POOL_ORDER[@]}" | LC_ALL=C sort -t: -k1,1 -k2,2)
|
||||||
|
} > "$YAML_TMP"
|
||||||
|
|
||||||
|
MANIFEST_WRITTEN='yazıldı'
|
||||||
|
if [[ -f "$MANIFEST_OUT" ]] && diff -q <(grep -v '^ generated_at:' "$YAML_TMP") <(grep -v '^ generated_at:' "$MANIFEST_OUT") >/dev/null 2>&1; then
|
||||||
|
MANIFEST_WRITTEN='içerik değişmedi (tarih korundu)'
|
||||||
|
else
|
||||||
|
install -m 0644 -- "$YAML_TMP" "$MANIFEST_OUT"
|
||||||
|
fi
|
||||||
|
rm -f "$YAML_TMP"
|
||||||
|
printf 'OK: künye %s: %s\n' "$MANIFEST_WRITTEN" "$(rel_to_repo "$MANIFEST_OUT")"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ final report
|
||||||
|
printf '\n=== ÖZET ===\n'
|
||||||
|
printf 'Paket sayısı : %d\n' "$POOL_TOTAL"
|
||||||
|
printf 'Toplam boyut : %s (%d bayt)\n' "$(human_size "$TOTAL_BYTES")" "$TOTAL_BYTES"
|
||||||
|
printf 'Ubuntu sürümü: %s\n' "$UBUNTU_RELEASE"
|
||||||
|
printf 'Mimari : %s\n' "$TARGET_ARCH"
|
||||||
|
printf 'Çıktı : %s\n' "$(rel_to_repo "$OUTPUT")"
|
||||||
|
printf 'İndeks : Packages %s | Packages.gz %s | packages.lock.tsv %s\n' \
|
||||||
|
"${PKG_PACKAGES_SHA:0:12}" "${PKG_GZ_SHA:0:12}" "${PKG_LOCK_SHA:0:12}"
|
||||||
|
printf '\nGit eklemesi (komutları depo kökünden çalıştırın; .gitignore Packages/Packages.gz/\npackages.lock.tsv adlarını her yerde yok saydığı için -f gerekir):\n'
|
||||||
|
printf ' git add provisioning/offline-repo/packages provisioning/offline-repo/manifests\n'
|
||||||
|
printf ' git add -f %s\n' "$(rel_to_repo "$OUTPUT")"
|
||||||
|
printf ' git add %s\n' "$(rel_to_repo "$MANIFEST_OUT")"
|
||||||
|
printf ' git commit -m "offline repo: %d paket, offline APT indeksi"\n' "$POOL_TOTAL"
|
||||||
|
if [[ ! -d "$SCRIPT_DIR/build" ]]; then
|
||||||
|
printf '\nNOT: provisioning/iso/build-blueforce-iso.sh offline depoyu "build" adıyla arıyor.\n'
|
||||||
|
printf ' ISO ile aynı yolu kullanmak isterseniz: ./build-index.sh --output build\n'
|
||||||
|
fi
|
||||||
|
printf '\nDoğrulama (hedef cihazda /opt/blueforce/offline-repo):\n'
|
||||||
|
printf ' cd /opt/blueforce/offline-repo && sha256sum -c SHA256SUMS\n'
|
||||||
71
provisioning/offline-repo/repo-manifest.yaml
Normal file
71
provisioning/offline-repo/repo-manifest.yaml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Blueforce Field OS - offline APT deposu künyesi (repository record)
|
||||||
|
#
|
||||||
|
# Bu dosyayı provisioning/offline-repo/build-index.sh üretir ve her derlemede
|
||||||
|
# günceller. Elle düzenlemeyin: bir sonraki derleme dosyanın tamamını yeniden
|
||||||
|
# yazar (yalnız generated_at alanı serbest bırakılır, içerik değişmediyse dosya
|
||||||
|
# korunur).
|
||||||
|
#
|
||||||
|
# Amaç: deponun hangi Ubuntu sürümü, hangi mimari ve hangi paketlerle
|
||||||
|
# üretildiğini kanıtlamak. Kurucu (scripts/install/blueforce-install.sh
|
||||||
|
# --offline) bu dosyayı OKUMAZ; denetim, yeniden üretim ve teslim kaydı için
|
||||||
|
# insan okur. Kurucunun doğruladığı dosyalar: Packages, Packages.gz,
|
||||||
|
# packages.lock.tsv, SHA256SUMS ve pool/ içindeki .deb dosyaları.
|
||||||
|
#
|
||||||
|
# Aşağıdaki değerler "henüz derlenmedi" durumunu gösterir: packages/ altına
|
||||||
|
# paket eklenip ./build-index.sh çalıştırıldığında gerçek değerler yazılır.
|
||||||
|
#
|
||||||
|
# Alan listesi (şema):
|
||||||
|
# schema_version bu dosyanın sürümü
|
||||||
|
# repository.* deponun künyesi ve dizin/indeks bilgileri
|
||||||
|
# repository.state UNBUILT (henüz indissiz) ya da BUILT
|
||||||
|
# repository.package_count / total_bytes / total_size paket sayısı ve boyut
|
||||||
|
# repository.index_sha256 indeks dosyalarının SHA256 değerleri
|
||||||
|
# repository.manifests okunan pin manifestleri
|
||||||
|
# repository.pinned_packages manifestlerdeki pin sayısı
|
||||||
|
# packages[] her paket: ad, sürüm, mimari, sha256, boyut, dosya adı,
|
||||||
|
# pinned_in (hangi manifestte pinli; boş liste = yalnızca
|
||||||
|
# bağımlılık olarak eklenmiş)
|
||||||
|
|
||||||
|
schema_version: 1
|
||||||
|
description: "Offline APT deposunun künyesi"
|
||||||
|
|
||||||
|
repository:
|
||||||
|
name: 'blueforce-offline-apt'
|
||||||
|
format: 'apt-deb'
|
||||||
|
state: 'UNBUILT'
|
||||||
|
ubuntu_release: 'unknown'
|
||||||
|
architecture: 'amd64'
|
||||||
|
generated_at: 'UNBUILT'
|
||||||
|
generated_by: 'provisioning/offline-repo/build-index.sh'
|
||||||
|
package_count: 0
|
||||||
|
total_bytes: 0
|
||||||
|
total_size: '0 B'
|
||||||
|
pool: 'pool/'
|
||||||
|
bundle_dir: 'provisioning/offline-repo/built'
|
||||||
|
install_path: '/opt/blueforce/offline-repo'
|
||||||
|
consumer: 'scripts/install/blueforce-install.sh --offline'
|
||||||
|
index_files:
|
||||||
|
- 'Packages'
|
||||||
|
- 'Packages.gz'
|
||||||
|
- 'packages.lock.tsv'
|
||||||
|
- 'SHA256SUMS'
|
||||||
|
index_sha256:
|
||||||
|
Packages: 'UNBUILT'
|
||||||
|
Packages.gz: 'UNBUILT'
|
||||||
|
packages.lock.tsv: 'UNBUILT'
|
||||||
|
manifests:
|
||||||
|
- 'base-packages.txt'
|
||||||
|
- 'docker.txt'
|
||||||
|
- 'remote-access.txt'
|
||||||
|
pinned_packages: 0
|
||||||
|
|
||||||
|
# Derleme sonrası doldurulacak biçim (bu blok yalnızca örnektir, veri değildir):
|
||||||
|
# packages:
|
||||||
|
# - package: 'curl'
|
||||||
|
# version: '8.9.1-1ubuntu1'
|
||||||
|
# architecture: 'amd64'
|
||||||
|
# sha256: '<64 haneli sha256>'
|
||||||
|
# size_bytes: 123456
|
||||||
|
# source: 'curl_8.9.1-1ubuntu1_amd64.deb'
|
||||||
|
# pinned_in: ['base-packages.txt']
|
||||||
|
packages: []
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "bfos"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Blueforce Field OS operator console (Turkish, standard library only)"
|
||||||
|
readme = "admin/README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
authors = [{ name = "Blueforce" }]
|
||||||
|
# Zero external dependencies on purpose: field devices are installed offline
|
||||||
|
# from a verified local package bundle (docs/27), and the console must run with
|
||||||
|
# nothing but the Python standard library.
|
||||||
|
dependencies = []
|
||||||
|
classifiers = [
|
||||||
|
"Environment :: Console",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Documentation = "https://github.com/emirhanmamak-blueforce/Blueforce-Linux"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bfos = "bfos.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["bfos"]
|
||||||
|
|
@ -1,55 +1,274 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Blueforce one-click installer — single entry point for field setup.
|
# Blueforce one-click installer — single entry point for field setup.
|
||||||
|
#
|
||||||
|
# The field operator runs one command and answers no technical question:
|
||||||
|
#
|
||||||
|
# sudo ./scripts/install/blueforce-install.sh --dealer-id 12345678
|
||||||
|
#
|
||||||
|
# Packages always come from the local bundle the repository owner commits with
|
||||||
|
# the repository; nothing is downloaded at install time. The bundle is found
|
||||||
|
# automatically, in this order (the first complete candidate wins):
|
||||||
|
#
|
||||||
|
# 1. BF_OFFLINE_REPO operator override (must be valid),
|
||||||
|
# 2. <repo>/provisioning/offline-repo/built bundle committed to this repo,
|
||||||
|
# 3. /opt/blueforce/offline-repo bundle installed on the device.
|
||||||
|
#
|
||||||
|
# A missing or incomplete bundle stops the run before the first module, with a
|
||||||
|
# Turkish explanation, and changes nothing on the system (fail-closed).
|
||||||
|
#
|
||||||
|
# --offline is kept for backward compatibility (it used to be mandatory) but is
|
||||||
|
# no longer needed: it only forces the local bundle. --online exists for
|
||||||
|
# development/test hosts only and is never the default.
|
||||||
|
#
|
||||||
|
# User-facing messages are Turkish; code and comments are English.
|
||||||
|
# Exit codes: 0 success, 1 blocked or module failure, 2 usage error.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
INSTALLER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT_DIR="$(cd "$INSTALLER_DIR/../.." && pwd)"
|
||||||
MODULES_DIR="$INSTALLER_DIR/installer/modules"
|
MODULES_DIR="$INSTALLER_DIR/installer/modules"
|
||||||
|
MANIFEST_DIR="$REPO_ROOT_DIR/provisioning/offline-repo/manifests"
|
||||||
|
BUNDLE_IN_REPO="$REPO_ROOT_DIR/provisioning/offline-repo/built"
|
||||||
|
BUNDLE_ON_DEVICE="/opt/blueforce/offline-repo"
|
||||||
|
README_REF="provisioning/offline-repo/README.md"
|
||||||
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
|
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
|
||||||
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
|
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
|
||||||
STATE_FILE="$STATE_DIR/install-state"
|
STATE_FILE="$STATE_DIR/install-state"
|
||||||
MODULES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck 18-final-check)
|
MODULES=(01-precheck 02-system 03-user 04-hostname 05-network 06-wireguard 07-ssh 08-rdp 09-rustdesk 10-docker 11-meg 12-monitoring 13-firewall 14-update-policy 15-logrotate 16-gui 17-healthcheck 18-final-check)
|
||||||
DEALER_ID="" FROM="" ONLY="" CHECK_MODE=0 RESUME=0 ASSUME_YES=0 OFFLINE=0
|
|
||||||
BF_OFFLINE_REPO="${BF_OFFLINE_REPO:-/opt/blueforce/offline-repo}"
|
DEALER_ID="" FROM="" ONLY="" CHECK_MODE=0 RESUME=0 ASSUME_YES=0 FORCE_OFFLINE=0 ONLINE=0
|
||||||
usage() { cat <<'EOF'
|
# The operator's override is remembered before any default is applied: an
|
||||||
Usage: sudo ./blueforce-install.sh --dealer-id <8 digits> [--offline] [--from NN] [--only NAME] [--check] [--resume] [--yes]
|
# explicitly set path must fail closed instead of being replaced silently.
|
||||||
--offline uses supplied local packages only and completes as PROVISIONED_OFFLINE.
|
REPO_OVERRIDE="${BF_OFFLINE_REPO:-}"
|
||||||
--check is read-only: it creates no logs, state, or directories.
|
REPO_DIR="" REPO_ORIGIN="" BF_OFFLINE=0
|
||||||
|
REPO_SEARCH=()
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Kullanım: sudo ./blueforce-install.sh --dealer-id <8 haneli numara> [seçenekler]
|
||||||
|
|
||||||
|
Paketler bu betiğin yanındaki yerel paket deposundan gelir. İnternet, paket
|
||||||
|
indirme ve teknik bilgi gerekmez; tek zorunlu bilgi bayi numarasıdır:
|
||||||
|
|
||||||
|
sudo ./blueforce-install.sh --dealer-id 12345678
|
||||||
|
|
||||||
|
Seçenekler:
|
||||||
|
--dealer-id NUMARA Zorunlu. 8 haneli bayi numarası (ana makine adı bf-NUMARA).
|
||||||
|
--yes, -y Onay sormadan başla (otomatik kurulum).
|
||||||
|
--check Salt okunur denetim: sistemde hiçbir şeyi değiştirmez.
|
||||||
|
--from NN NN numaralı modülden başla.
|
||||||
|
--only AD Yalnız belirtilen modülü çalıştır (örn. --only 05-network).
|
||||||
|
--resume Daha önce tamamlanmış modülleri atla.
|
||||||
|
--help, -h Bu yardımı yaz.
|
||||||
|
|
||||||
|
Yalnız geliştirme/test için (sahada gerekmez):
|
||||||
|
--offline Yerel depoyu kullanmayı zorla; varsayılan davranış zaten budur.
|
||||||
|
--online Paketleri internetten çeker. ÜRETİM cihazlarında KULLANMAYIN.
|
||||||
|
|
||||||
|
Paket deposu şu sırayla aranır: BF_OFFLINE_REPO ortam değişkeni, depodaki
|
||||||
|
provisioning/offline-repo/built dizini, /opt/blueforce/offline-repo.
|
||||||
|
Depo bulunamazsa kurulum başlamaz. Ayrıntı: scripts/install/README.md
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
while [[ $# -gt 0 ]]; do case "$1" in --dealer-id) DEALER_ID="${2:-}"; shift 2;; --offline) OFFLINE=1; shift;; --from) FROM="${2:-}"; shift 2;; --only) ONLY="${2:-}"; shift 2;; --check) CHECK_MODE=1; shift;; --resume) RESUME=1; shift;; --yes|-y) ASSUME_YES=1; shift;; --help|-h) usage; exit 0;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 2;; esac; done
|
|
||||||
[[ -n "$DEALER_ID" ]] || { echo "Missing --dealer-id (8 digits)" >&2; exit 2; }
|
log() {
|
||||||
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { echo "Invalid dealer id '$DEALER_ID': must match ^[0-9]{8}$" >&2; exit 2; }
|
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
log() { if [[ "$CHECK_MODE" -eq 1 ]]; then printf '[installer check] %s\n' "$1" >&2; else mkdir -p "$(dirname "$LOG_FILE")" "$STATE_DIR"; echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [installer] $1" | tee -a "$LOG_FILE"; fi; }
|
printf '[installer check] %s\n' "$1" >&2
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$STATE_DIR"
|
||||||
|
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [installer] $1" | tee -a "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# notice: a message the operator must read. Always on stdout, and in apply mode
|
||||||
|
# also in the log file (the log stays the audit record of the run).
|
||||||
|
notice() {
|
||||||
|
printf '%s\n' "$1"
|
||||||
|
if [[ "$CHECK_MODE" -eq 0 ]]; then
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
printf '%s [installer] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
usage_error() { printf 'HATA: %s\n\n' "$1" >&2; usage >&2; exit 2; }
|
||||||
|
blocked_bundle() {
|
||||||
|
printf 'OFFLINE BLOCKED: %s\n' "$1" >&2
|
||||||
|
printf ' Depo : %s\n' "${REPO_DIR:-tanımsız}" >&2
|
||||||
|
printf ' Ne yapmalı : paket setini yeniden üretip git ile getirin (%s)\n' "$README_REF" >&2
|
||||||
|
printf ' ÇIKIŞ NEDENİ: yerel paket deposu doğrulanamadı; kurulum başlamadı ve\n' >&2
|
||||||
|
printf ' sistemde hiçbir değişiklik yapılmadı.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE"; }
|
record_state() { mkdir -p "$STATE_DIR"; local tmp="$STATE_FILE.tmp"; grep -v "^$1=" "$STATE_FILE" 2>/dev/null > "$tmp" || true; echo "$1=$2" >> "$tmp"; mv "$tmp" "$STATE_FILE"; }
|
||||||
state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; }
|
state_of() { grep "^$1=" "$STATE_FILE" 2>/dev/null | cut -d= -f2 || true; }
|
||||||
export DEALER_ID BF_HOSTNAME="bf-${DEALER_ID}" BF_DEVICE="BF-${DEALER_ID}" STATE_DIR BF_OFFLINE="$OFFLINE" BF_OFFLINE_REPO
|
|
||||||
validate_offline_repository() {
|
# ------------------------------------------------------- bundle discovery -----
|
||||||
local manifest pin package version source
|
human_size() {
|
||||||
[[ "$BF_OFFLINE_REPO" = /* && -d "$BF_OFFLINE_REPO" ]] || { echo "OFFLINE BLOCKED: BF_OFFLINE_REPO must be an existing absolute local directory." >&2; return 1; }
|
awk -v b="$1" 'BEGIN {
|
||||||
[[ -s "$BF_OFFLINE_REPO/Packages" && -s "$BF_OFFLINE_REPO/Packages.gz" && -s "$BF_OFFLINE_REPO/packages.lock.tsv" && -s "$BF_OFFLINE_REPO/SHA256SUMS" ]] || { echo 'OFFLINE BLOCKED: local APT index, lock, or checksums are missing; ISO bundle is incomplete.' >&2; return 1; }
|
split("B KiB MiB GiB TiB", unit, " ")
|
||||||
(cd "$BF_OFFLINE_REPO" && sha256sum -c SHA256SUMS >/dev/null) || { echo 'OFFLINE BLOCKED: local APT repository checksum validation failed.' >&2; return 1; }
|
i = 1
|
||||||
awk -F '\t' 'NR == 1 { if ($0 != "package\tversion\tarchitecture\tsha256\tsource") exit 1; next } $1 !~ /^[a-z0-9][a-z0-9+.-]*$/ || $2 == "" || $3 == "" || $4 !~ /^[a-f0-9]{64}$/ || $5 == "" { exit 1 } END { exit NR < 2 }' "$BF_OFFLINE_REPO/packages.lock.tsv" || { echo 'OFFLINE BLOCKED: package lock is empty or invalid.' >&2; return 1; }
|
while (b >= 1024 && i < 5) { b /= 1024; i++ }
|
||||||
for manifest in "$INSTALLER_DIR/../../provisioning/offline-repo/manifests"/*.txt; do
|
if (i == 1) printf "%d %s\n", b, unit[i]; else printf "%.1f %s\n", b, unit[i]
|
||||||
[[ -s "$manifest" ]] || { echo "OFFLINE BLOCKED: required pin manifest is empty: $(basename "$manifest")." >&2; return 1; }
|
}'
|
||||||
while IFS= read -r pin || [[ -n "$pin" ]]; do
|
}
|
||||||
[[ -z "$pin" || "$pin" == \#* ]] && continue
|
repository_bytes() {
|
||||||
[[ "$pin" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ ]] && [[ "$pin" != *placeholder* && "$pin" != *REPLACE* && "$pin" != *replace* ]] || { echo "OFFLINE BLOCKED: invalid package pin in $(basename "$manifest")." >&2; return 1; }
|
local dir="$1" bytes
|
||||||
package="${pin%%=*}"; version="${pin#*=}"
|
bytes="$(du -sb "$dir" 2>/dev/null | awk '{print $1}')"
|
||||||
source="$(awk -F '\t' -v p="$package" -v v="$version" '$1 == p && $2 == v {print $5; exit}' "$BF_OFFLINE_REPO/packages.lock.tsv")"
|
if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then
|
||||||
[[ -n "$source" && -f "$BF_OFFLINE_REPO/pool/$source" ]] || { echo "OFFLINE BLOCKED: pinned package absent from bundle: $package." >&2; return 1; }
|
bytes="$(du -sk "$dir" 2>/dev/null | awk '{print $1 * 1024}')"
|
||||||
done < "$manifest"
|
fi
|
||||||
grep -qvE '^[[:space:]]*(#|$)' "$manifest" || { echo "OFFLINE BLOCKED: required pin manifest has no package pins: $(basename "$manifest")." >&2; return 1; }
|
[[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0
|
||||||
|
printf '%s' "$bytes"
|
||||||
|
}
|
||||||
|
repository_package_count() {
|
||||||
|
awk -F '\t' 'NR > 1 && NF >= 5 { n++ } END { printf "%d", n + 0 }' "$1/packages.lock.tsv"
|
||||||
|
}
|
||||||
|
repository_is_complete() {
|
||||||
|
local repo="$1"
|
||||||
|
[[ -n "$repo" && -d "$repo" && -s "$repo/Packages" && -s "$repo/Packages.gz" && -s "$repo/packages.lock.tsv" && -s "$repo/SHA256SUMS" ]]
|
||||||
|
}
|
||||||
|
# Fills REPO_DIR/REPO_ORIGIN and REPO_SEARCH (one readable line per candidate).
|
||||||
|
discover_offline_repository() {
|
||||||
|
local -a dirs=() labels=()
|
||||||
|
local i dir label
|
||||||
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
dirs=("$REPO_OVERRIDE"); labels=("BF_OFFLINE_REPO ortam değişkeni")
|
||||||
|
else
|
||||||
|
dirs=("$BUNDLE_IN_REPO" "$BUNDLE_ON_DEVICE")
|
||||||
|
labels=("depodaki hazır paket seti" "cihazdaki kurulu paket deposu")
|
||||||
|
fi
|
||||||
|
for i in "${!dirs[@]}"; do
|
||||||
|
dir="${dirs[$i]}"; label="${labels[$i]}"
|
||||||
|
if [[ ! -e "$dir" ]]; then
|
||||||
|
REPO_SEARCH+=("$label — yok: $dir")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if repository_is_complete "$dir"; then
|
||||||
|
REPO_DIR="$dir"; REPO_ORIGIN="$label"
|
||||||
|
REPO_SEARCH+=("$label — kullanılabilir: $dir")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
REPO_SEARCH+=("$label — eksik/bozuk: $dir (Packages, Packages.gz, packages.lock.tsv ve SHA256SUMS dosyalarının hepsi gerekli)")
|
||||||
done
|
done
|
||||||
install -d -m 700 "$STATE_DIR"
|
return 1
|
||||||
printf 'deb [trusted=yes] file:%s ./\n' "$BF_OFFLINE_REPO" > "$STATE_DIR/offline.list"
|
}
|
||||||
chmod 600 "$STATE_DIR/offline.list"
|
report_missing_repository() {
|
||||||
BF_OFFLINE_APT_CONFIG="$(mktemp)"; chmod 600 "$BF_OFFLINE_APT_CONFIG"
|
local entry
|
||||||
printf 'Dir::Etc::sourcelist "%s";\nDir::Etc::sourceparts "-";\nAcquire::Languages "none";\n' "$STATE_DIR/offline.list" > "$BF_OFFLINE_APT_CONFIG"
|
printf 'HATA: Yerel paket deposu yok. Repo sorumlusuna başvurun: %s\n' "$README_REF" >&2
|
||||||
|
printf '\n' >&2
|
||||||
|
printf 'Kurulum paketleri yalnız yerel depodan kurar; bu makinede internetten paket\n' >&2
|
||||||
|
printf 'alınmaz. Depo olmadığı için paket adımları çalışamaz.\n' >&2
|
||||||
|
printf '\nAranan yerler:\n' >&2
|
||||||
|
for entry in "${REPO_SEARCH[@]}"; do
|
||||||
|
printf ' - %s\n' "$entry" >&2
|
||||||
|
done
|
||||||
|
printf '\nNe yapmalı:\n' >&2
|
||||||
|
printf ' 1) Paket setini git ile tam olarak getirin (repo sorumlusunun hazırladığı\n' >&2
|
||||||
|
printf ' provisioning/offline-repo/built dizini) ya da repo sorumlusunun verdiği\n' >&2
|
||||||
|
printf ' kopyayı /opt/blueforce/offline-repo altına koyun.\n' >&2
|
||||||
|
printf ' 2) Kurulumu yeniden çalıştırın: sudo ./blueforce-install.sh --dealer-id <numara>\n' >&2
|
||||||
|
printf '\nKuruluma hiç başlanmadı; sistemde hiçbir değişiklik yapılmadı.\n' >&2
|
||||||
|
printf 'ÇIKIŞ NEDENİ: yerel paket deposu bulunamadı.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------ bundle validation -----
|
||||||
|
# Fail-closed: every check has to pass before the first module runs. The local
|
||||||
|
# APT source and the pinned versions are the only package contract of the
|
||||||
|
# install, so a bundle that does not match its own lock file is refused.
|
||||||
|
validate_offline_repository() {
|
||||||
|
local repo="$REPO_DIR" manifest pin package version source lpackage lversion larch lsha lsource
|
||||||
|
[[ "$repo" = /* && -d "$repo" ]] || blocked_bundle 'BF_OFFLINE_REPO mutlak bir yerel dizin olmalıdır.'
|
||||||
|
repository_is_complete "$repo" || blocked_bundle 'Yerel APT indeksi, kilit dosyası ya da checksum kaydı eksik; paket seti tamamlanmamış.'
|
||||||
|
(cd "$repo" && sha256sum -c SHA256SUMS >/dev/null) || blocked_bundle 'Yerel APT deposunun checksum doğrulaması başarısız.'
|
||||||
|
awk -F '\t' 'NR == 1 { if ($0 != "package\tversion\tarchitecture\tsha256\tsource") exit 1; next } $1 !~ /^[a-z0-9][a-z0-9+.-]*$/ || $2 == "" || $3 == "" || $4 !~ /^[a-f0-9]{64}$/ || $5 == "" { exit 1 } END { exit NR < 2 }' "$repo/packages.lock.tsv" || blocked_bundle 'Paket kilidi boş ya da biçimi bozuk.'
|
||||||
|
# Every locked row must be present in the pool: the lock file is the SBOM the
|
||||||
|
# bundle is installed from, so a row without its file is an incomplete set.
|
||||||
|
while IFS=$'\t' read -r lpackage lversion larch lsha lsource; do
|
||||||
|
[[ "$lpackage" == 'package' && "$lversion" == 'version' ]] && continue
|
||||||
|
[[ -n "$lsource" && -f "$repo/pool/$lsource" ]] || blocked_bundle "Kilitte yazılı paket depoda yok: $lpackage $lversion ($lsource)."
|
||||||
|
done < "$repo/packages.lock.tsv"
|
||||||
|
# The pin manifests are the reviewed "what must be installed" contract of the
|
||||||
|
# repository; when they travel with the checkout they are enforced too.
|
||||||
|
if [[ -d "$MANIFEST_DIR" ]]; then
|
||||||
|
for manifest in "$MANIFEST_DIR"/*.txt; do
|
||||||
|
[[ -e "$manifest" ]] || continue
|
||||||
|
[[ -s "$manifest" ]] || blocked_bundle "Gerekli pin manifestosu boş: $(basename "$manifest")."
|
||||||
|
grep -qvE '^[[:space:]]*(#|$)' "$manifest" || blocked_bundle "Gerekli pin manifestosunda paket pini yok: $(basename "$manifest")."
|
||||||
|
while IFS= read -r pin || [[ -n "$pin" ]]; do
|
||||||
|
[[ -z "$pin" || "$pin" == \#* ]] && continue
|
||||||
|
[[ "$pin" =~ ^[a-z0-9][a-z0-9+.-]*=[^[:space:]]+$ ]] && [[ "$pin" != *placeholder* && "$pin" != *REPLACE* && "$pin" != *replace* ]] || blocked_bundle "Geçersiz paket pini: $(basename "$manifest") içinde '$pin'."
|
||||||
|
package="${pin%%=*}"; version="${pin#*=}"
|
||||||
|
source="$(awk -F '\t' -v p="$package" -v v="$version" '$1 == p && $2 == v {print $5; exit}' "$repo/packages.lock.tsv")"
|
||||||
|
[[ -n "$source" && -f "$repo/pool/$source" ]] || blocked_bundle "Pinlenen paket depoda yok: $package=$version."
|
||||||
|
done < "$manifest"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
log "BİLGİ: pin manifestoları bu makinede yok; sözleşme packages.lock.tsv üzerinden doğrulandı."
|
||||||
|
fi
|
||||||
|
# The local APT source. --check stays read-only: its apt configuration lives
|
||||||
|
# in a temporary directory and is removed on exit.
|
||||||
|
if [[ "$CHECK_MODE" -eq 1 ]]; then
|
||||||
|
CHECK_TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${CHECK_TMP:-}"' EXIT
|
||||||
|
printf 'deb [trusted=yes] file:%s ./\n' "$repo" > "$CHECK_TMP/offline.list"
|
||||||
|
BF_OFFLINE_APT_CONFIG="$CHECK_TMP/apt.conf"
|
||||||
|
printf 'Dir::Etc::sourcelist "%s";\nDir::Etc::sourceparts "-";\nAcquire::Languages "none";\n' "$CHECK_TMP/offline.list" > "$BF_OFFLINE_APT_CONFIG"
|
||||||
|
chmod 600 "$CHECK_TMP/offline.list" "$BF_OFFLINE_APT_CONFIG"
|
||||||
|
else
|
||||||
|
install -d -m 700 "$STATE_DIR"
|
||||||
|
printf 'deb [trusted=yes] file:%s ./\n' "$repo" > "$STATE_DIR/offline.list"
|
||||||
|
chmod 600 "$STATE_DIR/offline.list"
|
||||||
|
BF_OFFLINE_APT_CONFIG="$(mktemp)"; chmod 600 "$BF_OFFLINE_APT_CONFIG"
|
||||||
|
printf 'Dir::Etc::sourcelist "%s";\nDir::Etc::sourceparts "-";\nAcquire::Languages "none";\n' "$STATE_DIR/offline.list" > "$BF_OFFLINE_APT_CONFIG"
|
||||||
|
fi
|
||||||
export BF_OFFLINE_APT_CONFIG
|
export BF_OFFLINE_APT_CONFIG
|
||||||
}
|
}
|
||||||
if [[ "$OFFLINE" -eq 1 ]]; then
|
|
||||||
validate_offline_repository || exit 1
|
# --------------------------------------------------------------- arguments ----
|
||||||
|
while [[ $# -gt 0 ]]; do case "$1" in
|
||||||
|
--dealer-id) DEALER_ID="${2:-}"; shift 2;;
|
||||||
|
--offline) FORCE_OFFLINE=1; shift;;
|
||||||
|
--online) ONLINE=1; shift;;
|
||||||
|
--from) FROM="${2:-}"; shift 2;;
|
||||||
|
--only) ONLY="${2:-}"; shift 2;;
|
||||||
|
--check) CHECK_MODE=1; shift;;
|
||||||
|
--resume) RESUME=1; shift;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift;;
|
||||||
|
--help|-h) usage; exit 0;;
|
||||||
|
*) usage_error "Bilinmeyen parametre: $1";;
|
||||||
|
esac; done
|
||||||
|
[[ -n "$DEALER_ID" ]] || usage_error 'Zorunlu parametre eksik: --dealer-id <8 haneli bayi numarası>. Örnek: sudo ./blueforce-install.sh --dealer-id 12345678'
|
||||||
|
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || usage_error "Geçersiz bayi numarası '$DEALER_ID': 8 haneli rakam olmalıdır (örn. 12345678)."
|
||||||
|
[[ "$FORCE_OFFLINE" -eq 1 && "$ONLINE" -eq 1 ]] && usage_error 'Bir kurulumda hem --offline hem --online verilemez.'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- mode -----
|
||||||
|
# Default (no flag): find the local bundle and install from it. The flag the
|
||||||
|
# operator used to have to remember is now only a way to insist on it.
|
||||||
|
if [[ "$ONLINE" -eq 1 ]]; then
|
||||||
|
BF_OFFLINE=0
|
||||||
|
notice 'DİKKAT: --online yalnız geliştirme/test içindir. Paketler uzak APT kaynaklarından'
|
||||||
|
notice ' çekilir; ÜRETİM cihazlarında KULLANMAYIN.'
|
||||||
|
export BF_OFFLINE_REPO=""
|
||||||
|
else
|
||||||
|
if ! discover_offline_repository; then
|
||||||
|
report_missing_repository
|
||||||
|
fi
|
||||||
|
BF_OFFLINE=1
|
||||||
|
notice "Yerel paket deposu bulundu: $(repository_package_count "$REPO_DIR") paket, $(human_size "$(repository_bytes "$REPO_DIR")")"
|
||||||
|
notice " Depo : $REPO_DIR"
|
||||||
|
notice " Kaynak : $REPO_ORIGIN"
|
||||||
|
notice ' Kip : yerel kurulum. İnternet, paket indirme ve ek anahtar gerekmez.'
|
||||||
|
if [[ "$FORCE_OFFLINE" -eq 1 ]]; then
|
||||||
|
log 'BİLGİ: --offline verildi; yerel depo zaten varsayılan kaynaktır.'
|
||||||
|
fi
|
||||||
|
validate_offline_repository
|
||||||
fi
|
fi
|
||||||
if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then read -r -p "Proceed with installation as $BF_DEVICE? [y/N] " answer; [[ "$answer" =~ ^[Yy]$ ]] || { echo Aborted.; exit 2; }; fi
|
export DEALER_ID BF_HOSTNAME="bf-${DEALER_ID}" BF_DEVICE="BF-${DEALER_ID}" STATE_DIR BF_OFFLINE BF_OFFLINE_REPO="${REPO_DIR:-}"
|
||||||
|
|
||||||
|
if [[ "$CHECK_MODE" -eq 0 && "$ASSUME_YES" -eq 0 ]]; then
|
||||||
|
read -r -p "Kurulum $BF_DEVICE olarak başlatılsın mı? [e/H] " answer
|
||||||
|
[[ "$answer" =~ ^[Ee]$ ]] || { echo 'Kurulum iptal edildi.'; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
RUN=(); for m in "${MODULES[@]}"; do
|
RUN=(); for m in "${MODULES[@]}"; do
|
||||||
if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi
|
if [[ -n "$ONLY" ]]; then [[ "$m" == "$ONLY" || "$m" == "$ONLY"* ]] && RUN+=("$m"); continue; fi
|
||||||
[[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue
|
[[ -n "$FROM" && "${m%%-*}" < "$FROM" ]] && continue
|
||||||
|
|
@ -57,7 +276,7 @@ RUN=(); for m in "${MODULES[@]}"; do
|
||||||
RUN+=("$m")
|
RUN+=("$m")
|
||||||
done
|
done
|
||||||
[[ ${#RUN[@]} -gt 0 ]] || { echo "No modules selected." >&2; exit 2; }
|
[[ ${#RUN[@]} -gt 0 ]] || { echo "No modules selected." >&2; exit 2; }
|
||||||
log "=== install start dealer=$BF_DEVICE mode=$([[ "$CHECK_MODE" -eq 1 ]] && echo check || echo apply) modules=${RUN[*]} ==="
|
log "=== install start dealer=$BF_DEVICE mode=$([[ "$CHECK_MODE" -eq 1 ]] && echo check || echo apply) source=$([[ "$BF_OFFLINE" -eq 1 ]] && echo local || echo remote) modules=${RUN[*]} ==="
|
||||||
failures=0
|
failures=0
|
||||||
for m in "${RUN[@]}"; do
|
for m in "${RUN[@]}"; do
|
||||||
script="$MODULES_DIR/$m.sh"; if [[ ! -x "$script" ]]; then log "FAIL $m (script missing)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); break; fi
|
script="$MODULES_DIR/$m.sh"; if [[ ! -x "$script" ]]; then log "FAIL $m (script missing)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); break; fi
|
||||||
|
|
@ -66,7 +285,7 @@ for m in "${RUN[@]}"; do
|
||||||
case "$rc" in 0) log "OK $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" OK;; 3) log "SKIP $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" SKIP;; *) log "FAIL $m (exit $rc)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); [[ "$CHECK_MODE" -eq 0 ]] && exit 1;; esac
|
case "$rc" in 0) log "OK $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" OK;; 3) log "SKIP $m"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" SKIP;; *) log "FAIL $m (exit $rc)"; [[ "$CHECK_MODE" -eq 0 ]] && record_state "$m" FAIL; failures=$((failures+1)); [[ "$CHECK_MODE" -eq 0 ]] && exit 1;; esac
|
||||||
done
|
done
|
||||||
[[ "$CHECK_MODE" -eq 1 && "$failures" -gt 0 ]] && exit 1
|
[[ "$CHECK_MODE" -eq 1 && "$failures" -gt 0 ]] && exit 1
|
||||||
if [[ "$CHECK_MODE" -eq 0 && "$OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then
|
if [[ "$CHECK_MODE" -eq 0 && "$BF_OFFLINE" -eq 1 && "$failures" -eq 0 ]]; then
|
||||||
install -d -m 700 "$STATE_DIR"
|
install -d -m 700 "$STATE_DIR"
|
||||||
umask 077
|
umask 077
|
||||||
python3 - "$STATE_DIR/state.json" "$DEALER_ID" <<'PY'
|
python3 - "$STATE_DIR/state.json" "$DEALER_ID" <<'PY'
|
||||||
|
|
@ -79,5 +298,7 @@ os.replace(tmp,path)
|
||||||
PY
|
PY
|
||||||
chmod 600 "$STATE_DIR/state.json"
|
chmod 600 "$STATE_DIR/state.json"
|
||||||
log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY"
|
log "OFFLINE COMPLETE: device is PROVISIONED_OFFLINE; enrollment is required before READY"
|
||||||
|
notice "Kurulum tamam: $BF_DEVICE yerel paket deposundan kuruldu."
|
||||||
|
notice 'Cihaz şu an PROVISIONED_OFFLINE durumunda; kayıt (enrollment) tamamlanana kadar READY sayılmaz.'
|
||||||
fi
|
fi
|
||||||
log "=== install done dealer=$BF_DEVICE failures=$failures ==="
|
log "=== install done dealer=$BF_DEVICE failures=$failures ==="
|
||||||
|
|
|
||||||
BIN
tests/__pycache__/test_bfos_gui_static.cpython-311.pyc
Normal file
BIN
tests/__pycache__/test_bfos_gui_static.cpython-311.pyc
Normal file
Binary file not shown.
|
|
@ -236,7 +236,7 @@ contract 'bf-bootstrap wires up bf-menu' "$BOOTSTRAP" has 'bf-menu'
|
||||||
contract 'bf-bootstrap supports Ubuntu 24.04' "$BOOTSTRAP" has '24\.04'
|
contract 'bf-bootstrap supports Ubuntu 24.04' "$BOOTSTRAP" has '24\.04'
|
||||||
contract 'bf-bootstrap supports Ubuntu 26.04' "$BOOTSTRAP" has '26\.04'
|
contract 'bf-bootstrap supports Ubuntu 26.04' "$BOOTSTRAP" has '26\.04'
|
||||||
contract 'bf-bootstrap has a read-only check mode' "$BOOTSTRAP" has 'CHECK_MODE'
|
contract 'bf-bootstrap has a read-only check mode' "$BOOTSTRAP" has 'CHECK_MODE'
|
||||||
contract 'bf-bootstrap prints the completion contract' "$BOOTSTRAP" has 'Kurulum tamam\. Başlamak için: sudo bf-menu'
|
contract 'bf-bootstrap prints the completion contract' "$BOOTSTRAP" has 'Kurulum tamam\. Başlamak için: sudo bf'
|
||||||
contract 'bf-bootstrap carries no private key material' "$BOOTSTRAP" lacks 'BEGIN [A-Z ]*PRIVATE KEY'
|
contract 'bf-bootstrap carries no private key material' "$BOOTSTRAP" lacks 'BEGIN [A-Z ]*PRIVATE KEY'
|
||||||
contract 'bf-bootstrap generates no WireGuard key' "$BOOTSTRAP" lacks 'wg genkey'
|
contract 'bf-bootstrap generates no WireGuard key' "$BOOTSTRAP" lacks 'wg genkey'
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@ control 'bf-bootstrap root check is checked' "$WORK/b-no-root.sh" has 'id -u'
|
||||||
|
|
||||||
sed -E '/Kurulum tamam/d' "$BOOTSTRAP" > "$WORK/b-no-summary.sh"
|
sed -E '/Kurulum tamam/d' "$BOOTSTRAP" > "$WORK/b-no-summary.sh"
|
||||||
chmod 0755 "$WORK/b-no-summary.sh"
|
chmod 0755 "$WORK/b-no-summary.sh"
|
||||||
control 'bf-bootstrap completion contract is checked' "$WORK/b-no-summary.sh" has 'Kurulum tamam\. Başlamak için: sudo bf-menu'
|
control 'bf-bootstrap completion contract is checked' "$WORK/b-no-summary.sh" has 'Kurulum tamam\. Başlamak için: sudo bf'
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 4. bf-bootstrap.sh runtime: --check is mutation-free, apply mode works.
|
# 4. bf-bootstrap.sh runtime: --check is mutation-free, apply mode works.
|
||||||
|
|
@ -384,9 +384,9 @@ set -e
|
||||||
expect_rc 'bf-bootstrap apply mode exits 0 in the sandbox' 0 "$apply_rc"
|
expect_rc 'bf-bootstrap apply mode exits 0 in the sandbox' 0 "$apply_rc"
|
||||||
expect_present 'bf-bootstrap cloned the repository' "$APPLY_ROOT/checkout/.git"
|
expect_present 'bf-bootstrap cloned the repository' "$APPLY_ROOT/checkout/.git"
|
||||||
expect_present 'bf-bootstrap linked a diagnostics tool' "$APPLY_ROOT/bin/bf-status"
|
expect_present 'bf-bootstrap linked a diagnostics tool' "$APPLY_ROOT/bin/bf-status"
|
||||||
expect_contains 'bf-bootstrap prints the completion contract' "$WORK/apply1.out" 'Kurulum tamam. Başlamak için: sudo bf-menu'
|
expect_contains 'bf-bootstrap prints the completion contract' "$WORK/apply1.out" 'Kurulum tamam. Başlamak için: sudo bf'
|
||||||
expect_contains 'bf-bootstrap reports the repository in its summary' "$WORK/apply1.out" "$APPLY_ROOT/checkout"
|
expect_contains 'bf-bootstrap reports the repository in its summary' "$WORK/apply1.out" "$APPLY_ROOT/checkout"
|
||||||
expect_contains 'bf-bootstrap warns when bf-menu is not in the clone' "$WORK/apply1.out" 'admin/bf-menu is not present'
|
expect_contains 'bf-bootstrap warns when the Python console is not in the clone' "$WORK/apply1.out" 'admin/bf is not present'
|
||||||
linked1="$(find "$APPLY_ROOT/bin" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')"
|
linked1="$(find "$APPLY_ROOT/bin" -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
check
|
check
|
||||||
if [[ "$linked1" -ge 10 ]]; then
|
if [[ "$linked1" -ge 10 ]]; then
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,337 @@ build_rc=0
|
||||||
bash "$BUILD" --packages "$TMP/absent-pool" --output "$TMP/out" >/dev/null 2>&1 || build_rc=$?
|
bash "$BUILD" --packages "$TMP/absent-pool" --output "$TMP/out" >/dev/null 2>&1 || build_rc=$?
|
||||||
[[ "$build_rc" -eq 2 ]] && pass 'a missing package directory is a usage error' || fail "missing package directory must exit 2, got $build_rc"
|
[[ "$build_rc" -eq 2 ]] && pass 'a missing package directory is a usage error' || fail "missing package directory must exit 2, got $build_rc"
|
||||||
|
|
||||||
|
# ------------------------------------------------- repository owner tooling ------
|
||||||
|
# The packages are committed by the repository owner on an Ubuntu host, so the
|
||||||
|
# intake and index builders are part of the contract: they must stay free of any
|
||||||
|
# download primitive, keep the exact lock header, and never write a partial build.
|
||||||
|
ADD_PKGS="$OFFLINE_REPO/add-packages.sh"
|
||||||
|
BUILD_INDEX="$OFFLINE_REPO/build-index.sh"
|
||||||
|
REPO_MANIFEST="$OFFLINE_REPO/repo-manifest.yaml"
|
||||||
|
require_file "$ADD_PKGS"
|
||||||
|
require_file "$BUILD_INDEX"
|
||||||
|
require_file "$REPO_MANIFEST"
|
||||||
|
|
||||||
|
for script in "$ADD_PKGS" "$BUILD_INDEX"; do
|
||||||
|
label="$(basename "$script")"
|
||||||
|
[[ "$(head -n 1 "$script")" == '#!/usr/bin/env bash' ]] || fail "$label requires a Bash shebang"
|
||||||
|
grep -q '^set -euo pipefail$' "$script" || fail "$label requires strict mode"
|
||||||
|
bash -n "$script" && pass "$label parses" || fail "$label has a parse error"
|
||||||
|
if grep -nE '(^|[^A-Za-z])(curl|wget)[[:space:]]+(-|http|https)|apt(-get)?[[:space:]]+download|https?://' "$script"; then
|
||||||
|
fail "$label must contain no download primitive"
|
||||||
|
else
|
||||||
|
pass "$label has no download primitive"
|
||||||
|
fi
|
||||||
|
if grep -nE '(BEGIN [A-Z ]*PRIVATE KEY|(password|passwd|token|api[_-]?key|secret)[[:space:]]*[:=]|AKIA[0-9A-Z]{16})' "$script"; then
|
||||||
|
fail "$label contains a credential-like pattern"
|
||||||
|
else
|
||||||
|
pass "$label carries no credential-like pattern"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# add-packages.sh: intake verdicts and the manifest hand-off are mandatory.
|
||||||
|
grep -q 'dpkg-deb' "$ADD_PKGS" && pass 'add-packages.sh validates with dpkg-deb' || fail 'add-packages.sh must validate with dpkg-deb'
|
||||||
|
grep -q 'UNPINNED' "$ADD_PKGS" && pass 'add-packages.sh knows the UNPINNED sentinel' || fail 'add-packages.sh must handle the UNPINNED sentinel'
|
||||||
|
grep -q 'MANIFEST GÜNCELLEMESİ' "$ADD_PKGS" && pass 'add-packages.sh hands the owner the pin lines to fill' || fail 'add-packages.sh must print the manifest lines to fill'
|
||||||
|
grep -q 'EKLENDİ' "$ADD_PKGS" && pass 'add-packages.sh reports each accepted package' || fail 'add-packages.sh must report what it accepted'
|
||||||
|
grep -q 'çakışma' "$ADD_PKGS" && pass 'add-packages.sh refuses a second version of a package' || fail 'add-packages.sh must refuse a version conflict'
|
||||||
|
grep -q 'mimari uyumsuz' "$ADD_PKGS" && pass 'add-packages.sh refuses a foreign architecture' || fail 'add-packages.sh must refuse a foreign architecture'
|
||||||
|
grep -q 'geçerli bir Debian paketi' "$ADD_PKGS" && pass 'add-packages.sh refuses a file that is not a Debian package' || fail 'add-packages.sh must refuse a non-Debian file'
|
||||||
|
grep -q -- '-name .\*.deb.' "$ADD_PKGS" && pass 'add-packages.sh only admits *.deb candidates' || fail 'add-packages.sh must filter on the .deb extension'
|
||||||
|
if grep -qE '^[^#]*manifests/\*\.txt.*>[[:space:]]*"' "$ADD_PKGS" || grep -qE '>[[:space:]]*"\$MANIFEST' "$ADD_PKGS"; then
|
||||||
|
fail 'add-packages.sh must never rewrite manifests/*.txt'
|
||||||
|
else
|
||||||
|
pass 'add-packages.sh never rewrites the manifests'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# build-index.sh: the bundle contract, fail-closed behaviour, stable rebuilds.
|
||||||
|
grep -q 'apt-ftparchive' "$BUILD_INDEX" && pass 'build-index.sh builds the index with apt-ftparchive' || fail 'build-index.sh must use apt-ftparchive'
|
||||||
|
grep -q 'apt-utils' "$BUILD_INDEX" && pass 'build-index.sh names the apt-utils package that carries the tool' || fail 'build-index.sh must say how to install apt-ftparchive'
|
||||||
|
grep -q 'package\\tversion\\tarchitecture\\tsha256\\tsource' "$BUILD_INDEX" \
|
||||||
|
&& pass 'build-index.sh writes the documented lock header' \
|
||||||
|
|| fail 'build-index.sh must write the documented lock header'
|
||||||
|
grep -q -- "-name '\*.txt'" "$BUILD_INDEX" && pass 'build-index.sh reads every manifests/*.txt pin file' || fail 'build-index.sh must read every pin manifest'
|
||||||
|
grep -q 'ekş\|eksik' "$BUILD_INDEX" && pass 'build-index.sh reports the missing packages' || fail 'build-index.sh must report missing packages'
|
||||||
|
grep -q 'paketler eksik' "$BUILD_INDEX" && pass 'build-index.sh names the missing package list explicitly' || fail 'build-index.sh must print an explicit missing package list'
|
||||||
|
grep -q 'YAZILMADI' "$BUILD_INDEX" && pass 'build-index.sh writes no bundle while a pin is unsatisfied' || fail 'build-index.sh must refuse to write an incomplete bundle'
|
||||||
|
grep -q 'canonicalize_stanzas' "$BUILD_INDEX" && pass 'build-index.sh orders the index canonically' || fail 'build-index.sh must order the index canonically'
|
||||||
|
grep -q 'DEĞİŞİKLİK YOK' "$BUILD_INDEX" && pass 'build-index.sh detects an unchanged bundle' || fail 'build-index.sh must detect an unchanged bundle'
|
||||||
|
grep -q 'repo-manifest.yaml' "$BUILD_INDEX" && pass 'build-index.sh maintains repo-manifest.yaml' || fail 'build-index.sh must maintain repo-manifest.yaml'
|
||||||
|
grep -q 'bu depoya ait olmayan dosya' "$BUILD_INDEX" && pass 'build-index.sh refuses to delete a foreign output directory' || fail 'build-index.sh must not clobber a foreign output directory'
|
||||||
|
grep -q 'git add -f' "$BUILD_INDEX" && pass 'build-index.sh prints the git add -f step the .gitignore requires' || fail 'build-index.sh must print the commit command'
|
||||||
|
grep -q 'built' "$BUILD_INDEX" && pass 'build-index.sh documents the built/ bundle path' || fail 'build-index.sh must document the bundle path'
|
||||||
|
|
||||||
|
# repo-manifest.yaml is the repository record: shape, not data, at rest.
|
||||||
|
grep -q '^schema_version: 1$' "$REPO_MANIFEST" && pass 'repo-manifest.yaml carries a schema version' || fail 'repo-manifest.yaml must carry a schema version'
|
||||||
|
for key in ubuntu_release architecture generated_at package_count total_bytes index_sha256 packages; do
|
||||||
|
grep -q "^ *$key:" "$REPO_MANIFEST" || fail "repo-manifest.yaml does not declare $key"
|
||||||
|
done
|
||||||
|
grep -q "^ architecture: 'amd64'$" "$REPO_MANIFEST" && pass 'repo-manifest.yaml states the amd64 target' || fail 'repo-manifest.yaml must state the target architecture'
|
||||||
|
grep -q "^ state: 'UNBUILT'$" "$REPO_MANIFEST" && pass 'repo-manifest.yaml is shipped in the unbuilt state' || fail 'the shipped repo-manifest.yaml must be marked UNBUILT'
|
||||||
|
grep -q '^packages: \[\]$' "$REPO_MANIFEST" && pass 'repo-manifest.yaml ships no package rows' || fail 'the shipped repo-manifest.yaml must not carry package rows'
|
||||||
|
if python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||||
|
python3 - "$REPO_MANIFEST" <<'PY' && pass 'repo-manifest.yaml is valid YAML' || fail 'repo-manifest.yaml is not valid YAML'
|
||||||
|
import sys, yaml
|
||||||
|
with open(sys.argv[1], encoding='utf-8') as handle:
|
||||||
|
data = yaml.safe_load(handle)
|
||||||
|
assert data['schema_version'] == 1
|
||||||
|
assert data['repository']['architecture'] == 'amd64'
|
||||||
|
assert data['repository']['package_count'] == 0
|
||||||
|
assert data['packages'] == []
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
pass 'repo-manifest.yaml YAML parse skipped (PyYAML unavailable)'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ README is Turkish --
|
||||||
|
README_FILE="$OFFLINE_REPO/README.md"
|
||||||
|
require_file "$README_FILE"
|
||||||
|
grep -q 'repo sorumlusu' "$README_FILE" && pass 'README explains the repository owner role' || fail 'README must explain who supplies the packages'
|
||||||
|
grep -q 'indirmez\|indirme yapmaz' "$README_FILE" && pass 'README states that the installer downloads nothing' || fail 'README must state that the offline installer downloads nothing'
|
||||||
|
grep -q 'OFFLINE BLOCKED' "$README_FILE" && pass 'README documents the OFFLINE BLOCKED failures' || fail 'README must document OFFLINE BLOCKED'
|
||||||
|
grep -q 'requirements.tsv' "$README_FILE" && pass 'README points at the audit table' || fail 'README must reference requirements.tsv'
|
||||||
|
grep -q 'add-packages.sh' "$README_FILE" && grep -q 'build-index.sh' "$README_FILE" \
|
||||||
|
&& pass 'README documents both owner tools' \
|
||||||
|
|| fail 'README must document add-packages.sh and build-index.sh'
|
||||||
|
grep -q 'built/' "$README_FILE" && pass 'README documents the bundle directory' || fail 'README must document the built/ layout'
|
||||||
|
grep -q 'offline-repo' "$README_FILE" && pass 'README names the install path of the bundle' || fail 'README must name the install path'
|
||||||
|
grep -q 'UNPINNED' "$README_FILE" && pass 'README explains the UNPINNED sentinel' || fail 'README must explain the UNPINNED sentinel'
|
||||||
|
grep -q 'git add -f' "$README_FILE" && pass 'README explains the git add -f requirement' || fail 'README must explain the git add -f requirement'
|
||||||
|
for package in curl openssh-server wireguard-tools smartmontools ufw prometheus-node-exporter xrdp rustdesk docker-ce containerd.io; do
|
||||||
|
grep -q "$package" "$README_FILE" || fail "README does not explain why $package is needed"
|
||||||
|
done
|
||||||
|
pass 'README lists the pinned packages with their reason'
|
||||||
|
|
||||||
|
# ------------------------------------------ stub tools (this machine has no apt) --
|
||||||
|
# apt-ftparchive and dpkg-deb exist on the Ubuntu build host only. These two stubs
|
||||||
|
# reproduce their observable interface so the owner tooling is exercised end to end
|
||||||
|
# here; they are created inside the temporary directory and never committed, and a
|
||||||
|
# passing run is not a substitute for one real build on the Ubuntu host.
|
||||||
|
STUB_BIN="$TMP/stub-bin"
|
||||||
|
mkdir -p "$STUB_BIN"
|
||||||
|
cat > "$STUB_BIN/dpkg-deb" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test stub: real dpkg-deb reads the control member; the identity is read from
|
||||||
|
# the <package>_<version>_<arch>.deb file name instead.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "${1:-}" == '-f' ]] || exit 1
|
||||||
|
file="${2:-}"
|
||||||
|
shift 2
|
||||||
|
base="$(basename "$file")"
|
||||||
|
case "$base" in
|
||||||
|
*_*_*.deb) ;;
|
||||||
|
*) printf 'stub: cannot read a control file from %s\n' "$base" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
name="${base%.deb}"; arch="${name##*_}"; rest="${name%_*}"
|
||||||
|
version="${rest##*_}"; package="${rest%_*}"
|
||||||
|
[[ -n "$package" && -n "$version" && -n "$arch" ]] || exit 1
|
||||||
|
for field in "$@"; do
|
||||||
|
case "$field" in
|
||||||
|
Package) printf '%s\n' "$package" ;;
|
||||||
|
Version) printf '%s\n' "$version" ;;
|
||||||
|
Architecture) printf '%s\n' "$arch" ;;
|
||||||
|
*) printf '\n' ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
STUB
|
||||||
|
cat > "$STUB_BIN/apt-ftparchive" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test stub: emits one Packages stanza per .deb, in reverse file order, so the
|
||||||
|
# builder's canonical ordering has to do real work.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "${1:-}" == 'packages' ]] || exit 1
|
||||||
|
dir="${2:-pool}"
|
||||||
|
first=1
|
||||||
|
while IFS= read -r deb; do
|
||||||
|
base="$(basename "$deb")"
|
||||||
|
name="${base%.deb}"; arch="${name##*_}"; rest="${name%_*}"
|
||||||
|
version="${rest##*_}"; package="${rest%_*}"
|
||||||
|
[[ "$first" -eq 1 ]] || printf '\n'
|
||||||
|
first=0
|
||||||
|
printf 'Package: %s\nVersion: %s\nArchitecture: %s\nFilename: %s/%s\nSize: %s\nSHA256: %s\n' \
|
||||||
|
"$package" "$version" "$arch" "$dir" "$base" "$(stat -c %s "$deb")" "$(sha256sum "$deb" | awk '{print $1}')"
|
||||||
|
done < <(find "$dir" -maxdepth 1 -type f -name '*.deb' | sort -r)
|
||||||
|
STUB
|
||||||
|
chmod +x "$STUB_BIN/dpkg-deb" "$STUB_BIN/apt-ftparchive"
|
||||||
|
export PATH="$STUB_BIN:$PATH"
|
||||||
|
|
||||||
|
new_fixture; fill_fixture
|
||||||
|
DEB_DIR="$TMP/debs"; BAD_DIR="$TMP/bad-debs"; PKG_OUT="$TMP/owner-packages"
|
||||||
|
mkdir -p "$DEB_DIR" "$BAD_DIR"
|
||||||
|
while IFS= read -r pin; do
|
||||||
|
[[ -z "$pin" || "$pin" == \#* ]] && continue
|
||||||
|
printf 'stub %s\n' "$pin" > "$DEB_DIR/${pin%%=*}_${pin#*=}_amd64.deb"
|
||||||
|
done < <(cat "$TMP/m/base-packages.txt" "$TMP/m/remote-access.txt" "$TMP/m/docker.txt")
|
||||||
|
printf 'stub arm64\n' > "$BAD_DIR/stub-arm_1.0-lab-fixture_arm64.deb"
|
||||||
|
printf 'this is not a debian package\n' > "$BAD_DIR/garbage.deb"
|
||||||
|
FIXTURE_PINS="$(grep -hvE '^[[:space:]]*(#|$)' "$TMP/m"/*.txt | wc -l)"
|
||||||
|
FIXTURE_PINS="$((FIXTURE_PINS))"
|
||||||
|
|
||||||
|
# --check must validate and mutate nothing.
|
||||||
|
[[ "$FIXTURE_PINS" -gt 0 ]] && pass "fixture declares $FIXTURE_PINS pins for the owner tooling"
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" --check 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] && pass 'add-packages.sh --check accepts a matching pool' || { fail "add-packages.sh --check failed (exit $rc)"; printf '%s\n' "$out" >&2; }
|
||||||
|
[[ ! -e "$PKG_OUT" ]] && pass 'add-packages.sh --check copies nothing' || fail 'add-packages.sh --check must not copy'
|
||||||
|
grep -q 'EKLENECEK' <<<"$out" && pass 'add-packages.sh --check previews the accepted packages' || fail 'add-packages.sh --check must preview the packages'
|
||||||
|
|
||||||
|
# Real intake, twice: the second run must be a no-op.
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" --manifest "$TMP/m/base-packages.txt" --include-all 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] && pass 'add-packages.sh accepts the reviewed pool' || { fail "add-packages.sh intake failed (exit $rc)"; printf '%s\n' "$out" >&2; }
|
||||||
|
copied="$(find "$PKG_OUT" -maxdepth 1 -type f -name '*.deb' | wc -l)"
|
||||||
|
[[ "$copied" -eq "$FIXTURE_PINS" ]] && pass "add-packages.sh copied all $copied packages into packages/" || fail "add-packages.sh copied $copied of $FIXTURE_PINS packages"
|
||||||
|
state_before="$(find "$PKG_OUT" -maxdepth 1 -type f -exec sha256sum {} + | sort)"
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$DEB_DIR" 2>&1)" || rc=$?
|
||||||
|
state_after="$(find "$PKG_OUT" -maxdepth 1 -type f -exec sha256sum {} + | sort)"
|
||||||
|
[[ "$rc" -eq 0 && "$state_before" == "$state_after" ]] \
|
||||||
|
&& pass 're-running add-packages.sh over the same files changes nothing' \
|
||||||
|
|| fail 'add-packages.sh is not idempotent'
|
||||||
|
grep -q 'ATLANDI' <<<"$out" && pass 'add-packages.sh reports an already accepted file instead of copying it again' || fail 'add-packages.sh must report a duplicate instead of re-copying'
|
||||||
|
|
||||||
|
# A pin that is still UNPINNED: the owner gets the exact line to fill.
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$TMP/other-packages" --from "$DEB_DIR" --manifest "$MANIFEST_DIR/base-packages.txt" --check 2>&1)" || rc=$?
|
||||||
|
if [[ "$rc" -eq 0 ]] && grep -q 'MANIFEST GÜNCELLEMESİ' <<<"$out" && grep -q 'base-packages.txt: curl=9.9.9-lab-fixture' <<<"$out"; then
|
||||||
|
pass 'add-packages.sh hands over the UNPINNED pin line with the real version'
|
||||||
|
else
|
||||||
|
fail "add-packages.sh must print the pin line to fill (exit $rc)"
|
||||||
|
printf '%s\n' "$out" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Failing intakes must change nothing and must be explicit.
|
||||||
|
printf 'stub newer\n' > "$BAD_DIR/curl_0.0.1-lab-fixture_amd64.deb"
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" --from "$BAD_DIR" --include-all 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] && pass 'add-packages.sh fails on an unusable batch' || fail 'add-packages.sh must fail on an unusable batch'
|
||||||
|
grep -q 'mimari uyumsuz' <<<"$out" && pass 'add-packages.sh names the architecture mismatch' || fail 'add-packages.sh must name the architecture mismatch'
|
||||||
|
grep -q 'geçerli bir Debian paketi' <<<"$out" && pass 'add-packages.sh names the unreadable file' || fail 'add-packages.sh must name the unreadable file'
|
||||||
|
grep -q 'çakışma' <<<"$out" && pass 'add-packages.sh refuses the second version of curl' || fail 'add-packages.sh must refuse a version conflict'
|
||||||
|
[[ ! -e "$PKG_OUT/curl_0.0.1-lab-fixture_amd64.deb" ]] && pass 'a refused batch copies no file' || fail 'add-packages.sh copied a refused file'
|
||||||
|
[[ ! -e "$PKG_OUT/garbage.deb" ]] && pass 'an unreadable file never reaches packages/' || fail 'add-packages.sh copied an unreadable file'
|
||||||
|
printf 'not a deb\n' > "$BAD_DIR/notes.txt"
|
||||||
|
rc=0; out="$(bash "$ADD_PKGS" --packages "$PKG_OUT" "$BAD_DIR/notes.txt" 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] && grep -q 'Debian paketi değil' <<<"$out" && pass 'add-packages.sh refuses a non-.deb argument' || fail 'add-packages.sh must refuse a non-.deb argument'
|
||||||
|
|
||||||
|
# build-index.sh --check: contract verified, nothing written.
|
||||||
|
BAD_OUT="$TMP/never-written"
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$BAD_OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/never.yaml" --check 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] && pass 'build-index.sh --check accepts a complete pool' || { fail "build-index.sh --check failed (exit $rc)"; printf '%s\n' "$out" >&2; }
|
||||||
|
[[ ! -e "$BAD_OUT" && ! -e "$TMP/never.yaml" ]] && pass 'build-index.sh --check writes nothing' || fail 'build-index.sh --check must not write'
|
||||||
|
|
||||||
|
# A refused output path must be refused before anything is removed.
|
||||||
|
FAKE_HOME="$TMP/fake-home"; mkdir -p "$FAKE_HOME"
|
||||||
|
rc=0; out="$(HOME="$FAKE_HOME" bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$FAKE_HOME" --manifest-dir "$TMP/m" --check 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 2 && -d "$FAKE_HOME" ]] && pass 'build-index.sh refuses an unsafe --output path' || fail "an unsafe --output must be a usage error (exit $rc)"
|
||||||
|
FOREIGN_OUT="$TMP/foreign-out"; mkdir -p "$FOREIGN_OUT"; printf 'keep me\n' > "$FOREIGN_OUT/junk.txt"
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$FOREIGN_OUT" --manifest-dir "$TMP/m" 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] && grep -q 'bu depoya ait olmayan dosya' <<<"$out" && pass 'build-index.sh refuses a foreign output directory' || fail 'build-index.sh must refuse a foreign output directory'
|
||||||
|
[[ -f "$FOREIGN_OUT/junk.txt" ]] && pass 'a refused build deletes nothing' || fail 'a refused build must not delete anything'
|
||||||
|
|
||||||
|
# The real build.
|
||||||
|
OUT="$TMP/owner-built"
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/repo-manifest.yaml" 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 0 ]] && pass 'build-index.sh builds a bundle from the accepted pool' || { fail "build-index.sh build failed (exit $rc)"; printf '%s\n' "$out" >&2; }
|
||||||
|
for artifact in Packages Packages.gz packages.lock.tsv SHA256SUMS; do
|
||||||
|
[[ -s "$OUT/$artifact" ]] || fail "build-index.sh did not write $artifact"
|
||||||
|
done
|
||||||
|
pool_count="$(find "$OUT/pool" -maxdepth 1 -type f -name '*.deb' | wc -l)"
|
||||||
|
[[ "$pool_count" -eq "$FIXTURE_PINS" ]] && pass "the bundle holds the whole pool ($pool_count .deb)" || fail "the bundle holds $pool_count of $FIXTURE_PINS packages"
|
||||||
|
lock_rows="$(wc -l < "$OUT/packages.lock.tsv")"
|
||||||
|
[[ "$lock_rows" -eq $((FIXTURE_PINS + 1)) ]] && pass 'packages.lock.tsv holds the header plus one row per package' || fail "packages.lock.tsv holds $lock_rows lines, expected $((FIXTURE_PINS + 1))"
|
||||||
|
[[ "$(head -n 1 "$OUT/packages.lock.tsv")" == "$LOCK_HEADER" ]] && pass 'the bundle lock header is byte-identical to the documented one' || fail 'the bundle lock header is wrong'
|
||||||
|
awk -F '\t' 'NR > 1 { if (NF != 5 || $0 ~ /\r/ || $4 !~ /^[a-f0-9]{64}$/) { print "BADROW: " $0; bad=1 } } END { exit bad }' "$OUT/packages.lock.tsv" \
|
||||||
|
&& pass 'every lock row carries five TAB-separated fields and a sha256' \
|
||||||
|
|| fail 'a lock row is malformed'
|
||||||
|
( cd "$OUT" && sha256sum -c SHA256SUMS >/dev/null ) && pass 'the bundle verifies with sha256sum -c' || fail 'the bundle does not verify'
|
||||||
|
stanza_count="$(grep -c '^Package: ' "$OUT/Packages")"
|
||||||
|
[[ "$stanza_count" -eq "$FIXTURE_PINS" ]] && pass 'Packages indexes every package' || fail "Packages indexes $stanza_count of $FIXTURE_PINS packages"
|
||||||
|
gzip_flg="$(od -An -t u1 -j 3 -N 1 "$OUT/Packages.gz" | tr -d ' ')"
|
||||||
|
gzip_mtime="$(od -An -t u1 -j 4 -N 4 "$OUT/Packages.gz" | tr -d ' \n')"
|
||||||
|
[[ "$gzip_flg" == '0' && "$gzip_mtime" == '0000' ]] \
|
||||||
|
&& pass 'Packages.gz carries no name and no timestamp (reproducible)' \
|
||||||
|
|| fail "Packages.gz is not timestamp free (FLG=$gzip_flg MTIME=$gzip_mtime)"
|
||||||
|
cmp -s <(gzip -dc "$OUT/Packages.gz") "$OUT/Packages" && pass 'Packages.gz decompresses to Packages' || fail 'Packages.gz does not match Packages'
|
||||||
|
|
||||||
|
# The repository record.
|
||||||
|
if [[ -f "$TMP/repo-manifest.yaml" ]]; then
|
||||||
|
pass 'build-index.sh writes the repository record'
|
||||||
|
if python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||||
|
python3 - "$TMP/repo-manifest.yaml" "$FIXTURE_PINS" "$OUT" <<'PY' && pass 'the repository record matches the bundle' || fail 'the repository record disagrees with the bundle'
|
||||||
|
import hashlib, os, sys, yaml
|
||||||
|
record_path, expected, out = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
||||||
|
with open(record_path, encoding='utf-8') as handle:
|
||||||
|
rec = yaml.safe_load(handle)
|
||||||
|
repo = rec['repository']
|
||||||
|
assert rec['schema_version'] == 1, rec['schema_version']
|
||||||
|
assert repo['state'] == 'BUILT', repo['state']
|
||||||
|
assert repo['architecture'] == 'amd64', repo['architecture']
|
||||||
|
assert repo['ubuntu_release'] == '26.04', repo['ubuntu_release']
|
||||||
|
assert repo['package_count'] == expected, repo['package_count']
|
||||||
|
assert repo['pinned_packages'] == expected, repo['pinned_packages']
|
||||||
|
assert repo['total_bytes'] > 0
|
||||||
|
assert len(rec['packages']) == expected, len(rec['packages'])
|
||||||
|
for row in rec['packages']:
|
||||||
|
assert len(row['sha256']) == 64, row
|
||||||
|
assert row['pinned_in'], row
|
||||||
|
assert os.path.isfile(os.path.join(out, 'pool', row['source'])), row
|
||||||
|
for name in ('Packages', 'Packages.gz', 'packages.lock.tsv'):
|
||||||
|
with open(os.path.join(out, name), 'rb') as handle:
|
||||||
|
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
assert repo['index_sha256'][name] == digest, (name, digest)
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
pass 'repository record parse skipped (PyYAML unavailable)'
|
||||||
|
fi
|
||||||
|
grep -q "^ package_count: $FIXTURE_PINS$" "$TMP/repo-manifest.yaml" && pass 'the record counts every package' || fail 'the record has the wrong package_count'
|
||||||
|
else
|
||||||
|
fail 'build-index.sh must write the repository record'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An unchanged rebuild must be a no-op, byte for byte.
|
||||||
|
bundle_before="$(find "$OUT" -type f -exec sha256sum {} + | sort)"
|
||||||
|
record_before="$(sha256sum "$TMP/repo-manifest.yaml" | awk '{print $1}')"
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" --manifest-out "$TMP/repo-manifest.yaml" 2>&1)" || rc=$?
|
||||||
|
bundle_after="$(find "$OUT" -type f -exec sha256sum {} + | sort)"
|
||||||
|
record_after="$(sha256sum "$TMP/repo-manifest.yaml" | awk '{print $1}')"
|
||||||
|
[[ "$rc" -eq 0 && "$bundle_before" == "$bundle_after" ]] && pass 'rebuilding an unchanged pool leaves the bundle byte-identical' || fail 'rebuilding an unchanged pool changed the bundle'
|
||||||
|
grep -q 'DEĞİŞİKLİK YOK' <<<"$out" && pass 'build-index.sh reports the unchanged bundle instead of rewriting it' || fail 'build-index.sh must report an unchanged bundle'
|
||||||
|
[[ "$record_before" == "$record_after" ]] && pass 'an unchanged rebuild leaves the repository record untouched' || fail 'an unchanged rebuild must not restamp the repository record'
|
||||||
|
git_status_note="$(git -C "$REPO_ROOT" status --porcelain -- "provisioning/offline-repo/built" 2>/dev/null || true)"
|
||||||
|
[[ -z "$git_status_note" ]] && pass 'the test build never touches the committed bundle directory' || fail "the test build wrote into provisioning/offline-repo/built: $git_status_note"
|
||||||
|
|
||||||
|
# A missing pin: report, then stop, without touching the previous bundle.
|
||||||
|
rm -f "$PKG_OUT/rustdesk_9.9.9-lab-fixture_amd64.deb"
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$OUT" --manifest-dir "$TMP/m" 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] && pass 'build-index.sh fails when a manifest pin is absent from the pool' || fail "a missing pinned package must exit 1, got $rc"
|
||||||
|
grep -q 'paketler eksik' <<<"$out" && pass 'build-index.sh prints the missing package list' || fail 'build-index.sh must print the missing package list'
|
||||||
|
grep -q 'rustdesk' <<<"$out" && pass 'the missing package is named' || fail 'the missing package must be named'
|
||||||
|
grep -q 'YAZILMADI' <<<"$out" && grep -q 'OFFLINE BLOCKED' <<<"$out" \
|
||||||
|
&& pass 'build-index.sh explains that the device would report OFFLINE BLOCKED' \
|
||||||
|
|| fail 'build-index.sh must explain the offline consequence'
|
||||||
|
[[ "$bundle_before" == "$(find "$OUT" -type f -exec sha256sum {} + | sort)" ]] && pass 'a refused build leaves the previous bundle untouched' || fail 'a refused build must not modify the previous bundle'
|
||||||
|
[[ -z "$(find "$(dirname "$OUT")" -maxdepth 1 -name '.build-index.*' -print -quit)" ]] && pass 'a refused build leaves no staging directory behind' || fail 'a refused build left a staging directory'
|
||||||
|
|
||||||
|
# An unfilled manifest pin: no bundle either.
|
||||||
|
rc=0; out="$(bash "$BUILD_INDEX" --packages "$PKG_OUT" --output "$TMP/never-2" --manifest-dir "$MANIFEST_DIR" 2>&1)" || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] && pass 'build-index.sh refuses while a pin still reads UNPINNED' || fail "an UNPINNED pin must exit 1, got $rc"
|
||||||
|
grep -q 'UNPINNED' <<<"$out" && grep -q 'eksik' <<<"$out" && pass 'the unfilled pins are listed as missing packages' || fail 'build-index.sh must list the unfilled pins'
|
||||||
|
[[ ! -e "$TMP/never-2" ]] && pass 'nothing is written while a pin is unfilled' || fail 'build-index.sh wrote a bundle for an unfilled contract'
|
||||||
|
|
||||||
|
# The canonical order is deterministic and stable: prove it on the sorter alone.
|
||||||
|
SORTED_A="$TMP/sorted-a"; SORTED_B="$TMP/sorted-b"; SORTER="$TMP/sorter.sh"
|
||||||
|
awk '/^canonicalize_stanzas\(\) \{/,/^\}/' "$BUILD_INDEX" > "$SORTER"
|
||||||
|
[[ -s "$SORTER" ]] || fail 'the canonical sorter function could not be extracted from build-index.sh'
|
||||||
|
bash -c 'set -euo pipefail
|
||||||
|
source "$1"
|
||||||
|
canonicalize_stanzas < "$2" > "$3"
|
||||||
|
canonicalize_stanzas < "$2" > "$4"
|
||||||
|
cmp -s "$3" "$4" || exit 1
|
||||||
|
canonicalize_stanzas < "$3" > "$3.recanon"
|
||||||
|
cmp -s "$3" "$3.recanon"' _ "$SORTER" "$OUT/Packages" "$SORTED_A" "$SORTED_B" \
|
||||||
|
&& pass 'the canonical index order is reproducible and self-stable' \
|
||||||
|
|| fail 'the canonical index sorter is not deterministic'
|
||||||
|
indexed_names="$(grep '^Package: ' "$OUT/Packages" | sed 's/^Package: //')"
|
||||||
|
[[ "$indexed_names" == "$(printf '%s\n' "$indexed_names" | LC_ALL=C sort)" ]] \
|
||||||
|
&& pass 'the index is ordered by package name, not by the raw pool order' \
|
||||||
|
|| fail 'the index must be ordered by package name'
|
||||||
|
|
||||||
if [[ "$FAIL" -ne 0 ]]; then
|
if [[ "$FAIL" -ne 0 ]]; then
|
||||||
printf 'check-offline-repo-static: FAILED\n' >&2
|
printf 'check-offline-repo-static: FAILED\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ filters=("$@")
|
||||||
tests=()
|
tests=()
|
||||||
while IFS= read -r script; do
|
while IFS= read -r script; do
|
||||||
tests+=("$script")
|
tests+=("$script")
|
||||||
done < <(find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.sh' ! -name "$SELF" | sort)
|
done < <(find "$SCRIPT_DIR" -maxdepth 1 -type f \( -name '*.sh' -o -name 'test_*.py' -o -name 'check_*.py' \) ! -name "$SELF" | sort)
|
||||||
|
|
||||||
if [[ ${#tests[@]} -eq 0 ]]; then
|
if [[ ${#tests[@]} -eq 0 ]]; then
|
||||||
printf 'run-all: no tests found in %s\n' "$SCRIPT_DIR" >&2
|
printf 'run-all: no tests found in %s\n' "$SCRIPT_DIR" >&2
|
||||||
|
|
@ -54,7 +54,16 @@ failures=()
|
||||||
for script in "${selected[@]}"; do
|
for script in "${selected[@]}"; do
|
||||||
name="$(basename "$script")"
|
name="$(basename "$script")"
|
||||||
printf '\n=== %s ===\n' "$name"
|
printf '\n=== %s ===\n' "$name"
|
||||||
bash "$script"
|
case "$script" in
|
||||||
|
*.py)
|
||||||
|
# Python suites run from the repository root so `import bfos` and
|
||||||
|
# `tests.test_*` module paths resolve.
|
||||||
|
( cd "$REPO_ROOT" && python3 "$script" )
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
( cd "$REPO_ROOT" && bash "$script" )
|
||||||
|
;;
|
||||||
|
esac
|
||||||
status=$?
|
status=$?
|
||||||
if [[ "$status" -eq 0 ]]; then
|
if [[ "$status" -eq 0 ]]; then
|
||||||
printf '%s\n' "--- $name: PASS"
|
printf '%s\n' "--- $name: PASS"
|
||||||
|
|
|
||||||
1375
tests/test_bfos.py
Normal file
1375
tests/test_bfos.py
Normal file
File diff suppressed because it is too large
Load diff
703
tests/test_bfos_gui_static.py
Normal file
703
tests/test_bfos_gui_static.py
Normal file
|
|
@ -0,0 +1,703 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""bfos.ui_gui -- statik testler (stdlib unittest, pencere ACMAZ).
|
||||||
|
|
||||||
|
Bu testler GUI'yi baslatmaz ve hicbir Tk penceresi olusturmaz; yalnizca
|
||||||
|
tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular:
|
||||||
|
|
||||||
|
* bayi no dogrulamasi (^[0-9]{8}$) ve Turkce hata metinleri,
|
||||||
|
* komut onizleme metni (calistirilacak komut onizlemede GORUNUR),
|
||||||
|
* yikici islem siniflandirmasi (katalog + playbook ile capraz kontrol),
|
||||||
|
* headless (SSH) tespiti ve tkinter eksikligi mesaji,
|
||||||
|
* hedef/kapi kurallari: release -> tek cihaz yok, production -> token,
|
||||||
|
* --check kipi, salt-okunur rapor plani,
|
||||||
|
* komut calistirma: canli satir akisi + cikis kodu (gercek subprocess).
|
||||||
|
|
||||||
|
Calistirma:
|
||||||
|
python3 tests/test_bfos_gui_static.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
UI_GUI_PATH = REPO_ROOT / "bfos" / "ui_gui.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ui_gui():
|
||||||
|
"""ui_gui.py'yi paket __init__'ini calistirmadan yukler (izole import)."""
|
||||||
|
if not UI_GUI_PATH.is_file():
|
||||||
|
raise unittest.SkipTest(f"bulunamadi: {UI_GUI_PATH}")
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
name = "bfos_ui_gui_static_test"
|
||||||
|
spec = importlib.util.spec_from_file_location(name, UI_GUI_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
# dataclasses kayitli bir modul bekler; bu kayit zorunludur.
|
||||||
|
sys.modules[name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
ui = _load_ui_gui()
|
||||||
|
|
||||||
|
INVENTORY_YAML = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
# gecici test envanteri (gercek cihaz degil)
|
||||||
|
all:
|
||||||
|
children:
|
||||||
|
blueforce:
|
||||||
|
children:
|
||||||
|
lab:
|
||||||
|
hosts:
|
||||||
|
bf-12010101:
|
||||||
|
ansible_host: 10.42.0.11
|
||||||
|
pilot_1:
|
||||||
|
hosts:
|
||||||
|
bf-12010193:
|
||||||
|
ansible_host: 10.42.1.11
|
||||||
|
production:
|
||||||
|
hosts:
|
||||||
|
bf-12010999:
|
||||||
|
ansible_host: 10.42.9.11
|
||||||
|
"""
|
||||||
|
).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
class StaticCase(unittest.TestCase):
|
||||||
|
"""Tum testler icin ortak kurulum: gecici envanter + backend."""
|
||||||
|
|
||||||
|
tmpdir: tempfile.TemporaryDirectory
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.tmpdir = tempfile.TemporaryDirectory(prefix="bfos-gui-test-")
|
||||||
|
cls.inventory = Path(cls.tmpdir.name) / "hosts.yml"
|
||||||
|
cls.inventory.write_text(INVENTORY_YAML, encoding="utf-8")
|
||||||
|
cls.ctx = ui.GuiContext.detect(inventory=str(cls.inventory))
|
||||||
|
cls.backend, cls.backend_note = ui.load_backend(cls.ctx)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
cls.tmpdir.cleanup()
|
||||||
|
|
||||||
|
# -- kucuk yardimcilar -------------------------------------------------
|
||||||
|
def operation(self, operation_id: str):
|
||||||
|
for op in self.backend.list_operations():
|
||||||
|
if op.id == operation_id:
|
||||||
|
return op
|
||||||
|
self.fail(f"katalogda islem yok: {operation_id}")
|
||||||
|
|
||||||
|
def available_fleet_operation(self, scope: str = "simple"):
|
||||||
|
"""Playbook'u DISKTE olan bir filo islemi (depo es zamanli degisebilir)."""
|
||||||
|
for op in self.backend.list_operations():
|
||||||
|
if op.is_fleet and op.scope == scope and op.playbook and \
|
||||||
|
ui.playbook_path(REPO_ROOT, op.playbook).is_file():
|
||||||
|
return op
|
||||||
|
self.skipTest(f"kullanilabilir {scope} kapsamli filo islemi yok")
|
||||||
|
|
||||||
|
SAMPLE_VALUES = {
|
||||||
|
"script": "/opt/blueforce/bin/deneme.sh",
|
||||||
|
"packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble",
|
||||||
|
"service": "docker",
|
||||||
|
"only": "01-precheck",
|
||||||
|
"grace": "300",
|
||||||
|
"log_lines": "500",
|
||||||
|
}
|
||||||
|
|
||||||
|
def valid_options(self, operation) -> dict:
|
||||||
|
"""Zorunlu secenekler icin gecerli ornek degerlerle sozluk uretir."""
|
||||||
|
options: dict = {}
|
||||||
|
for spec in operation.options:
|
||||||
|
value = self.SAMPLE_VALUES.get(spec.name, spec.default)
|
||||||
|
if spec.required and not value:
|
||||||
|
self.skipTest(f"{operation.id}: '{spec.name}' icin ornek deger yok")
|
||||||
|
options[spec.name] = value
|
||||||
|
return options
|
||||||
|
|
||||||
|
def dealer_target(self, dealer: str = "12010193"):
|
||||||
|
return ui.Target("dealer", dealer)
|
||||||
|
|
||||||
|
def group_target(self, group: str = "pilot_1"):
|
||||||
|
return ui.Target("group", group)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDealerValidation(StaticCase):
|
||||||
|
def test_valid_dealer_numbers(self) -> None:
|
||||||
|
for value in ("12010193", "00000000", "99999999"):
|
||||||
|
self.assertTrue(ui.validate_dealer(value), value)
|
||||||
|
self.assertIsNone(ui.dealer_error(value), value)
|
||||||
|
|
||||||
|
def test_invalid_dealer_numbers(self) -> None:
|
||||||
|
for value in ("", "1201019", "120101933", "1201019a", "1201 0193",
|
||||||
|
" 12010193", "12010193 ", "bf-12010193", "12345678"):
|
||||||
|
self.assertFalse(ui.validate_dealer(value), repr(value))
|
||||||
|
self.assertIsNotNone(ui.dealer_error(value), repr(value))
|
||||||
|
|
||||||
|
def test_error_message_is_turkish_and_actionable(self) -> None:
|
||||||
|
message = ui.dealer_error("1201019")
|
||||||
|
self.assertIn("8 hane", message)
|
||||||
|
self.assertIn("1201019", message)
|
||||||
|
self.assertIn("12010193", message) # ornek deger
|
||||||
|
self.assertIn("rakam", ui.dealer_error("abcdefgh"))
|
||||||
|
|
||||||
|
def test_dealer_derived_names(self) -> None:
|
||||||
|
self.assertEqual(ui.dealer_to_host("12010193"), "bf-12010193")
|
||||||
|
self.assertEqual(ui.dealer_to_device_id("12010193"), "BF-12010193")
|
||||||
|
self.assertEqual(ui.host_to_dealer("bf-12010193"), "12010193")
|
||||||
|
self.assertIsNone(ui.host_to_dealer("sunucu-1"))
|
||||||
|
|
||||||
|
def test_dealer_host_matches_repo_conventions(self) -> None:
|
||||||
|
# docs/02: hostname bf-<8 hane>, insan kimligi BF-<8 hane>
|
||||||
|
self.assertEqual(ui.HOST_PATTERN.pattern, r"^bf-[0-9]{8}$")
|
||||||
|
self.assertEqual(ui.DEALER_PATTERN.pattern, r"^[0-9]{8}$")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadlessAndTkinter(StaticCase):
|
||||||
|
def test_detects_headless_without_display(self) -> None:
|
||||||
|
message = ui.headless_error({})
|
||||||
|
self.assertIsNotNone(message)
|
||||||
|
self.assertIn("DISPLAY", message)
|
||||||
|
self.assertIn("SSH", message)
|
||||||
|
self.assertIn("bf-menu", message)
|
||||||
|
self.assertIn("python3 -m bfos.ui_gui", message)
|
||||||
|
|
||||||
|
def test_display_and_wayland_are_accepted(self) -> None:
|
||||||
|
self.assertIsNone(ui.headless_error({"DISPLAY": ":0"}))
|
||||||
|
self.assertIsNone(ui.headless_error({"WAYLAND_DISPLAY": "wayland-0"}))
|
||||||
|
self.assertIsNone(ui.headless_error({"DISPLAY": ":99",
|
||||||
|
"WAYLAND_DISPLAY": "wayland-9"}))
|
||||||
|
|
||||||
|
def test_tkinter_missing_message_mentions_package(self) -> None:
|
||||||
|
message = ui.tkinter_missing_message("No module named '_tkinter'")
|
||||||
|
self.assertIn("python3-tk", message)
|
||||||
|
self.assertIn("apt", message)
|
||||||
|
self.assertIn("bf-menu", message)
|
||||||
|
self.assertIn("_tkinter", message)
|
||||||
|
|
||||||
|
def test_window_start_error_is_turkish(self) -> None:
|
||||||
|
message = ui.window_start_error(RuntimeError("no display name and no $DISPLAY"))
|
||||||
|
self.assertIn("Pencere açılamadı", message)
|
||||||
|
self.assertIn("ssh -X", message)
|
||||||
|
self.assertIn("bf-menu", message)
|
||||||
|
|
||||||
|
def test_import_does_not_create_a_window(self) -> None:
|
||||||
|
# tkinter varsa bile modul import'u bir Tk koku olusturmamalidir:
|
||||||
|
# BfGui yalnizca acikca verilen bir root ile kurulabilir.
|
||||||
|
self.assertIsInstance(ui.HAVE_TKINTER, bool)
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
signature = inspect.signature(ui.BfGui.__init__)
|
||||||
|
self.assertIn("root", signature.parameters)
|
||||||
|
# Yardimcilar tkinter olmadan da cagrilabilir.
|
||||||
|
self.assertIsNone(ui.dealer_error("12010193"))
|
||||||
|
|
||||||
|
def test_main_list_prints_catalogue_without_a_window(self) -> None:
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
code = ui.main(["--list", "--inventory", str(self.inventory)])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
output = buffer.getvalue()
|
||||||
|
self.assertIn("işlem kataloğu", output)
|
||||||
|
for category in ui.CATEGORIES:
|
||||||
|
self.assertIn(category, output)
|
||||||
|
|
||||||
|
def test_main_reports_headless_with_exit_3(self) -> None:
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in ("DISPLAY", "WAYLAND_DISPLAY")}
|
||||||
|
stderr = io.StringIO()
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True), \
|
||||||
|
contextlib.redirect_stderr(stderr):
|
||||||
|
code = ui.main([])
|
||||||
|
self.assertEqual(code, 3)
|
||||||
|
self.assertIn("bf-menu", stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
class TestSimpleModeLayout(StaticCase):
|
||||||
|
def test_three_big_buttons_in_simple_mode(self) -> None:
|
||||||
|
self.assertEqual(ui.SIMPLE_MODE_BUTTONS, ("Kur", "Durum", "Çıkış"))
|
||||||
|
self.assertEqual(ui.SIMPLE_MODE_LABEL, "Detaylı mod")
|
||||||
|
|
||||||
|
def test_window_title_is_turkish(self) -> None:
|
||||||
|
self.assertEqual(ui.WINDOW_TITLE, "Blueforce Field OS — Yönetim")
|
||||||
|
|
||||||
|
def test_five_category_tabs(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
ui.CATEGORIES,
|
||||||
|
("Kurulum", "Cihaz işlemleri", "Merkez hazırlığı", "Araçlar", "Cihaz bilgisi"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogue(StaticCase):
|
||||||
|
def test_operations_have_unique_ids_and_known_categories(self) -> None:
|
||||||
|
operations = self.backend.list_operations()
|
||||||
|
self.assertGreaterEqual(len(operations), 21)
|
||||||
|
ids = [op.id for op in operations]
|
||||||
|
self.assertEqual(len(ids), len(set(ids)), "islem id'leri tekil olmali")
|
||||||
|
for op in operations:
|
||||||
|
self.assertIn(op.category, ui.CATEGORIES, op.id)
|
||||||
|
self.assertTrue(op.label.strip(), op.id)
|
||||||
|
|
||||||
|
def test_every_category_has_at_least_one_operation(self) -> None:
|
||||||
|
for category in ui.CATEGORIES:
|
||||||
|
with self.subTest(category=category):
|
||||||
|
self.assertTrue(
|
||||||
|
[op for op in self.backend.list_operations() if op.category == category],
|
||||||
|
f"{category} kategorisi bos",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fleet_operation_playbooks_match_declared_scope(self) -> None:
|
||||||
|
"""Konsol tablosu playbook'la uyusmali; playbook yoksa CALISTIRMA reddedilir.
|
||||||
|
|
||||||
|
(bf_playbook_check + fail-closed kuralinin statik karsiligi.)
|
||||||
|
"""
|
||||||
|
checked = 0
|
||||||
|
missing: list[str] = []
|
||||||
|
for op in self.backend.list_operations():
|
||||||
|
if not op.is_fleet:
|
||||||
|
continue
|
||||||
|
book = ui.playbook_path(REPO_ROOT, op.playbook)
|
||||||
|
if not book.is_file():
|
||||||
|
# Eksik playbook SESSIZ gecilemez: komut uretimi reddedilmeli.
|
||||||
|
missing.append(f"{op.id} ({op.playbook})")
|
||||||
|
with self.subTest(operation=op.id):
|
||||||
|
with self.assertRaises(ui.CommandError):
|
||||||
|
self.backend.build_command(op, self.group_target())
|
||||||
|
continue
|
||||||
|
with self.subTest(operation=op.id):
|
||||||
|
self.assertEqual(
|
||||||
|
ui.playbook_scope(book), op.scope,
|
||||||
|
f"{op.playbook}: tablo '{op.scope}' derken playbook "
|
||||||
|
f"'{ui.playbook_scope(book)}' beyan ediyor",
|
||||||
|
)
|
||||||
|
checked += 1
|
||||||
|
self.assertGreaterEqual(checked, 15)
|
||||||
|
if missing:
|
||||||
|
print(f"\n[UYARI] bu checkout'ta playbook dosyası eksik: {', '.join(missing)}"
|
||||||
|
" -> ilgili işlemler fail-closed olarak reddediliyor")
|
||||||
|
|
||||||
|
def test_every_ansible_playbook_is_covered_or_known(self) -> None:
|
||||||
|
"""Katalog disinda kalan playbook varsa gorunur olmali (sessiz kapsam disi yok)."""
|
||||||
|
catalogue = {op.playbook for op in self.backend.list_operations() if op.is_fleet}
|
||||||
|
on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")}
|
||||||
|
self.assertEqual(on_disk - catalogue, set())
|
||||||
|
|
||||||
|
def test_operation_ids_match_admin_bf_menu_catalogue(self) -> None:
|
||||||
|
"""id'ler admin/bf-menu tablosuyla ayni olmali (iki giris noktasi ayrisamaz)."""
|
||||||
|
menu = (REPO_ROOT / "admin" / "bf-menu").read_text(encoding="utf-8")
|
||||||
|
for op in self.backend.list_operations():
|
||||||
|
if op.is_fleet:
|
||||||
|
with self.subTest(operation=op.id):
|
||||||
|
self.assertIn(f'"{op.id}|', menu)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDestructiveClassification(StaticCase):
|
||||||
|
YIKICI = ("rdp-restart", "rustdesk-restart", "wireguard-restart", "service-restart",
|
||||||
|
"gui-off", "gui-on", "run-script", "reboot", "deploy-update",
|
||||||
|
"install-local", "install-resume", "install-module", "tools-git-pull")
|
||||||
|
SALT_OKUNUR = ("ping", "uptime", "disk", "docker", "packages", "security-updates",
|
||||||
|
"provisioning-status", "enrollment-status", "fieldos-version",
|
||||||
|
"remote-channels", "offline-ready", "collect-logs", "install-preflight",
|
||||||
|
"install-state", "install-log", "central-targets", "info-summary",
|
||||||
|
"tools-release", "tools-support-bundle")
|
||||||
|
|
||||||
|
def test_destructive_ids_are_flagged(self) -> None:
|
||||||
|
flagged = set(ui.destructive_operations(self.backend.list_operations()))
|
||||||
|
for operation_id in self.YIKICI:
|
||||||
|
with self.subTest(operation=operation_id):
|
||||||
|
self.assertIn(operation_id, flagged)
|
||||||
|
self.assertTrue(ui.operation_is_destructive(operation_id))
|
||||||
|
|
||||||
|
def test_read_only_ids_are_not_flagged(self) -> None:
|
||||||
|
flagged = set(ui.destructive_operations(self.backend.list_operations()))
|
||||||
|
for operation_id in self.SALT_OKUNUR:
|
||||||
|
with self.subTest(operation=operation_id):
|
||||||
|
self.assertNotIn(operation_id, flagged)
|
||||||
|
self.assertFalse(ui.operation_is_destructive(operation_id))
|
||||||
|
|
||||||
|
def test_destructive_flag_reaches_the_plan(self) -> None:
|
||||||
|
plan = self.backend.build_command(self.operation("reboot"), self.group_target(),
|
||||||
|
{"grace": "300"})
|
||||||
|
self.assertTrue(plan.destructive)
|
||||||
|
plan = self.backend.build_command(self.operation("ping"), self.group_target())
|
||||||
|
self.assertFalse(plan.destructive)
|
||||||
|
|
||||||
|
def test_unknown_operation_is_not_destructive(self) -> None:
|
||||||
|
self.assertFalse(ui.operation_is_destructive("boyle-bir-islem-yok"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandPreview(StaticCase):
|
||||||
|
def test_fleet_preview_shows_the_exact_command(self) -> None:
|
||||||
|
operation = self.available_fleet_operation()
|
||||||
|
target = self.dealer_target()
|
||||||
|
plan = self.backend.build_command(operation, target, self.valid_options(operation))
|
||||||
|
preview = ui.preview_text(plan, operation=operation, target=target)
|
||||||
|
self.assertTrue(plan.lines, "en az bir komut satiri uretilmeli")
|
||||||
|
for line in plan.lines:
|
||||||
|
self.assertIn(line, preview)
|
||||||
|
self.assertIn("ansible-playbook", preview)
|
||||||
|
self.assertIn("--limit", preview)
|
||||||
|
self.assertIn("bf-12010193", preview)
|
||||||
|
self.assertIn(operation.playbook, preview)
|
||||||
|
self.assertIn("Çalıştırılacak komut", preview)
|
||||||
|
|
||||||
|
def test_preview_marks_destructive_operations(self) -> None:
|
||||||
|
operation = self.operation("deploy-update")
|
||||||
|
plan = self.backend.build_command(
|
||||||
|
operation, self.group_target(),
|
||||||
|
{"packages": "docker-ce=5:27.0.3-1~ubuntu.24.04~noble"},
|
||||||
|
)
|
||||||
|
preview = ui.preview_text(plan, operation=operation)
|
||||||
|
self.assertIn("UYARI", preview)
|
||||||
|
self.assertIn("YIKICI", preview)
|
||||||
|
self.assertIn("update_packages=", preview)
|
||||||
|
|
||||||
|
def test_preview_lists_target_and_scope_in_turkish(self) -> None:
|
||||||
|
operation = self.operation("provisioning-status")
|
||||||
|
target = self.group_target()
|
||||||
|
plan = self.backend.build_command(operation, target)
|
||||||
|
preview = ui.preview_text(plan, operation=operation, target=target)
|
||||||
|
self.assertIn("dalga", preview.lower())
|
||||||
|
self.assertIn("dalga kapılı", preview)
|
||||||
|
|
||||||
|
def test_preview_of_report_only_plan_says_nothing_runs(self) -> None:
|
||||||
|
operation = self.operation("central-targets")
|
||||||
|
plan = self.backend.build_command(operation, None)
|
||||||
|
self.assertTrue(plan.is_report_only)
|
||||||
|
preview = ui.preview_text(plan, operation=operation)
|
||||||
|
self.assertIn("komut çalıştırmaz", preview)
|
||||||
|
self.assertNotIn("Çalıştırılacak komut(lar)", preview)
|
||||||
|
|
||||||
|
def test_check_mode_is_visible_in_command_and_preview(self) -> None:
|
||||||
|
operation = self.available_fleet_operation()
|
||||||
|
target = self.group_target()
|
||||||
|
plan = self.backend.build_command(
|
||||||
|
operation, target, self.valid_options(operation), check_mode=True
|
||||||
|
)
|
||||||
|
self.assertTrue(plan.check_mode)
|
||||||
|
self.assertIn("--check", " ".join(plan.lines))
|
||||||
|
preview = ui.preview_text(plan, operation=operation, target=target)
|
||||||
|
self.assertIn("--check", preview)
|
||||||
|
self.assertIn("değişiklik uygulanmaz", preview)
|
||||||
|
|
||||||
|
def test_options_change_the_previewed_command(self) -> None:
|
||||||
|
operation = self.operation("disk")
|
||||||
|
options = {spec.name: "75" for spec in operation.options if "disk" in spec.name}
|
||||||
|
if not options:
|
||||||
|
self.skipTest("disk isleminin esik secenegi yok")
|
||||||
|
plan = self.backend.build_command(operation, self.group_target(), options)
|
||||||
|
self.assertIn("disk_warn_pct=75", " ".join(plan.lines))
|
||||||
|
|
||||||
|
def test_invalid_option_is_refused_with_turkish_message(self) -> None:
|
||||||
|
operation = self.operation("service-restart")
|
||||||
|
name = "service" if any(s.name == "service" for s in operation.options) else "service_name"
|
||||||
|
with self.assertRaises(ui.CommandError) as ctx:
|
||||||
|
self.backend.build_command(operation, self.group_target(), {name: "kritik-servis"})
|
||||||
|
self.assertIn("izinli", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_missing_required_option_is_refused(self) -> None:
|
||||||
|
operation = self.operation("deploy-update")
|
||||||
|
name = "packages" if any(s.name == "packages" for s in operation.options) \
|
||||||
|
else "update_packages"
|
||||||
|
with self.assertRaises(ui.CommandError):
|
||||||
|
self.backend.build_command(operation, self.group_target(), {name: ""})
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatesAndTargets(StaticCase):
|
||||||
|
def test_wave_operation_requires_wave_token(self) -> None:
|
||||||
|
# A wave-scoped READ-ONLY report asks for no gate token -- the bash
|
||||||
|
# console behaves the same way (see bfos/operations.confirmation_plan:
|
||||||
|
# only release-gated runs and the production wave ask for a written
|
||||||
|
# token). What must hold is that the built command carries the wave
|
||||||
|
# scope and the confirmation flag, so the playbook's own gate accepts it.
|
||||||
|
steps = self.backend.gate_steps(self.operation("provisioning-status"),
|
||||||
|
self.group_target())
|
||||||
|
tokens = [s.token for s in steps if s.kind == "token"]
|
||||||
|
self.assertEqual([], tokens,
|
||||||
|
"a read-only wave report must not ask for a gate token")
|
||||||
|
plan = self.backend.build_command(self.operation("provisioning-status"),
|
||||||
|
self.group_target(), {}, {})
|
||||||
|
argv = list(plan.commands[0])
|
||||||
|
self.assertIn("operation_wave=pilot_1", argv)
|
||||||
|
self.assertIn("operation_confirmed=true", argv)
|
||||||
|
|
||||||
|
def test_release_gated_operation_requires_wave_token(self) -> None:
|
||||||
|
# The gate that must never disappear: reboot/deploy-update are
|
||||||
|
# release-gated, so the wave name has to be typed before anything runs.
|
||||||
|
steps = self.backend.gate_steps(self.operation("reboot"),
|
||||||
|
self.group_target())
|
||||||
|
tokens = [s.token for s in steps if s.kind == "token"]
|
||||||
|
self.assertIn("pilot_1", tokens)
|
||||||
|
|
||||||
|
def test_destructive_operation_requires_second_approval(self) -> None:
|
||||||
|
steps = self.backend.gate_steps(self.operation("gui-off"), self.group_target())
|
||||||
|
self.assertTrue([s for s in steps if s.kind == "approval"], steps)
|
||||||
|
|
||||||
|
def test_production_wave_requires_production_token(self) -> None:
|
||||||
|
steps = self.backend.gate_steps(self.operation("gui-off"),
|
||||||
|
self.group_target("production"))
|
||||||
|
tokens = [s.token for s in steps if s.kind == "token"]
|
||||||
|
self.assertIn("production", tokens)
|
||||||
|
unlocks = {name for step in steps for name in step.unlocks}
|
||||||
|
self.assertIn("production_override", unlocks)
|
||||||
|
|
||||||
|
def test_release_scope_refuses_single_device_and_fleet(self) -> None:
|
||||||
|
operation = self.operation("reboot")
|
||||||
|
with self.assertRaises(ui.CommandError) as ctx:
|
||||||
|
self.backend.build_command(operation, self.dealer_target(), {"grace": "300"})
|
||||||
|
self.assertIn("dalga", str(ctx.exception).lower())
|
||||||
|
with self.assertRaises(ui.CommandError):
|
||||||
|
self.backend.build_command(operation, ui.Target("all", "all"), {"grace": "300"})
|
||||||
|
|
||||||
|
def test_wave_scope_refuses_a_non_wave_group(self) -> None:
|
||||||
|
operation = self.operation("gui-on")
|
||||||
|
with self.assertRaises(ui.CommandError):
|
||||||
|
self.backend.build_command(operation, self.group_target("blueforce"))
|
||||||
|
|
||||||
|
def test_wave_scope_on_a_dealer_resolves_the_wave(self) -> None:
|
||||||
|
plan = self.backend.build_command(self.operation("gui-off"), self.dealer_target())
|
||||||
|
line = " ".join(plan.lines)
|
||||||
|
self.assertIn("operation_wave=pilot_1", line)
|
||||||
|
self.assertIn("operation_confirmed=true", line)
|
||||||
|
self.assertIn("bf-12010193", line)
|
||||||
|
|
||||||
|
def test_unknown_dealer_outside_inventory_is_refused(self) -> None:
|
||||||
|
operation = self.available_fleet_operation()
|
||||||
|
with self.assertRaises(ui.CommandError) as ctx:
|
||||||
|
self.backend.build_command(operation, self.dealer_target("12010000"))
|
||||||
|
self.assertIn("12010000", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_invalid_dealer_never_builds_a_command(self) -> None:
|
||||||
|
operation = self.available_fleet_operation()
|
||||||
|
with self.assertRaises(ui.CommandError):
|
||||||
|
self.backend.build_command(operation, self.dealer_target("123"))
|
||||||
|
|
||||||
|
def test_grants_change_the_gated_command(self) -> None:
|
||||||
|
operation = self.operation("reboot")
|
||||||
|
target = self.group_target("production")
|
||||||
|
options = {"grace": "120"}
|
||||||
|
if not any(s.name == "grace" for s in operation.options):
|
||||||
|
options = {"reboot_grace_seconds": "120"}
|
||||||
|
draft = self.backend.build_command(operation, target, options)
|
||||||
|
final = self.backend.build_command(
|
||||||
|
operation, target, options,
|
||||||
|
grants={"wave_gate_confirmed": True, "production_override": True,
|
||||||
|
"allow_production_reboot": True},
|
||||||
|
)
|
||||||
|
joined = " ".join(final.lines)
|
||||||
|
self.assertIn("allow_production_reboot=true", joined)
|
||||||
|
self.assertNotIn("allow_production_reboot=true", " ".join(draft.lines))
|
||||||
|
self.assertNotEqual(draft.lines, final.lines)
|
||||||
|
|
||||||
|
def test_no_gate_flag_is_ever_added_silently(self) -> None:
|
||||||
|
"""Onaysiz hicbir komut kapi bayragi tasimamali (fail-closed)."""
|
||||||
|
checked = 0
|
||||||
|
for operation in self.backend.list_operations():
|
||||||
|
if not operation.is_fleet or operation.scope == "simple":
|
||||||
|
continue
|
||||||
|
target = self.group_target("production" if operation.scope == "release"
|
||||||
|
else "pilot_1")
|
||||||
|
options = self.valid_options(operation)
|
||||||
|
try:
|
||||||
|
plan = self.backend.build_command(operation, target, options)
|
||||||
|
except ui.CommandError:
|
||||||
|
# Playbook eksik/kapi reddi: komut URETILMEDI, bu da guvenli.
|
||||||
|
continue
|
||||||
|
with self.subTest(operation=operation.id):
|
||||||
|
joined = " ".join(plan.lines)
|
||||||
|
for flag in ("wave_gate_confirmed=true", "production_override=true",
|
||||||
|
"allow_production_reboot=true"):
|
||||||
|
self.assertNotIn(flag, joined,
|
||||||
|
f"{operation.id}: onaysiz kapi bayragi uretildi")
|
||||||
|
checked += 1
|
||||||
|
self.assertGreaterEqual(checked, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerStreaming(StaticCase):
|
||||||
|
def test_run_command_streams_lines_and_returns_exit_code(self) -> None:
|
||||||
|
lines: list[str] = []
|
||||||
|
result = ui.run_command(
|
||||||
|
[sys.executable, "-c", "print('merhaba'); print('dunya')"],
|
||||||
|
on_line=lines.append,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertEqual(lines, ["merhaba", "dunya"])
|
||||||
|
self.assertIn("merhaba", result.output)
|
||||||
|
|
||||||
|
def test_run_command_reports_nonzero_exit_code(self) -> None:
|
||||||
|
result = ui.run_command([sys.executable, "-c", "import sys; sys.exit(3)"])
|
||||||
|
self.assertEqual(result.exit_code, 3)
|
||||||
|
self.assertFalse(result.ok)
|
||||||
|
|
||||||
|
def test_missing_command_gives_turkish_message(self) -> None:
|
||||||
|
result = ui.run_command(["boyle-bir-komut-yok-12345"])
|
||||||
|
self.assertEqual(result.exit_code, 127)
|
||||||
|
self.assertFalse(result.ok)
|
||||||
|
self.assertIn("bulunamadı", result.output)
|
||||||
|
self.assertIn("bf-bootstrap", result.output)
|
||||||
|
|
||||||
|
def test_run_plan_stops_at_the_first_failure(self) -> None:
|
||||||
|
plan = ui.CommandPlan(
|
||||||
|
commands=(
|
||||||
|
(sys.executable, "-c", "import sys; print('ilk'); sys.exit(4)"),
|
||||||
|
(sys.executable, "-c", "print('IKINCI-CALISMAMALI')"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines: list[str] = []
|
||||||
|
result = ui.run_plan(plan, on_line=lines.append)
|
||||||
|
self.assertEqual(result.exit_code, 4)
|
||||||
|
self.assertFalse(result.ok)
|
||||||
|
self.assertIn("ilk", lines)
|
||||||
|
self.assertNotIn("IKINCI-CALISMAMALI", lines)
|
||||||
|
|
||||||
|
def test_run_plan_executes_nothing_for_a_report_plan(self) -> None:
|
||||||
|
plan = ui.CommandPlan(report_lines=("satır 1", "satır 2"))
|
||||||
|
lines: list[str] = []
|
||||||
|
result = ui.run_plan(plan, on_line=lines.append)
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
self.assertEqual(lines, ["satır 1", "satır 2"])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
|
||||||
|
def test_dry_run_plan_does_not_execute(self) -> None:
|
||||||
|
plan = ui.CommandPlan(
|
||||||
|
commands=((sys.executable, "-c", "print('CALISMAMALI')"),),
|
||||||
|
dry_run=True,
|
||||||
|
)
|
||||||
|
lines: list[str] = []
|
||||||
|
result = ui.run_plan(plan, on_line=lines.append)
|
||||||
|
self.assertTrue(result.ok)
|
||||||
|
# Yalnizca "[deneme] <komut>" satiri yazilir; komutun CIKTISI yazilmaz.
|
||||||
|
self.assertTrue(lines)
|
||||||
|
self.assertTrue(all(line.startswith("[deneme] ") for line in lines), lines)
|
||||||
|
self.assertNotIn("CALISMAMALI", result.output)
|
||||||
|
self.assertIn("[deneme]", result.output)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInventoryHelpers(StaticCase):
|
||||||
|
def test_parses_groups_and_hosts(self) -> None:
|
||||||
|
groups = ui.parse_inventory(self.inventory)
|
||||||
|
self.assertEqual(groups.get("pilot_1"), ["bf-12010193"])
|
||||||
|
self.assertEqual(groups.get("lab"), ["bf-12010101"])
|
||||||
|
self.assertEqual(groups.get("production"), ["bf-12010999"])
|
||||||
|
|
||||||
|
def test_wave_list_follows_rollout_order(self) -> None:
|
||||||
|
groups = ui.parse_inventory(self.inventory)
|
||||||
|
self.assertEqual(ui.waves_in_inventory(groups), ("lab", "pilot_1", "production"))
|
||||||
|
self.assertEqual(ui.WAVES,
|
||||||
|
("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production"))
|
||||||
|
|
||||||
|
def test_device_wave_lookup(self) -> None:
|
||||||
|
groups = ui.parse_inventory(self.inventory)
|
||||||
|
self.assertEqual(ui.device_wave("bf-12010193", groups), "pilot_1")
|
||||||
|
self.assertIsNone(ui.device_wave("bf-99999999", groups))
|
||||||
|
|
||||||
|
def test_missing_inventory_does_not_crash(self) -> None:
|
||||||
|
self.assertEqual(ui.parse_inventory(Path("/yok/boyle/bir/dosya.yml")), {})
|
||||||
|
|
||||||
|
def test_missing_machine_inventory_is_reported_in_turkish(self) -> None:
|
||||||
|
backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None))
|
||||||
|
note = backend.inventory_note()
|
||||||
|
self.assertIn("envanter", note.lower())
|
||||||
|
self.assertIn("hosts.yml", note)
|
||||||
|
|
||||||
|
def test_inventory_note_survives_and_warns_without_ansible(self) -> None:
|
||||||
|
"""Not uretimi hicbir durumda patlamamali (eksik import regresyonu)."""
|
||||||
|
for backend in (
|
||||||
|
ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT,
|
||||||
|
inventory=self.inventory)),
|
||||||
|
self.backend,
|
||||||
|
):
|
||||||
|
with self.subTest(backend=type(backend).__name__):
|
||||||
|
note = backend.inventory_note()
|
||||||
|
self.assertIn("Envanter", note)
|
||||||
|
if shutil.which("ansible-playbook") is None:
|
||||||
|
self.assertIn("ansible-playbook bulunamadı", note)
|
||||||
|
|
||||||
|
def test_dealer_wave_gate_requires_inventory(self) -> None:
|
||||||
|
backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None))
|
||||||
|
problem = backend.validate_target(
|
||||||
|
ui.operation_by_id("gui-off") or ui.OPERATIONS[0], self.dealer_target()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(problem)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatalogueBackendFallback(StaticCase):
|
||||||
|
"""Gercek moduller yoksa da ayni kurallar gecerli olmali (ayna backend)."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
super().setUpClass()
|
||||||
|
cls.fallback = ui.CatalogueBackend(cls.ctx)
|
||||||
|
|
||||||
|
def test_fallback_has_the_same_fleet_operations(self) -> None:
|
||||||
|
fleet = {op.id for op in self.fallback.list_operations() if op.is_fleet}
|
||||||
|
self.assertGreaterEqual(len(fleet), 21)
|
||||||
|
self.assertIn("ping", fleet)
|
||||||
|
self.assertIn("deploy-update", fleet)
|
||||||
|
|
||||||
|
def test_fallback_builds_the_same_core_arguments(self) -> None:
|
||||||
|
operation = None
|
||||||
|
for candidate in self.fallback.list_operations():
|
||||||
|
if candidate.is_fleet and candidate.scope == "simple" and candidate.playbook \
|
||||||
|
and ui.playbook_path(REPO_ROOT, candidate.playbook).is_file():
|
||||||
|
operation = candidate
|
||||||
|
break
|
||||||
|
if operation is None:
|
||||||
|
self.skipTest("kullanilabilir basit kapsamli filo islemi yok")
|
||||||
|
options = {spec.name: self.SAMPLE_VALUES[spec.name]
|
||||||
|
for spec in operation.options if spec.name in self.SAMPLE_VALUES}
|
||||||
|
plan = self.fallback.build_command(operation, self.dealer_target(), options)
|
||||||
|
joined = " ".join(plan.lines)
|
||||||
|
self.assertIn("ansible-playbook", joined)
|
||||||
|
self.assertIn(f"-i {self.ctx.inventory}", joined)
|
||||||
|
self.assertIn("--limit bf-12010193", joined)
|
||||||
|
|
||||||
|
def test_fallback_uses_the_same_destructive_table(self) -> None:
|
||||||
|
destructive = set(ui.destructive_operations(self.fallback.list_operations()))
|
||||||
|
self.assertEqual(destructive, set(ui.destructive_operations()))
|
||||||
|
|
||||||
|
def test_fallback_refuses_missing_inventory(self) -> None:
|
||||||
|
backend = ui.CatalogueBackend(ui.GuiContext(repo_root=REPO_ROOT, inventory=None))
|
||||||
|
with self.assertRaises(ui.CommandError) as ctx:
|
||||||
|
backend.build_command(ui.operation_by_id("uptime"), self.dealer_target())
|
||||||
|
self.assertIn("envanter", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_fallback_refuses_scope_mismatch_with_the_playbook(self) -> None:
|
||||||
|
"""Tablo playbook'la celisirse komut URETILMEZ (bf_playbook_check kurali)."""
|
||||||
|
wrong = ui.Operation(
|
||||||
|
id="uptime", label="Yanlış kapsam", category="Cihaz işlemleri",
|
||||||
|
destructive=False, scope="wave", playbook="bf-uptime.yml",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ui.CommandError) as ctx:
|
||||||
|
self.fallback.build_command(wrong, self.group_target())
|
||||||
|
self.assertIn("bf-uptime.yml", str(ctx.exception))
|
||||||
|
self.assertIn("kapsam", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_fallback_installs_only_with_a_valid_dealer(self) -> None:
|
||||||
|
installer = REPO_ROOT / ui.INSTALLER_REL
|
||||||
|
if not installer.is_file():
|
||||||
|
self.skipTest("kurulum betigi bu checkout'ta yok")
|
||||||
|
plan = self.fallback.build_command(
|
||||||
|
ui.operation_by_id("install-preflight"), self.dealer_target()
|
||||||
|
)
|
||||||
|
joined = " ".join(plan.lines)
|
||||||
|
self.assertIn("--dealer-id 12010193", joined)
|
||||||
|
self.assertIn("--check", joined)
|
||||||
|
self.assertFalse(plan.destructive)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Loading…
Reference in a new issue