Резервные копии машин были добавлены: создание, восстановление, удаление

Первый модуль-фича на общем core.py — web/features/backups.py,
подключён в app.py одной строкой include_router.

- копия = папка /var/lib/virtuality/backups/<машина>/<ГГГГММДД-ЧЧММ>/
  с vm.xml (virsh dumpxml --migratable), <диск>.qcow2 на каждый диск
  и meta.json (машина, дата, диски, размер, версия, заметка);
- создание — фоновая операция: свободное место проверяется заранее по
  qemu-img info, работающая машина мягко выключается (ждём до 3 минут,
  без принудительного выключения), диски копируются qemu-img convert -c
  с прогрессом из строк «(NN.NN/100%)», после чего машина запускается;
- восстановление — операция: диски распаковываются в images/<имя>[-<диск>]
  .qcow2, в XML меняется имя, убираются uuid, MAC и nvram, затем
  virsh define; восстановление под занятым именем требует галочку
  «заменить существующую» — старая машина удаляется с дисками, а ISO
  перед этим извлекается из привода, чтобы --remove-all-storage его не
  стёр;
- страницы: «Резервные копии» в меню (все копии по машинам, занятое и
  свободное место), вкладка «Резервные копии» на странице машины с
  формой «Создать копию» и заметкой, страница восстановления с
  понятным списком шагов;
- create_backup(vm, note, wait=True) можно вызывать без HTTP —
  задел под расписание (python3 web/features/backups.py web01 «заметка»);
- tests/conftest.py: каталог backups, патчи core.*, вывод virsh
  domblklist --details, dumpxml --migratable и qemu-img info из fixtures;
  tests/test_backups.py — 33 теста парсеров, воркеров и маршрутов;
  tests/dev_server.py показывает демо-копии.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:32:05 +00:00
