From c5b576dbe781b05a7b42a639dd80198c2be72d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:19:19 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?;=20=D0=BA=D0=BB=D0=BE=D0=BD=20=D0=B8=20=D1=83=D0=B2=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B7=D0=B0=D1=89=D0=B8=D1=89=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BE=D1=82=20=D1=81=D0=BF=D1=8F=D1=89=D0=B8=D1=85=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=88=D0=B8=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stable (по умолчанию): обновление до последнего тега vX.Y.Z; пока релизов нет — ветка main - main: ранний доступ, все изменения; канал берётся из web.env (Настройки → Обновления) - scripts/update_target.sh общий для автообновления и ручного обновления, ZIP-запасной путь для тегов - страница «Обновления» показывает канал и целевую версию - клонирование и увеличение диска недоступны, пока машина в спящем режиме Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- scripts/apply_github_update.sh | 18 +++++++----- scripts/auto_update_check.sh | 17 ++++++----- scripts/update_target.sh | 29 ++++++++++++++++++ tests/test_core.py | 38 ++++++++++++++++++++++++ web/features/vmops.py | 9 ++++++ web/templates/update.html | 4 ++- web/templates/vm_clone.html | 1 + web/update_core.py | 54 ++++++++++++++++++++++++++++------ 8 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 scripts/update_target.sh diff --git a/scripts/apply_github_update.sh b/scripts/apply_github_update.sh index 2cb435f..711c438 100644 --- a/scripts/apply_github_update.sh +++ b/scripts/apply_github_update.sh @@ -7,8 +7,11 @@ STATE_FILE="${STATE_DIR}/state.json" LOG_FILE="/var/log/virtuality/update.log" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" -REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}" TMP_DIR="${STATE_DIR}/tmp" +# shellcheck source=scripts/update_target.sh +. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh" +resolve_update_target +REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}" mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")" @@ -48,7 +51,7 @@ try_run() { fetch_zip_update() { local zip_file extract_dir extracted_root - zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip" + zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip" extract_dir="${TMP_DIR}/zip-update" rm -rf "$zip_file" "$extract_dir" mkdir -p "$extract_dir" @@ -127,9 +130,10 @@ GIT_UPDATED=0 if [ -d "$SOURCE_DIR/.git" ]; then cd "$SOURCE_DIR" || exit 1 write_state "running" "Получаем изменения из GitHub через git" - if try_run git fetch "$REMOTE" "$BRANCH"; then - TARGET_REF="${REMOTE}/${BRANCH}" - TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)" + if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then + resolve_update_target + log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}" + TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)" if [ -n "$TARGET_COMMIT" ]; then LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)" if [ -n "$LOCAL_STATUS" ]; then @@ -137,8 +141,8 @@ if [ -d "$SOURCE_DIR/.git" ]; then printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE" fi write_state "running" "Синхронизируем исходники с GitHub через git reset" - log "Deploy mode: git reset --hard $TARGET_REF" - if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then + log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}" + if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then GIT_UPDATED=1 else log "git reset/clean failed, ZIP fallback will be used" diff --git a/scripts/auto_update_check.sh b/scripts/auto_update_check.sh index c01c282..0d9ba61 100644 --- a/scripts/auto_update_check.sh +++ b/scripts/auto_update_check.sh @@ -9,7 +9,8 @@ LOG_FILE="/var/log/virtuality/update.log" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh" -ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}" +# shellcheck source=scripts/update_target.sh +. "${SOURCE_DIR}/scripts/update_target.sh" mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")" @@ -89,16 +90,18 @@ diag_network() { fi if [ -d "$SOURCE_DIR/.git" ]; then - if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then - latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)" + if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then + resolve_update_target + log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}" + latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)" if [ -n "$latest" ] && [ "$current" = "$latest" ]; then log "no updates: ${current:0:12} / version $current_version" write_state "idle" "Автообновление: новых обновлений нет" exit 0 fi if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then - log "local build ${current:0:12} is not behind ${REMOTE}/${BRANCH} (${latest:0:12}); skip to avoid a downgrade" - write_state "idle" "Автообновление: установленная сборка новее или отличается от ${BRANCH}, обновление пропущено" + log "local build ${current:0:12} is not behind ${TARGET_LABEL} (${latest:0:12}); skip to avoid a downgrade" + write_state "idle" "Автообновление: установленная версия новее ${TARGET_LABEL}, обновление пропущено" exit 0 fi log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}" @@ -117,6 +120,6 @@ diag_network() { exit 0 fi - write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм" - VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" VIRTUALITY_UPDATE_ZIP_URL="$ZIP_URL" bash "$APPLY_SCRIPT" + write_state "running" "Автообновление: устанавливаем обновление" + VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT" ) 9>"$LOCK_FILE" diff --git a/scripts/update_target.sh b/scripts/update_target.sh new file mode 100644 index 0000000..9bfa11b --- /dev/null +++ b/scripts/update_target.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to. +# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged +# main — every change on the main branch +# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL. + +update_channel() { + local value="${VIRTUALITY_UPDATE_CHANNEL:-}" + if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then + value="$(sed -n 's/^VIRTUALITY_UPDATE_CHANNEL=//p' /var/lib/virtuality/config/web.env | tail -n1 | tr -d "\"'")" + fi + [[ "$value" == "main" ]] && echo main || echo stable +} + +resolve_update_target() { + local tag="" + UPDATE_CHANNEL="$(update_channel)" + TARGET_REF="${REMOTE}/${BRANCH}" + TARGET_LABEL="${BRANCH}" + TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip" + if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then + tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)" + if [[ -n "$tag" ]]; then + TARGET_REF="refs/tags/${tag}" + TARGET_LABEL="$tag" + TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/tags/${tag}.zip" + fi + fi +} diff --git a/tests/test_core.py b/tests/test_core.py index 92e7cb5..4f8f23f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -102,3 +102,41 @@ def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "upstream", cwd=remote) git("reset", "-q", "--hard", "origin/main", cwd=local) assert update_core.check_updates(fetch=True)["has_update"] is True + + +def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs): + import subprocess + + def git(*args, cwd): + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd, check=True, capture_output=True) + + remote, local = tmp_path / "remote", tmp_path / "local" + remote.mkdir() + git("init", "-q", "-b", "main", cwd=remote) + (remote / "VERSION").write_text("0.10.0\n") + git("add", "VERSION", cwd=remote) + git("commit", "-q", "-m", "0.10.0", cwd=remote) + git("clone", "-q", str(remote), str(local), cwd=tmp_path) + monkeypatch.setattr(update_core, "SOURCE_DIR", local) + monkeypatch.setattr(update_core, "NODE_ENV", tmp_path / "web.env") + monkeypatch.delenv("VIRTUALITY_UPDATE_CHANNEL", raising=False) + assert update_core.update_channel() == "stable" + + # Work on main without a release: stable follows main until the first tag exists. + (remote / "VERSION").write_text("0.11.0\n") + git("commit", "-q", "-am", "0.11.0", cwd=remote) + data = update_core.check_updates(fetch=True) + assert data["has_update"] and data["target"] == "main" + + # With a release tag, stable offers the tag and ignores newer commits on main. + git("tag", "v0.11.0", cwd=remote) + git("commit", "-q", "--allow-empty", "-m", "work in progress", cwd=remote) + data = update_core.check_updates(fetch=True) + assert data["channel"] == "stable" and data["target"] == "v0.11.0" and data["latest_version"] == "0.11.0" and data["has_update"] + git("checkout", "-q", "v0.11.0", cwd=local) + assert update_core.check_updates(fetch=True)["has_update"] is False + + # The early-access channel sees every change on main. + (tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n") + data = update_core.check_updates(fetch=True) + assert data["channel"] == "main" and data["target"] == "main" and data["has_update"] diff --git a/web/features/vmops.py b/web/features/vmops.py index a97b755..1912d54 100644 --- a/web/features/vmops.py +++ b/web/features/vmops.py @@ -174,6 +174,11 @@ def parse_desc_output(result: dict[str, Any]) -> str: return text +def saved_state(name: str) -> bool: + """The machine is hibernated (virsh managedsave): its disks must not change until it is resumed.""" + return has_managed_save(run(["virsh", "dominfo", name], timeout=10)["stdout"]) + + def has_managed_save(dominfo: str) -> bool: """Строка «Managed save: yes» в `virsh dominfo` — машина в спящем режиме.""" match = re.search(r"^Managed save:\s*(\S+)", dominfo or "", re.MULTILINE | re.IGNORECASE) @@ -372,6 +377,8 @@ def disk_resize(request: Request, name: str, target: str, size_gb: int = Form(.. free = free_space(disk["source"]) if growth > free: return redirect_tab(name, "storage", "disk_error", f"На сервере свободно только {presenters.format_bytes(free)}: столько добавить диску не получится") + if saved_state(name): + return redirect_tab(name, "storage", "disk_error", "Машина в спящем режиме: сохранённая память рассчитана на старый размер диска. Сначала запустите её и выключите обычным образом.") if is_shut_off(vm_state(name)): result = run(["qemu-img", "resize", disk["source"], f"{size_gb}G"], timeout=120) else: @@ -413,6 +420,8 @@ def clone_context(name: str) -> dict[str, Any]: blockers = [] if not is_shut_off(state): blockers.append("running") + elif saved_state(name): + blockers.append("saved") if not cloneable: blockers.append("no_disks") if needed > free: diff --git a/web/templates/update.html b/web/templates/update.html index 582e198..1abecb6 100644 --- a/web/templates/update.html +++ b/web/templates/update.html @@ -66,7 +66,9 @@
Установлена{{ info.current_version }}
{% if info.has_update %}
Доступна{{ info.latest_version }}
{% endif %} -
Канал{{ info.branch }}
+
Канал{{ info.channel_label or info.branch }}
+ {% if info.target %}
Обновляемся до{{ info.target }}
{% endif %} +
Сборка{{ info.current_commit[:8] if info.current_commit else '—' }}
diff --git a/web/templates/vm_clone.html b/web/templates/vm_clone.html index d85ba36..3fc41bd 100644 --- a/web/templates/vm_clone.html +++ b/web/templates/vm_clone.html @@ -29,6 +29,7 @@ {% if st.running %}
{% endif %} {% endif %} + {% if 'saved' in blockers %}{{ alert('warning', 'Машина в спящем режиме, её память сохранена на диске. Запустите машину и выключите её обычным образом — тогда копия будет целой.', 'Сначала разбудите машину') }}{% endif %} {% if 'space' in blockers %}{{ alert('danger', 'Копии нужно ' ~ needed_text ~ ', а свободно только ' ~ free_text ~ '. Удалите ненужные машины или образы.', 'Не хватает места на сервере') }}{% endif %} {% if 'no_disks' in blockers %}{{ alert('danger', 'У машины нет дисков, которые можно скопировать.') }}{% endif %} {% if 'busy' in blockers %}{{ alert('info', 'Эта машина уже клонируется. Дождитесь окончания в разделе «Задачи».') }}{% endif %} diff --git a/web/update_core.py b/web/update_core.py index 270b083..e146127 100644 --- a/web/update_core.py +++ b/web/update_core.py @@ -12,6 +12,8 @@ STATE_FILE = STATE_DIR / 'state.json' LOG_FILE = Path('/var/log/virtuality/update.log') DEFAULT_BRANCH = os.environ.get('VIRTUALITY_UPDATE_BRANCH', 'main') REMOTE = os.environ.get('VIRTUALITY_UPDATE_REMOTE', 'origin') +NODE_ENV = Path(os.environ.get('VIRTUALITY_NODE_ENV', '/var/lib/virtuality/config/web.env')) +CHANNELS = {'stable': 'Стабильный — выпущенные версии', 'main': 'Ранний доступ — все изменения'} class UpdateError(Exception): @@ -69,8 +71,37 @@ def current_commit() -> str: return result['stdout'] if result['ok'] else '' -def latest_commit() -> str: - result = run_cmd(['git', 'rev-parse', f'{REMOTE}/{DEFAULT_BRANCH}'], cwd=SOURCE_DIR, timeout=10) +def update_channel() -> str: + """stable: the latest release tag vX.Y.Z; main: every change on the main branch.""" + value = os.environ.get('VIRTUALITY_UPDATE_CHANNEL', '') + if not value: + try: + for line in NODE_ENV.read_text().splitlines(): + if line.startswith('VIRTUALITY_UPDATE_CHANNEL='): + value = line.split('=', 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return value if value in CHANNELS else 'stable' + + +def latest_release_tag() -> str: + result = run_cmd(['git', 'tag', '--list', 'v[0-9]*', '--sort=-v:refname'], cwd=SOURCE_DIR, timeout=10) + tags = [tag for tag in result['stdout'].split() if all(part.isdigit() for part in tag[1:].split('.'))] if result['ok'] else [] + return tags[0] if tags else '' + + +def target_ref(channel: str | None = None) -> str: + """Git ref the node updates to. Until the first release is tagged, stable follows the main branch.""" + channel = channel or update_channel() + if channel == 'stable': + tag = latest_release_tag() + if tag: + return f'refs/tags/{tag}' + return f'{REMOTE}/{DEFAULT_BRANCH}' + + +def latest_commit(ref: str | None = None) -> str: + result = run_cmd(['git', 'rev-parse', f'{ref or target_ref()}^{{commit}}'], cwd=SOURCE_DIR, timeout=10) return result['stdout'] if result['ok'] else '' @@ -91,8 +122,8 @@ def current_version() -> str: return local_file_text('VERSION') or '0.0.0' -def latest_version() -> str: - value = file_from_git(f'{REMOTE}/{DEFAULT_BRANCH}', 'VERSION').strip() +def latest_version(ref: str | None = None) -> str: + value = file_from_git(ref or target_ref(), 'VERSION').strip() return value or current_version() @@ -169,13 +200,15 @@ def check_updates(fetch: bool = True) -> dict[str, Any]: fetch_result = {'ok': True, 'stdout': '', 'stderr': '', 'cmd': 'skip'} if fetch: - fetch_result = run_cmd(['git', 'fetch', '--quiet', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60) + fetch_result = run_cmd(['git', 'fetch', '--quiet', '--tags', '--force', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60) + channel = update_channel() + ref = target_ref(channel) local_commit = current_commit() - remote_commit = latest_commit() + remote_commit = latest_commit(ref) local_version = current_version() - remote_version = latest_version() - manifest = load_manifest(f'{REMOTE}/{DEFAULT_BRANCH}') + remote_version = latest_version(ref) + manifest = load_manifest(ref) missed = missing_versions(local_version, remote_version, manifest) commits = git_log_between(local_commit, remote_commit) has_update = bool(local_commit and remote_commit and local_commit != remote_commit and is_ancestor(local_commit, remote_commit)) @@ -185,6 +218,9 @@ def check_updates(fetch: bool = True) -> dict[str, Any]: 'source_dir': str(SOURCE_DIR), 'remote': REMOTE, 'branch': DEFAULT_BRANCH, + 'channel': channel, + 'channel_label': CHANNELS[channel], + 'target': ref.removeprefix('refs/tags/').removeprefix(f'{REMOTE}/'), 'fetch_ok': fetch_result['ok'], 'fetch_error': fetch_result['stderr'] or fetch_result['stdout'], 'current_commit': local_commit, @@ -230,6 +266,6 @@ def start_update() -> dict[str, Any]: # Run outside virtuality-web's cgroup: the update restarts virtuality-web, # which would otherwise kill the update script before it finishes. unit = f"virtuality-update-{datetime.now().strftime('%Y%m%d%H%M%S')}" - cmd = ['systemd-run', '--unit', unit, '--collect', '--quiet', f'--setenv=VIRTUALITY_SOURCE_DIR={SOURCE_DIR}', f'--working-directory={SOURCE_DIR}'] + cmd + cmd = ['systemd-run', '--unit', unit, '--collect', '--quiet', f'--setenv=VIRTUALITY_SOURCE_DIR={SOURCE_DIR}', f'--setenv=VIRTUALITY_UPDATE_CHANNEL={update_channel()}', f'--working-directory={SOURCE_DIR}'] + cmd subprocess.Popen(cmd, cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return data