#!/usr/bin/python3 # Экран установки Bridge&Join на первой консоли — вместо технического журнала установщика Ubuntu # («start: subiquity/Install/install/curtin_install/…»). Запускается из early-commands автоустановки # (deploy/iso/ubuntu/user-data.tmpl) отдельной службой с выводом на tty1 и сам останавливает экран # установщика Ubuntu (snap.subiquity.subiquity-service). Сама установка идёт как шла: этот экран только # читает её события из журнала (SUBIQUITY_EVENT_TYPE / SUBIQUITY_CONTEXT_NAME) и состояние сервера # установщика (/run/subiquity/server-state). # # Ошибка установки — экран говорит об этом и возвращает стандартный экран установщика Ubuntu (он # показывает подробности и открывает командную строку); то же, если сам этот экран упадёт # (обёртка в early-commands). # # Шрифт консоли установщика (subiquity.psf, 512 знаков): есть кириллица, рамки, ✓ ▶ ✗ █ ░ · •; # нет ○ ◐ ⚠ ▓ — их не использовать. # # Проверка без установки: bj-install-progress --demo <файл событий JSON> [--html кадр.html] import json import math import os import select import shutil import subprocess import sys import time STAGES = [ # заголовок, вес в общей полосе, типичная длительность этапа (с) — для плавной полосы внутри этапа ("Подготовка", 4, 25), ("Разметка диска", 4, 20), ("Копирование системы на диск", 34, 180), ("Настройка системы и загрузчика", 32, 180), ("Пакеты и обновления безопасности", 18, 150), ("Компоненты Bridge&Join", 8, 20), ] DONE_STAGE = len(STAGES) STATE_FILE = os.environ.get("BJ_PROGRESS_STATE", "/run/subiquity/server-state") # переменная — только для проверки CLIENT_UNIT = "snap.subiquity.subiquity-service.service" # цвета консоли Linux RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m" GREEN, CYAN, RED, WHITE, GREY = "\033[32m", "\033[36m", "\033[31m", "\033[97m", "\033[90m" def stage_of(name, message, state): """Этап по событию установщика (имя контекста и сообщение) и состоянию сервера; -1 — не меняет этап.""" n, m = name or "", message or "" if state == "DONE": return DONE_STAGE if state == "LATE_COMMANDS" or n.startswith("subiquity/Late/run"): return 5 if state == "UU_RUNNING" or "/postinstall" in n: return 4 if "stage-curthooks" in n or "curthooks step" in m or "swap step" in m: return 3 if "stage-extract" in n or "extract step" in m: return 2 if "stage-partitioning" in n or "partitioning step" in m or "formatting step" in m: return 1 return -1 class Progress: def __init__(self): self.stage = 0 self.stage_started = time.time() self.started = time.time() self.state = "" self.error = False self.detail = "" def feed(self, ev): """Событие журнала (JSON journalctl).""" name = ev.get("SUBIQUITY_CONTEXT_NAME", "") msg = (ev.get("MESSAGE") or "").strip() etype = ev.get("SUBIQUITY_EVENT_TYPE", "") if etype == "error": self.detail = msg[:200] st = stage_of(name, msg, "") self.advance(st) if self.stage == 4 and etype == "start": if "/install_" in name and ": installing " in msg: self.detail = "ставится " + msg.split(": installing ", 1)[1] elif "updates" in msg: self.detail = "загрузка и установка обновлений безопасности" def advance(self, st): if st > self.stage: self.stage, self.stage_started, self.detail = st, time.time(), "" def set_state(self, state): self.state = state if state == "ERROR": self.error = True self.advance(stage_of("", "", state)) def percent(self): if self.stage >= DONE_STAGE: return 100 done = sum(w for _, w, _ in STAGES[: self.stage]) _, w, tau = STAGES[self.stage] f = min(0.95, 1 - math.exp(-(time.time() - self.stage_started) / tau)) return min(99, int(done + w * f)) def mmss(sec): sec = int(max(0, sec)) return "%d:%02d" % (sec // 60, sec % 60) def frame(p, cols, rows): """Кадр: список строк, каждая — список кусков (текст, цвет).""" W = 66 inner = W - 2 lines = [] def row(parts=()): parts, text = list(parts), "" for i, (t, c) in enumerate(parts): # строка длиннее рамки — обрезать, рамка не должна съезжать if len(text) + len(t) > inner: parts = parts[:i] + [(t[: inner - len(text)], c)] text += parts[-1][0] break text += t pad = inner - len(text) lines.append([("║", GREY)] + list(parts) + [(" " * max(0, pad), ""), ("║", GREY)]) lines.append([("╔" + "═" * inner + "╗", GREY)]) row() row([(" Bridge&Join", BOLD + WHITE)]) row([(" шлюз, объединяющий системы финансовой организации", DIM)]) row() row([(" Установка сервера", BOLD)]) row() for i, (title, _, _) in enumerate(STAGES): if i < p.stage: row([(" ✓ ", GREEN), (title, "")]) elif i == p.stage and p.error: row([(" ✗ ", RED), (title, BOLD + RED)]) elif i == p.stage: t = mmss(time.time() - p.stage_started) gap = inner - 6 - len(title) - len(t) - 3 row([(" ▶ ", CYAN), (title, BOLD + CYAN), (" " * max(1, gap), ""), (t, DIM)]) else: row([(" · ", GREY), (title, GREY)]) row() if p.error: row([(" ✗ Установка не удалась.", BOLD + RED)]) row([(" Сейчас откроется экран установщика Ubuntu", "")]) row([(" с подробностями и командной строкой.", "")]) else: pct = p.percent() bar_w = 46 full = int(bar_w * pct / 100) row([(" ", ""), ("█" * full, CYAN), ("░" * (bar_w - full), GREY), (" %3d %%" % pct, BOLD)]) row([(" " + p.detail, DIM)] if p.detail and p.stage < DONE_STAGE else []) # подпись под полосой row() if p.stage >= DONE_STAGE: row([(" ✓ Установка завершена — сервер перезагружается.", BOLD + GREEN)]) row([(" После загрузки на этом экране будет адрес для входа.", "")]) else: row([(" Не выключайте сервер: он перезагрузится сам и покажет", "")]) row([(" на этом экране адрес для входа в браузере.", "")]) row() lines.append([("╚" + "═" * inner + "╝", GREY)]) foot = "прошло " + mmss(time.time() - p.started) lines.append([(" " * max(0, (W - len(foot)) // 2) + foot, GREY)]) # по центру экрана left = max(0, (cols - W) // 2) top = max(0, (rows - len(lines)) // 2) return [[("", "")]] * top + [[(" " * left, "")] + ln for ln in lines] def to_ansi(fr): out = ["\033[H\033[2J"] for ln in fr: out.append("".join((c + t + RESET) if c else t for t, c in ln) + "\n") return "".join(out) def to_html(fr): css = {GREEN: "#4e9a06", CYAN: "#06989a", RED: "#cc0000", GREY: "#888", DIM: "#9a9a9a", BOLD: "", WHITE: "#fff"} html = [] for ln in fr: h = "" for t, c in ln: t = t.replace("&", "&").replace("<", "<") if not c: h += t continue color = next((css[k] for k in (GREEN, CYAN, RED, GREY, WHITE, DIM) if k in c), "#ddd") weight = "700" if BOLD in c else "400" h += '%s' % (color, weight, t) html.append(h) return ('' '
'
            + "\n".join(html) + "