родитель 1e29134fb3
Коммит 2450278d93
13 изменённых файлов: 1433 добавлений и 8 удалений
+18
Просмотреть файл
@@ -0,0 +1,18 @@
{% from "_ui.html" import icon, badge %}
{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#}
{% for b in rows %}
<div class="list-item">
<span class="list-icon {{ 'brand' if b.complete else 'danger' }}">{{ icon('database' if b.complete else 'triangle-alert') }}</span>
<div class="grow">
<div class="title">{{ b.created_label }}{% if b.note %} <span class="tag" style="margin-left:6px">{{ b.note }}</span>{% endif %}</div>
<div class="meta">{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · <span style="color:var(--danger)">не хватает файлов</span>{% endif %}</div>
</div>
{% if b.complete %}
<a class="btn btn-sm" href="/backups/{{ b.vm }}/{{ b.id }}/restore">{{ icon('rotate-cw', 'icon-sm') }}<span class="hide-sm">Восстановить</span></a>
{% endif %}
<form method="post" action="/backups/{{ b.vm }}/{{ b.id }}/delete" class="inline-form" data-confirm="Копия машины {{ b.vm }} от {{ b.created_label }} ({{ b.size }}) будет удалена. Сама машина не пострадает." data-confirm-title="Удалить копию?" data-confirm-button="Удалить" data-toast="Копия удалена" data-toast-tone="warning">
<input type="hidden" name="next" value="{{ back }}">
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить копию" title="Удалить копию">{{ icon('trash-2', 'icon-sm') }}</button>
</form>
</div>
{% endfor %}
+75
Просмотреть файл
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% set page_title = 'Восстановить ' ~ backup.vm %}
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/backups">{{ icon('arrow-left', 'icon-sm') }} Резервные копии</a></div>
<h1>Восстановить машину из копии</h1>
<p class="lead">Копия машины <strong>{{ backup.vm }}</strong> от {{ backup.created_label }}{% if backup.note %} — «{{ backup.note }}»{% endif %}.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if error %}{{ alert('danger', error) }}{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-main">
<article class="card">
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.id }}/restore" data-busy="Запускаем…" {% if exists %}data-confirm="Машина {{ name }} будет удалена вместе со всеми её дисками и заменена копией от {{ backup.created_label }}. Отменить это будет нельзя." data-confirm-title="Заменить машину {{ name }}?" data-confirm-button="Заменить и восстановить"{% endif %}>
<div class="card-body form-grid">
<div class="field">
<label for="name">Название восстановленной машины</label>
<input class="input" id="name" type="text" name="name" value="{{ name }}" pattern="[a-zA-Z0-9][a-zA-Z0-9_.\-]{1,62}" autocomplete="off" required>
<span class="hint">Оставьте как есть, чтобы вернуть машину {{ backup.vm }}, или введите новое название — получится вторая независимая машина.</span>
</div>
{% if exists %}
<div class="alert warning">{{ icon('triangle-alert') }}<div><strong>Машина {{ name }} уже есть на сервере</strong>Чтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.</div></div>
<label class="check danger">
<input type="checkbox" name="replace" value="1" required>
<span><strong>Заменить существующую машину {{ name }}</strong><span>Она будет выключена и удалена вместе с дисками, а на её место встанет копия.</span></span>
</label>
{% else %}
<input type="hidden" name="replace" value="">
{% endif %}
<div class="kv" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<div><span>Дисков в копии</span><strong>{{ backup.disk_count }} · {{ backup.size }}</strong></div>
<div><span>Понадобится места</span><strong>около {{ need }}</strong></div>
<div><span>Свободно</span><strong>{{ free }}</strong></div>
</div>
</div>
<div class="card-footer">
<a class="btn" href="/backups">Отмена</a>
<button type="submit" class="btn {{ 'btn-danger' if exists else 'btn-primary' }}">{{ icon('rotate-cw') }}{{ 'Заменить и восстановить' if exists else 'Восстановить' }}</button>
</div>
</form>
</article>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Что будет сделано</h2></div>
<div class="checklist">
{% for title, text in [
('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'),
('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'),
('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'),
('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'),
] %}
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
{% endfor %}
</div>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('info') }}<h3>Копия не пострадает</h3></div>
<p class="muted small">Восстанавливать из одной копии можно сколько угодно раз — файлы копии остаются на месте.</p>
</div>
</article>
</aside>
</section>
{% endblock %}
+82
Просмотреть файл
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, meter, empty, alert %}
{% set page_title = 'Резервные копии' %}
{% block page_header %}
<div class="page-header">
<div>
<h1>Резервные копии</h1>
<p class="lead">Полная копия машины: настройки и все её диски. Из копии можно восстановить машину на этом сервере — под тем же или новым названием.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if backup_message %}{{ alert('success', backup_message) }}{% endif %}
{% if backup_error %}{{ alert('danger', backup_error) }}{% endif %}
{% for op in running %}
<div class="alert info">{{ icon('activity') }}<div><strong>{{ op.title }} — {{ op.progress|default(0) }}%</strong>{{ op.message }}</div><a class="btn btn-sm alert-action" href="/operations/{{ op.id }}">Следить</a></div>
{% endfor %}
{% endblock %}
{% block content %}
<section class="grid grid-3">
<article class="card stat">
<div class="stat-head">Копий на сервере<span class="stat-icon">{{ icon('database', 'icon-sm') }}</span></div>
<div class="stat-value">{{ storage.count }}<small>{{ plural(storage.count, 'копия', 'копии', 'копий') }}</small></div>
<div class="stat-foot">{{ groups|length }} {{ plural(groups|length, 'машина', 'машины', 'машин') }}</div>
</article>
<article class="card stat">
<div class="stat-head">Занято копиями<span class="stat-icon">{{ icon('hard-drive', 'icon-sm') }}</span></div>
<div class="stat-value">{{ storage.used }}</div>
{{ meter(storage.used_pct, 'brand') }}
<div class="stat-foot">{{ storage.used_pct }}% места на диске сервера</div>
</article>
<article class="card stat">
<div class="stat-head">Свободно для новых копий<span class="stat-icon">{{ icon('layers', 'icon-sm') }}</span></div>
<div class="stat-value">{{ storage.free }}</div>
<div class="stat-foot">Перед копированием место проверяется автоматически</div>
</article>
</section>
{% if groups %}
<div class="stack mt-3">
{% for g in groups %}
<article class="card">
<div class="card-header">
<div class="row">
<span class="vm-avatar">{{ icon('monitor') }}</span>
<div>
<h2>{% if g.exists %}<a href="/vm/{{ g.vm }}" style="color:inherit">{{ g.vm }}</a>{% else %}{{ g.vm }}{% endif %}{% if not g.exists %} {{ badge('машина удалена', 'warning', cls='hide-sm') }}{% endif %}</h2>
<p>{{ g.count }} {{ plural(g.count, 'копия', 'копии', 'копий') }} · {{ g.size }}{% if not g.exists %} · машины больше нет на сервере, но её можно восстановить{% endif %}</p>
</div>
</div>
{% if g.exists %}<a class="btn btn-sm" href="/vm/{{ g.vm }}/backups">{{ icon('plus', 'icon-sm') }}Создать копию</a>{% endif %}
</div>
<div class="list">
{% with rows = g.backups, back = '/backups' %}{% include "_backup_rows.html" %}{% endwith %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<article class="card mt-3">
{{ empty('database', 'Копий пока нет', 'Откройте страницу машины, перейдите на вкладку «Резервные копии» и нажмите «Создать копию». Копирование идёт в фоне — страницу можно закрыть.', '/', 'К списку машин', 'monitor') }}
</article>
{% endif %}
<section class="grid grid-2 mt-3">
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('shield-check') }}<h3>Что входит в копию</h3></div>
<p class="muted small">Настройки машины (процессор, память, сеть, порядок загрузки) и все её диски целиком. Установочные образы (ISO) не копируются — они лежат отдельно в разделе «Образы ОС». Диски сжимаются, поэтому копия обычно меньше самой машины.</p>
</div>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной и получает новые сетевые адреса, чтобы не мешать оригиналу. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
</div>
</article>
</section>
{% endblock %}
+1
Просмотреть файл
@@ -39,6 +39,7 @@
<div class="nav-section">Ресурсы</div>
{{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }}
{{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }}
{{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }}
<div class="nav-section">Система</div>
{{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }}
+81
Просмотреть файл
@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, vm_badge, empty, alert %}
{% set page_title = vm.name ~ ' — резервные копии' %}
{% set s = vm.state|vm_state %}
{% block page_header %}
<div class="page-header" data-live-vm="{{ vm.name }}">
<div>
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
<div class="title-row">
<span class="vm-avatar {{ 'on' if s.running else '' }}" style="width:48px;height:48px">{{ icon('monitor', 'icon-lg') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
<div>
<div class="row"><h1>{{ vm.name }}</h1>{{ vm_badge(vm.state, 'lg') }}</div>
<p class="subtle mt-1">{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }} · {{ vm.info.memory_mb|format_mb }} памяти</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if backup_message %}{{ alert('success', backup_message) }}{% endif %}
{% if backup_error %}{{ alert('danger', backup_error) }}{% endif %}
{% if active %}
<div class="alert info">{{ icon('activity') }}<div><strong>{{ active.title }} — {{ active.progress|default(0) }}%</strong>{{ active.message }}</div><a class="btn btn-sm alert-action" href="/operations/{{ active.id }}">Следить</a></div>
{% endif %}
{% endblock %}
{% block content %}
<nav class="tabs" role="tablist">
<a href="/vm/{{ vm.name }}#overview">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</a>
<a href="/vm/{{ vm.name }}#resources">{{ icon('cpu', 'icon-sm') }}Ресурсы</a>
<a href="/vm/{{ vm.name }}#storage">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</a>
<a href="/vm/{{ vm.name }}/backups" class="active" aria-current="page">{{ icon('database', 'icon-sm') }}Резервные копии{% if backups %}<span class="tag">{{ backups|length }}</span>{% endif %}</a>
<a href="/vm/{{ vm.name }}#expert">{{ icon('terminal', 'icon-sm') }}Для специалистов</a>
</nav>
<section class="grid grid-main">
<article class="card">
<div class="card-header"><div><h2>Копии этой машины</h2><p>Новые сверху. Из любой можно восстановить машину — под тем же или новым названием.</p></div></div>
{% if backups %}
<div class="list">
{% with rows = backups, back = '/vm/' ~ vm.name ~ '/backups' %}{% include "_backup_rows.html" %}{% endwith %}
</div>
{% else %}
{{ empty('database', 'Копий пока нет', 'Создайте первую копию — это займёт несколько минут и защитит от неудачных обновлений и случайных ошибок.') }}
{% endif %}
</article>
<aside class="stack">
<article class="card">
<div class="card-header"><div><h2>Создать копию</h2><p>Настройки и все диски машины</p></div></div>
<form method="post" action="/vm/{{ vm.name }}/backups/create" class="stack-sm" data-busy="Запускаем…">
<div class="card-body stack-sm">
{% if s.running %}
{{ alert('info', 'Машина работает: на время копирования она будет аккуратно выключена, а потом запущена снова.') }}
{% endif %}
<div class="field">
<label for="note">Заметка <span class="subtle" style="font-weight:400">— необязательно</span></label>
<input class="input" id="note" type="text" name="note" maxlength="120" placeholder="например, перед обновлением сайта" autocomplete="off">
<span class="hint">Поможет потом вспомнить, зачем делали копию.</span>
</div>
<div class="kv">
<div><span>Свободно для копий</span><strong>{{ storage.free }}</strong></div>
<div><span>Уже занято копиями</span><strong>{{ storage.used }}</strong></div>
</div>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary btn-block" {% if active %}disabled{% endif %}>{{ icon('database') }}Создать копию</button>
</div>
</form>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
<p class="muted small">Обычно несколько минут: зависит от размера дисков. Ход выполнения виден в разделе «Задачи», страницу можно закрыть.</p>
</div>
</article>
</aside>
</section>
{% endblock %}
+1
Просмотреть файл
@@ -53,6 +53,7 @@
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
<a href="/vm/{{ vm.name }}/backups" role="tab">{{ icon('database', 'icon-sm') }}Резервные копии</a>
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
</nav>