") def read_state(): try: with open(STATE_FILE) as f: return f.read().strip() except OSError: return "" def demo(path, html_out): """Прогон по записанным событиям: кадр в HTML (для проверки вида без установки).""" p = Progress() for line in open(path, encoding="utf-8"): line = line.strip() if not line: continue ev = json.loads(line) if "STATE" in ev: p.set_state(ev["STATE"]) else: p.feed(ev) if "ELAPSED" in ev: # сдвиг времени для наглядности кадра p.stage_started -= ev["ELAPSED"] p.started -= ev["ELAPSED"] * 3 fr = frame(p, 100, 37) if html_out: open(html_out, "w", encoding="utf-8").write(to_html(fr)) else: sys.stdout.write(to_ansi(fr)) def main(): if len(sys.argv) >= 3 and sys.argv[1] == "--demo": demo(sys.argv[2], sys.argv[4] if len(sys.argv) >= 5 and sys.argv[3] == "--html" else "") return 0 if "BJ_PROGRESS_STATE" not in os.environ: # экран установщика Ubuntu больше не нужен: события читаем сами subprocess.call(["systemctl", "stop", CLIENT_UNIT], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) for psf in ("/snap/subiquity/current/subiquity.psf",): if os.path.exists(psf): subprocess.call(["setfont", psf], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) sys.stdout.write("\033[?25l") # без мигающего курсора p = Progress() j = subprocess.Popen(["journalctl", "-o", "json", "-f", "-n", "all", "-q", "SUBIQUITY_EVENT_TYPE=start", "SUBIQUITY_EVENT_TYPE=finish", "SUBIQUITY_EVENT_TYPE=error"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) buf = b"" error_at = 0.0 while True: r, _, _ = select.select([j.stdout], [], [], 1.0) if r: chunk = os.read(j.stdout.fileno(), 65536) if not chunk and j.poll() is not None: return 1 # журнал недоступен — обёртка вернёт экран установщика buf += chunk while b"\n" in buf: line, buf = buf.split(b"\n", 1) try: p.feed(json.loads(line.decode("utf-8", "replace"))) except ValueError: pass p.set_state(read_state()) cols, rows = shutil.get_terminal_size((100, 37)) sys.stdout.write(to_ansi(frame(p, cols, rows))) sys.stdout.flush() if p.error: if not error_at: error_at = time.time() elif time.time() - error_at > 6: j.terminate() sys.stdout.write("\033[?25h\033[H\033[2J") return 2 # обёртка запустит экран установщика Ubuntu: подробности и командная строка if __name__ == "__main__": sys.exit(main())