fix: address production release review findings
This commit is contained in:
1 parent
852d639c45
commit
9d8ce6b67f
16 files changed
+505
-128
No files matched your search
@@ -81,3 +81,13 @@ jobs:
|
||||
workspace: ${{ github.workspace }}
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
destination: akyldash-opensearch:ci
|
||||
- name: Prepare a synthetic artifact compatibility marker
|
||||
run: |
|
||||
mkdir -p artifact-smoke
|
||||
printf 'Gitea artifact upload smoke test; no release data.\n' > artifact-smoke/marker.txt
|
||||
- name: Verify Gitea runtime artifact upload
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
|
||||
with:
|
||||
name: artifact-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifact-smoke/marker.txt
|
||||
retention-days: 1
|
||||
@@ -49,10 +49,15 @@ jobs:
|
||||
python3 infra/deploy/validate_release.py release/release-manifest.json \
|
||||
--policy infra/deploy/release-policy.json
|
||||
sha256sum release/release-manifest.json > release/release-manifest.json.sha256
|
||||
- name: Print verified release descriptor for the owner
|
||||
- name: Upload verified release descriptor
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc
|
||||
with:
|
||||
name: release-descriptor-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
release/release-manifest.json
|
||||
release/release-manifest.json.sha256
|
||||
retention-days: 90
|
||||
- name: Confirm uploaded release descriptor
|
||||
run: |
|
||||
sha256sum -c release/release-manifest.json.sha256
|
||||
echo 'Release descriptor (copy from this authenticated run log):'
|
||||
cat release/release-manifest.json
|
||||
echo 'Descriptor SHA-256:'
|
||||
cat release/release-manifest.json.sha256
|
||||
echo 'Release descriptor and checksum are available as this run artifact.'
|
||||
@@ -190,6 +190,11 @@ Wildcard removal, пустой индекс и alias с несколькими t
|
||||
старый target успел измениться после проверки, OpenSearch отклонит атомарную
|
||||
операцию удаления точного старого alias. `--resume` повторно проверяет весь
|
||||
ожидаемый inventory, а не только количество документов текущего запуска.
|
||||
Если ответ на alias request потерян после успешного переключения или процесс
|
||||
остановился до записи завершённого checkpoint, повторный запуск проверит
|
||||
фактическую цель alias. Уже опубликованный candidate оставляется на месте, и
|
||||
checkpoint завершается; старый target позволяет безопасно повторить cutover,
|
||||
а любой другой target останавливает операцию.
|
||||
|
||||
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
|
||||
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
|
||||
|
||||
@@ -462,22 +462,44 @@ def switch_alias(base: str, index: str, alias: str, expected_old_index: str) ->
|
||||
if not expected_old_index or expected_old_index == index:
|
||||
raise ValueError("--expected-old-index must name the exact previous index")
|
||||
actual_old_index = alias_target(base, alias)
|
||||
if actual_old_index == index:
|
||||
return
|
||||
if actual_old_index != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} currently targets {actual_old_index}, expected {expected_old_index}; refusing cutover"
|
||||
)
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/_aliases",
|
||||
"POST",
|
||||
json.dumps({
|
||||
"actions": [
|
||||
{"remove": {"index": expected_old_index, "alias": alias, "must_exist": True}},
|
||||
{"add": {"index": index, "alias": alias}},
|
||||
]
|
||||
}).encode(),
|
||||
"application/json",
|
||||
)
|
||||
if result.get("acknowledged") is not True:
|
||||
try:
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/_aliases",
|
||||
"POST",
|
||||
json.dumps({
|
||||
"actions": [
|
||||
{"remove": {"index": expected_old_index, "alias": alias, "must_exist": True}},
|
||||
{"add": {"index": index, "alias": alias}},
|
||||
]
|
||||
}).encode(),
|
||||
"application/json",
|
||||
)
|
||||
except Exception as error:
|
||||
try:
|
||||
current = alias_target(base, alias)
|
||||
except Exception:
|
||||
raise RuntimeError(f"alias switch outcome is unknown; inspect {alias} before resuming") from error
|
||||
if current == index:
|
||||
return
|
||||
if current != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
|
||||
) from error
|
||||
raise RuntimeError(f"alias switch failed; {alias} still targets {expected_old_index}") from error
|
||||
if not isinstance(result, dict) or result.get("acknowledged") is not True:
|
||||
current = alias_target(base, alias)
|
||||
if current == index:
|
||||
return
|
||||
if current != expected_old_index:
|
||||
raise ValueError(
|
||||
f"alias {alias} now targets unexpected index {current}; refusing to retry publication"
|
||||
)
|
||||
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
|
||||
|
||||
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.2</span></footer>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.3</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
@@ -675,7 +675,7 @@
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
|
||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.2 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.3 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -709,8 +709,8 @@
|
||||
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
|
||||
document.body.classList.toggle('is-review-route', reviewRoute);
|
||||
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = roleLabel(user); $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
@@ -1343,7 +1343,7 @@
|
||||
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
|
||||
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
|
||||
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.2`;
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.3`;
|
||||
if (usersRoute && account) loadUsers();
|
||||
};
|
||||
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
|
||||
|
||||
@@ -95,11 +95,101 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
||||
switch_alias("http://127.0.0.1:9200", "new", "alias", "akyldash-fragments-v1")
|
||||
request.assert_called_once()
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}}, {"acknowledged": False}
|
||||
{"old": {"aliases": {"alias": {}}}}, {"acknowledged": False},
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
]):
|
||||
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "alias", "old")
|
||||
|
||||
def test_switch_alias_recovers_when_post_succeeds_but_response_is_lost(self):
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
RuntimeError("response lost after OpenSearch applied request"),
|
||||
{"candidate": {"aliases": {"alias": {}}}},
|
||||
]) as request:
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
self.assertEqual(request.call_count, 3)
|
||||
self.assertEqual(request.call_args_list[1].args[1], "POST")
|
||||
self.assertEqual(request.call_args_list[2].args[1], "GET")
|
||||
|
||||
def test_switch_alias_is_idempotent_after_process_stops_before_checkpoint(self):
|
||||
with patch("search.minjust_opensearch.request_json", return_value={
|
||||
"candidate": {"aliases": {"alias": {}}}
|
||||
}) as request:
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
request.assert_called_once()
|
||||
|
||||
def test_switch_alias_refuses_to_retry_after_unexpected_target(self):
|
||||
with patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"old": {"aliases": {"alias": {}}}},
|
||||
RuntimeError("response lost"),
|
||||
{"other": {"aliases": {"alias": {}}}},
|
||||
]):
|
||||
with self.assertRaisesRegex(ValueError, "unexpected index"):
|
||||
switch_alias("http://os:9200", "candidate", "alias", "old")
|
||||
|
||||
def test_resume_completes_checkpoint_when_alias_switched_before_previous_process_stopped(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
input_root = root / "normalized"
|
||||
document_root = input_root / "documents/1"
|
||||
document_root.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(input_root / "manifest.sqlite3")) as connection:
|
||||
with connection:
|
||||
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
connection.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
(document_root / "document.json").write_text(
|
||||
json.dumps({"schema_version": "1", "source_code": "1", "editions": [
|
||||
{"source_code": "1", "source_order": 0},
|
||||
]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
text = "текст закона"
|
||||
fragment = document_root / "editions/1/ru/fragments.json"
|
||||
fragment.parent.mkdir(parents=True)
|
||||
fragment.write_text(json.dumps([{
|
||||
"id": "document:1:edition:1:lang:ru:fragment:1",
|
||||
"document_code": "1", "edition_code": "1", "language": "ru", "position": 1,
|
||||
"type": "paragraph", "text": text,
|
||||
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
|
||||
"source_path": "documents/1/editions/1/ru.html", "source_sha256": "b" * 64,
|
||||
}]), encoding="utf-8")
|
||||
checkpoint = root / "checkpoint.json"
|
||||
state = {
|
||||
"schema_version": 2,
|
||||
"url": "http://os:9200",
|
||||
"cluster_uuid": "cluster",
|
||||
"index": "candidate",
|
||||
"index_uuid": "index-uuid",
|
||||
"input": str(input_root.resolve()),
|
||||
"manifest_sha256": hashlib.sha256((input_root / "manifest.sqlite3").read_bytes()).hexdigest(),
|
||||
"limit": None,
|
||||
"alias": "current",
|
||||
"last_document_code": "1",
|
||||
"complete": False,
|
||||
}
|
||||
checkpoint.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch("search.minjust_opensearch.request_json", side_effect=[
|
||||
{"cluster_uuid": "cluster"},
|
||||
{"candidate": {"settings": {"index": {"uuid": "index-uuid"}}}},
|
||||
{"errors": False, "items": [{"index": {}}]},
|
||||
{"candidate": {"aliases": {"current": {}}}},
|
||||
]) as request,
|
||||
patch("search.minjust_opensearch.validate_candidate_index") as validate_candidate,
|
||||
):
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
input_root, "http://os:9200", "candidate", resume=True, checkpoint=checkpoint,
|
||||
alias="current", expected_old_index="previous", canary_ru="закон", canary_ky="мыйзам",
|
||||
),
|
||||
(1, 1),
|
||||
)
|
||||
self.assertTrue(json.loads(checkpoint.read_text(encoding="utf-8"))["complete"])
|
||||
self.assertEqual(request.call_args_list[-1].args[1], "GET")
|
||||
validate_candidate.assert_called_once_with("http://os:9200", "candidate", 1, "закон", "мыйзам")
|
||||
|
||||
def test_zero_fragment_candidate_never_reaches_alias_switch(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary) / "normalized"
|
||||
|
||||
+16
-11
@@ -123,18 +123,20 @@ unneeded administration rights. Do not reuse a runner or production host
|
||||
credential. The workflow uses the Gitea host and fixed package namespace
|
||||
`git.vakanaut.ru/admin/akyldash/{backend,opensearch}`, pushes unique
|
||||
commit/run/attempt tags, captures registry digests, validates the release
|
||||
descriptor, and prints its JSON plus SHA-256 sidecar to the authenticated Gitea
|
||||
run log for the owner to retrieve. The runner cache remains disabled. Because
|
||||
the installed act_runner 0.2.13 has not yet been validated with Gitea's
|
||||
v1-compatible artifact fork, the authenticated run log is the current bounded
|
||||
retrieval fallback; this PR makes no runner cache changes. Registry retention must preserve referenced digests
|
||||
for the full rollback window; this policy has not yet been confirmed.
|
||||
descriptor, and uploads the JSON plus SHA-256 sidecar as a Gitea Actions
|
||||
artifact using a full-SHA-pinned Gitea-compatible v3 action. PR CI also uploads
|
||||
a tiny synthetic marker with one-day retention to verify the runner's runtime
|
||||
artifact path without registry credentials. The runner cache stays disabled;
|
||||
artifact compatibility is based on the runtime API supported by act_runner
|
||||
0.2.13/Gitea 1.25.5 and will be empirically checked by PR CI. Registry and
|
||||
artifact retention must preserve release digests and descriptors for the full
|
||||
rollback window; the owner must confirm both policies.
|
||||
|
||||
The descriptor records source repository/ref/commit, workflow run and attempt,
|
||||
pinned Kaniko builder and both image digests. It is an unsigned trace record,
|
||||
not a cryptographic attestation; deployment relies on retrieving it from the
|
||||
authenticated Gitea run log and verifying its checksum and repository allowlist.
|
||||
Do not treat the sidecar alone as proof of authenticity.
|
||||
authenticated Gitea run artifact and verifying its checksum and repository
|
||||
allowlist. Do not treat the sidecar alone as proof of authenticity.
|
||||
|
||||
For a future owner-approved cutover, use the root-owned checkout and run
|
||||
`infra/deploy/deploy_release.py` as root with the downloaded manifest, its
|
||||
@@ -148,9 +150,12 @@ after corpus/index canaries have been verified; do not use generic terms that
|
||||
can match unrelated test data. The helper requires the backup image references and image IDs to match the live
|
||||
stack, serializes deploy and rollback with `/run/akyldash-deploy.lock`,
|
||||
atomically switches both protected env image refs, and always runs Compose with
|
||||
`--no-build`. It records recovery state before changing references; a failed
|
||||
cutover keeps a secret-free recovery journal and refuses a subsequent release
|
||||
until an owner resolves it. Rollback accepts a digest-pinned previous release
|
||||
`--no-build`. It records recovery state before changing references. If ref
|
||||
replacement reports an error after an atomic rename, recovery rereads the
|
||||
actual file and restores the prior pair before restarting; an unexpected or
|
||||
unverifiable pair stops recovery and leaves a secret-free journal. A failed
|
||||
cutover refuses a subsequent release until an owner resolves the journal.
|
||||
Rollback accepts a digest-pinned previous release
|
||||
only when that backup matches the separately recorded previous refs and image
|
||||
IDs. Legacy tag rollback remains supported. Neither the helper nor registry
|
||||
secrets are installed on the VPS by this PR; deployment remains an owner-only
|
||||
|
||||
@@ -83,13 +83,15 @@ apply. The workflow fails before building if `REGISTRY_USERNAME` or
|
||||
`REGISTRY_PASSWORD` is absent. Those names are not configured or verified on
|
||||
Gitea. If the owner later enables it, the credential must be package-write-only
|
||||
for the two approved image names; no production env, service secret, runner
|
||||
socket, or host mount is passed. The release descriptor is unsigned and the
|
||||
workflow prints its small JSON and checksum to the authenticated run log. The
|
||||
runner's cache service remains disabled. Gitea's runner-v2 documentation says
|
||||
that its v1 cache protocol requires Gitea's upload/download artifact forks;
|
||||
this repository does not yet validate a compatible artifact action against
|
||||
the installed act_runner 0.2.13, so the log is the bounded retrieval fallback
|
||||
and no runner cache setting is changed. The workflow has
|
||||
socket, or host mount is passed. The release descriptor is unsigned and is
|
||||
uploaded with its SHA-256 sidecar as a Gitea Actions artifact using the
|
||||
full-SHA-pinned Gitea-compatible upload-artifact v3 action. Gitea 1.25.5
|
||||
provides the artifact runtime endpoints and act_runner 0.2.13 exposes
|
||||
`ACTIONS_RUNTIME_URL` independently of the cache URL. PR CI uploads a small
|
||||
synthetic marker with one-day retention to verify that endpoint without
|
||||
registry credentials. Runner cache remains disabled and unchanged. The release
|
||||
artifact requests 90-day retention, subject to the instance's configured cap;
|
||||
the owner must confirm retention covers the rollback window. The workflow has
|
||||
not yet been run with registry credentials.
|
||||
|
||||
## Installation and registration
|
||||
|
||||
@@ -30,6 +30,15 @@ conflicts with this dated recheck.
|
||||
- Gitea is `1.25.5`; `main` is `b5adc09203cbbbd298054248206b9f47ae4a44ca`.
|
||||
CI run 175 and runner smoke 176 succeeded on that commit. Package write
|
||||
access and runner inventory remain outside this API principal's permissions.
|
||||
- PR #59 head `852d639c455ff1c60202405e112d47b8128d66c6` received formal
|
||||
review #308 with five requested fixes. CI run 177 failed in
|
||||
`test_digest_rollback_fails_before_stopping_or_changing_references` because
|
||||
the test's global `Path.stat` mock made `Path.exists()` report a false
|
||||
recovery-journal hit under CI Python 3.12; runner smoke 178 succeeded. A
|
||||
fix batch in this PR isolates state paths, handles ambiguous alias/cutover
|
||||
outcomes, verifies rollback image IDs, and adds a synthetic artifact upload
|
||||
check. Gitea CI for the updated head still needs to confirm the change; no
|
||||
live release or VPS change was made.
|
||||
- Owner-only outstanding evidence: sanitized container/volume and protected
|
||||
file metadata; current backup and separate-target restore verification;
|
||||
live corpus/index counts and RU/KY canaries; Cloudflare TLS mode; registry
|
||||
|
||||
@@ -16,9 +16,12 @@ or raw process environments.
|
||||
VPS, runner, or production service credentials.
|
||||
- Confirm the registry retention rule keeps all digest-pinned versions for at
|
||||
least the rollback window. State the number of versions/days, not secret data.
|
||||
- The release descriptor is printed to the authenticated workflow run log; the
|
||||
runner's artifact/cache service remains disabled to avoid extra disk use.
|
||||
- The current descriptor is unsigned. Decide whether Gitea run/artifact
|
||||
- Confirm Gitea artifact retention also covers the rollback window. The main-only
|
||||
release workflow requests 90 days for its small descriptor and checksum;
|
||||
instance policy may cap that value. PR CI uploads a tiny marker for one day
|
||||
to validate the runner artifact endpoint. Neither requires enabling the
|
||||
runner cache service.
|
||||
- The release descriptor is unsigned. Decide whether Gitea run/artifact
|
||||
traceability is sufficient for the first owner-run release or require a
|
||||
separately provisioned signing/attestation service before publishing.
|
||||
|
||||
|
||||
@@ -29,9 +29,12 @@ against the separately pinned previous refs and image IDs, verifies image
|
||||
availability, makes and verifies a second cold backup, and switches both
|
||||
protected image refs atomically. Digest references are never retagged; legacy
|
||||
tag images are restored by image ID. Compose runs with `--no-build`, followed
|
||||
by healthcheck and local HTTPS validation. A failed cutover or rollback keeps
|
||||
a secret-free recovery journal and blocks another release until an owner
|
||||
resolves it. A legacy deployment without release state can still roll back
|
||||
by a check that both running container image IDs equal the selected restore
|
||||
plan, followed by healthcheck and local HTTPS validation. A mismatch fails the
|
||||
rollback, restores the prior references when they can be verified, and keeps a
|
||||
secret-free recovery journal; it never records the expected IDs as if they
|
||||
were running. A failed cutover or rollback blocks another release until an
|
||||
owner resolves the journal. A legacy deployment without release state can still roll back
|
||||
matching legacy tags, but digest rollback is refused until the state is
|
||||
established by the reviewed release helper.
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ def require_backup_matches_current(current: dict, backup: dict) -> None:
|
||||
raise ValueError("backup image references or image IDs do not match the current deployment")
|
||||
|
||||
|
||||
def restore_previous_references(old_env: dict[str, str], new_env: dict[str, str]) -> None:
|
||||
_, actual = read_images(ENV_FILE)
|
||||
if actual == new_env:
|
||||
replace_images(ENV_FILE, new_env, old_env)
|
||||
elif actual != old_env:
|
||||
raise ValueError("production image references match neither the previous nor target release")
|
||||
_, restored = read_images(ENV_FILE)
|
||||
if restored != old_env:
|
||||
raise ValueError("previous production image references could not be verified")
|
||||
|
||||
|
||||
def app_images(value: dict, label: str) -> dict[str, dict[str, str]]:
|
||||
images = value.get("images")
|
||||
if not isinstance(images, dict) or not set(SERVICES) <= images.keys():
|
||||
@@ -177,12 +188,10 @@ def deploy(manifest_path: Path, backup_path: Path, policy_path: Path, canary_ru:
|
||||
"previous": current, "target": target,
|
||||
}
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
switched = False
|
||||
state_written = False
|
||||
try:
|
||||
compose("stop")
|
||||
replace_images(ENV_FILE, old_env, new_env)
|
||||
switched = True
|
||||
journal["status"] = "references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
@@ -211,25 +220,23 @@ def deploy(manifest_path: Path, backup_path: Path, policy_path: Path, canary_ru:
|
||||
raise ValueError(f"release is active; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
journal["status"] = "cutover-failed"
|
||||
journal["error_type"] = type(error).__name__
|
||||
if switched:
|
||||
try:
|
||||
replace_images(ENV_FILE, new_env, old_env)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
write_deployed_backend_image(current["backend"]["image_id"])
|
||||
journal["recovery"] = "previous references restored; verify service health"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = f"manual recovery required ({type(recovery_error).__name__}); references and backup are recorded"
|
||||
else:
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
write_deployed_backend_image(current["backend"]["image_id"])
|
||||
journal["recovery"] = "previous stack restarted"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = f"manual recovery required ({type(recovery_error).__name__}); backup is recorded"
|
||||
if pinned is not None:
|
||||
write_json_atomic(STATE_FILE, pinned)
|
||||
elif (STATE_FILE.exists() or STATE_FILE.is_symlink()) and not STATE_FILE.is_symlink():
|
||||
STATE_FILE.unlink()
|
||||
try:
|
||||
restore_previous_references(old_env, new_env)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
restored = image_ids()
|
||||
if restored != current:
|
||||
raise ValueError("restored running image IDs do not match the previous release")
|
||||
write_deployed_backend_image(current["backend"]["image_id"])
|
||||
if pinned is not None:
|
||||
write_json_atomic(STATE_FILE, pinned)
|
||||
elif (STATE_FILE.exists() or STATE_FILE.is_symlink()) and not STATE_FILE.is_symlink():
|
||||
STATE_FILE.unlink()
|
||||
journal["recovery"] = "previous references and running image IDs restored and verified"
|
||||
except Exception as recovery_error:
|
||||
journal["recovery"] = (
|
||||
f"manual recovery required ({type(recovery_error).__name__}); "
|
||||
"references, release state, and backup are recorded; services were not restarted unless previous refs were verified"
|
||||
)
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
raise ValueError(f"cutover failed; recovery journal preserved at {JOURNAL_FILE}") from error
|
||||
|
||||
|
||||
+39
-13
@@ -20,6 +20,7 @@ BACKUP_DIR = Path("/var/backups/akyldash")
|
||||
BACKUP_TOOL = Path("/usr/local/lib/akyldash/archive.py")
|
||||
STATE_FILE = Path("/opt/akyldash/release-state.json")
|
||||
JOURNAL_FILE = Path("/opt/akyldash/release-recovery.json")
|
||||
DEPLOYED_IMAGE_FILE = Path("/opt/akyldash/deployed-image.txt")
|
||||
APP_SERVICES = {"backend", "opensearch"}
|
||||
IMAGE_TAG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*")
|
||||
IMAGE_DIGEST_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*@sha256:[0-9a-f]{64}")
|
||||
@@ -111,7 +112,7 @@ def record_backend_image() -> None:
|
||||
image_id = run(["docker", "inspect", "--format", "{{.Image}}", container], capture=True)
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError("backend returned an invalid image ID")
|
||||
target = Path("/opt/akyldash/deployed-image.txt")
|
||||
target = DEPLOYED_IMAGE_FILE
|
||||
with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".deployed-image-", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
stream.write(f"{image_id}\n".encode("ascii"))
|
||||
@@ -127,6 +128,27 @@ def record_backend_image() -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def running_image_ids() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for service in sorted(APP_SERVICES):
|
||||
container = compose("ps", "--all", "--quiet", service, capture=True)
|
||||
if not container or "\n" in container:
|
||||
raise ValueError(f"expected exactly one running {service} container")
|
||||
inspection = run(
|
||||
["docker", "inspect", "--format", "{{.State.Running}}\t{{.Image}}", container], capture=True
|
||||
)
|
||||
running, separator, image_id = inspection.partition("\t")
|
||||
if not separator or running != "true" or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError(f"{service} container is not running with a valid image ID")
|
||||
result[service] = image_id
|
||||
return result
|
||||
|
||||
|
||||
def require_running_image_ids(expected: dict[str, str], actual: dict[str, str], label: str) -> None:
|
||||
if actual != expected:
|
||||
raise ValueError(f"running image IDs do not match the {label}")
|
||||
|
||||
|
||||
def rollback(backup: Path, data_compatible: bool) -> None:
|
||||
sys.path.insert(0, str(BACKUP_TOOL.parent))
|
||||
from deployment_lock import deployment_lock
|
||||
@@ -160,6 +182,7 @@ def _rollback_locked(backup: Path, data_compatible: bool) -> None:
|
||||
raise ValueError("Compose or Caddyfile changed since this backup; restore the matching Git revision first")
|
||||
rendered = json.loads(compose("config", "--format", "json", capture=True))
|
||||
configured = {service: rendered["services"][service]["image"] for service in APP_SERVICES}
|
||||
current_running_ids = running_image_ids()
|
||||
state = None
|
||||
expected_previous = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
@@ -169,18 +192,11 @@ def _rollback_locked(backup: Path, data_compatible: bool) -> None:
|
||||
current_refs = {service: state["current"][service]["configured"] for service in APP_SERVICES}
|
||||
if current_refs != configured:
|
||||
raise ValueError("release state does not match current Compose image references")
|
||||
for service in APP_SERVICES:
|
||||
container = compose("ps", "--all", "--quiet", service, capture=True)
|
||||
if not container or "\n" in container:
|
||||
raise ValueError(f"expected exactly one current {service} container")
|
||||
inspection = run(
|
||||
["docker", "inspect", "--format", "{{.State.Running}}\t{{.Image}}", container], capture=True
|
||||
)
|
||||
running, separator, actual_id = inspection.partition("\t")
|
||||
if not separator or running != "true":
|
||||
raise ValueError(f"current {service} is not running; refusing rollback")
|
||||
if actual_id != state["current"][service]["image_id"]:
|
||||
raise ValueError(f"current {service} image ID does not match pinned release state")
|
||||
expected_current = {service: state["current"][service]["image_id"] for service in APP_SERVICES}
|
||||
try:
|
||||
require_running_image_ids(expected_current, current_running_ids, "pinned release state")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"current deployment does not match pinned release state: {error}") from error
|
||||
expected_previous = state["previous"]
|
||||
plan = image_restore_plan(manifest, configured, expected_previous)
|
||||
verify_previous_images(plan)
|
||||
@@ -241,6 +257,8 @@ def _rollback_locked(backup: Path, data_compatible: bool) -> None:
|
||||
journal["status"] = "rollback-references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
expected_restored = {service: plan[service][0] for service in APP_SERVICES}
|
||||
require_running_image_ids(expected_restored, running_image_ids(), "rollback plan")
|
||||
run(
|
||||
[
|
||||
"curl", "--fail", "--silent", "--show-error", "--resolve", "akyldash.kg:443:127.0.0.1",
|
||||
@@ -282,7 +300,14 @@ def _rollback_locked(backup: Path, data_compatible: bool) -> None:
|
||||
active = {"BACKEND_IMAGE": current_env["BACKEND_IMAGE"], "OPENSEARCH_IMAGE": current_env["OPENSEARCH_IMAGE"]}
|
||||
if active == after:
|
||||
replace_images(ENV_FILE, after, before)
|
||||
elif active != before:
|
||||
raise ValueError("image references match neither the rolled-back nor pre-rollback release")
|
||||
_, verified_env = read_images(ENV_FILE)
|
||||
if {"BACKEND_IMAGE": verified_env["BACKEND_IMAGE"],
|
||||
"OPENSEARCH_IMAGE": verified_env["OPENSEARCH_IMAGE"]} != before:
|
||||
raise ValueError("pre-rollback image references could not be verified")
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
require_running_image_ids(expected_current, running_image_ids(), "previous release state")
|
||||
record_backend_image()
|
||||
write_json_atomic(STATE_FILE, state)
|
||||
journal["recovery"] = "current release references restored; verify service health"
|
||||
@@ -296,6 +321,7 @@ def _rollback_locked(backup: Path, data_compatible: bool) -> None:
|
||||
journal["error_type"] = type(error).__name__
|
||||
try:
|
||||
compose("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300")
|
||||
require_running_image_ids(current_running_ids, running_image_ids(), "pre-rollback deployment")
|
||||
record_backend_image()
|
||||
journal["recovery"] = "legacy release restarted; verify service health"
|
||||
except Exception as recovery_error:
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
import deploy_release
|
||||
import deployment_lock
|
||||
import release_refs
|
||||
|
||||
|
||||
class DeploymentCutoverTests(unittest.TestCase):
|
||||
@@ -46,6 +47,35 @@ class DeploymentCutoverTests(unittest.TestCase):
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
descriptor.close()
|
||||
|
||||
def test_fsync_error_after_atomic_replace_restores_previous_references(self):
|
||||
old = {"BACKEND_IMAGE": "akyldash/backend:old", "OPENSEARCH_IMAGE": "akyldash/opensearch:old"}
|
||||
new = {"BACKEND_IMAGE": "registry/backend@sha256:" + "c" * 64,
|
||||
"OPENSEARCH_IMAGE": "registry/opensearch@sha256:" + "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / "production.env"
|
||||
env_file.write_text("BACKEND_IMAGE=akyldash/backend:old\nOPENSEARCH_IMAGE=akyldash/opensearch:old\n")
|
||||
original_fsync = release_refs.os.fsync
|
||||
calls = 0
|
||||
|
||||
def fail_first_directory_fsync(descriptor):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise OSError("simulated directory fsync failure after replace")
|
||||
original_fsync(descriptor)
|
||||
|
||||
with patch.object(release_refs.os, "fsync", side_effect=fail_first_directory_fsync):
|
||||
with self.assertRaisesRegex(OSError, "after replace"):
|
||||
release_refs.replace_images(env_file, old, new, require_root=False)
|
||||
self.assertEqual(release_refs.read_images(env_file)[1], new)
|
||||
with (
|
||||
patch.object(deploy_release, "ENV_FILE", env_file),
|
||||
patch.object(deploy_release, "replace_images", side_effect=lambda path, expected, replacement:
|
||||
release_refs.replace_images(path, expected, replacement, require_root=False)),
|
||||
):
|
||||
deploy_release.restore_previous_references(old, new)
|
||||
self.assertEqual(release_refs.read_images(env_file)[1], old)
|
||||
|
||||
def test_failure_after_atomic_ref_switch_keeps_recovery_journal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
@@ -78,10 +108,12 @@ class DeploymentCutoverTests(unittest.TestCase):
|
||||
patch.object(deploy_release, "run", side_effect=run_calls),
|
||||
patch.object(deploy_release, "compose", side_effect=compose_calls) as compose,
|
||||
patch.object(deploy_release, "replace_images") as replace,
|
||||
patch.object(deploy_release, "restore_previous_references") as restore,
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "recovery journal preserved"):
|
||||
deploy_release.deploy(manifest_file, backup_path, policy_file, "закон", "мыйзам")
|
||||
self.assertEqual(replace.call_count, 2)
|
||||
self.assertEqual(replace.call_count, 1)
|
||||
restore.assert_called_once()
|
||||
self.assertEqual(compose.call_args_list[-1].args, ("up", "--detach", "--no-build", "--wait", "--wait-timeout", "300"))
|
||||
journal = json.loads((directory / "recovery.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(journal["status"], "cutover-failed")
|
||||
|
||||
@@ -2,6 +2,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
GITEA_UPLOAD_ARTIFACT = "https://gitea.com/actions/gitea-upload-artifact@055b8b3f04a4a7ed853f2c6ab04256f83e4874dc"
|
||||
|
||||
|
||||
class ReleaseWorkflowTests(unittest.TestCase):
|
||||
@@ -12,12 +13,20 @@ class ReleaseWorkflowTests(unittest.TestCase):
|
||||
self.assertNotIn("pull_request:", workflow)
|
||||
self.assertIn("secrets.REGISTRY_USERNAME", workflow)
|
||||
self.assertIn("secrets.REGISTRY_PASSWORD", workflow)
|
||||
self.assertIn("Print verified release descriptor for the owner", workflow)
|
||||
self.assertIn(GITEA_UPLOAD_ARTIFACT, workflow)
|
||||
self.assertIn("release/release-manifest.json.sha256", workflow)
|
||||
self.assertIn("retention-days: 90", workflow)
|
||||
self.assertIn("sha256sum -c release/release-manifest.json.sha256", workflow)
|
||||
self.assertIn("cat release/release-manifest.json", workflow)
|
||||
self.assertNotIn("upload-artifact", workflow)
|
||||
self.assertNotIn("docker compose", workflow)
|
||||
|
||||
def test_pr_ci_has_small_synthetic_artifact_upload_without_registry_secrets(self):
|
||||
workflow = (ROOT / ".gitea/workflows/ci.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("Verify Gitea runtime artifact upload", workflow)
|
||||
self.assertIn(GITEA_UPLOAD_ARTIFACT, workflow)
|
||||
self.assertIn("retention-days: 1", workflow)
|
||||
self.assertIn("artifact-smoke/marker.txt", workflow)
|
||||
self.assertNotIn("REGISTRY_PASSWORD", workflow)
|
||||
|
||||
def test_pr_build_remains_no_push_and_push_action_has_no_engine_socket(self):
|
||||
build_action = (ROOT / ".gitea/actions/kaniko-build/action.yml").read_text(encoding="utf-8")
|
||||
push_action = (ROOT / ".gitea/actions/kaniko-push/release-build.sh").read_text(encoding="utf-8")
|
||||
|
||||
+192
-43
@@ -8,7 +8,15 @@ from unittest.mock import patch
|
||||
|
||||
import rollback
|
||||
import deployment_lock
|
||||
from rollback import image_restore_plan, require_tag_rollback, tag_previous_images, verify_previous_images
|
||||
import release_refs
|
||||
import release_state
|
||||
from rollback import (
|
||||
image_restore_plan,
|
||||
require_running_image_ids,
|
||||
require_tag_rollback,
|
||||
tag_previous_images,
|
||||
verify_previous_images,
|
||||
)
|
||||
|
||||
|
||||
class RollbackPlanTests(unittest.TestCase):
|
||||
@@ -29,6 +37,12 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
self.assertEqual(plan["backend"][1], "akyldash/backend:local")
|
||||
self.assertEqual(plan["opensearch"][1], "akyldash/opensearch:local")
|
||||
|
||||
def test_rollback_rejects_running_images_that_differ_from_restore_plan(self):
|
||||
expected = {"backend": "sha256:" + "a" * 64, "opensearch": "sha256:" + "b" * 64}
|
||||
actual = {**expected, "opensearch": "sha256:" + "c" * 64}
|
||||
with self.assertRaisesRegex(ValueError, "rollback plan"):
|
||||
require_running_image_ids(expected, actual, "rollback plan")
|
||||
|
||||
def test_refuses_changed_compose_image_reference(self):
|
||||
with self.assertRaisesRegex(ValueError, "reference changed"):
|
||||
image_restore_plan(self.manifest, {**self.configured, "backend": "other/backend:tag"})
|
||||
@@ -115,24 +129,45 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
digest=lambda path: "compose-hash" if path.name == "compose.yaml" else "caddy-hash",
|
||||
verify_archive=lambda _backup: manifest,
|
||||
)
|
||||
backup = rollback.BACKUP_DIR / "synthetic.tar.gz"
|
||||
lock_dir = tempfile.TemporaryDirectory()
|
||||
with (
|
||||
patch.object(deployment_lock, "LOCK_FILE", rollback.Path(lock_dir.name) / "deploy.lock"),
|
||||
patch("rollback.Path.is_file", return_value=True),
|
||||
patch("rollback.Path.is_symlink", return_value=False),
|
||||
patch("rollback.Path.stat", return_value=SimpleNamespace(st_uid=0, st_mode=0o100600)),
|
||||
patch("rollback.compose", return_value=json.dumps(rendered)) as compose_mock,
|
||||
patch("rollback.run", side_effect=[backend_id, backend_id, opensearch_id]) as run_mock,
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
compose_mock.assert_called_once_with("config", "--format", "json", capture=True)
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
self.assertTrue(all(call.args[0][:3] == ["docker", "image", "inspect"]
|
||||
for call in run_mock.call_args_list))
|
||||
lock_dir.cleanup()
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
temporary = rollback.Path(temporary)
|
||||
backup_dir = temporary / "backups"
|
||||
backup_dir.mkdir()
|
||||
backup = backup_dir / "synthetic.tar.gz"
|
||||
original_stat = rollback.Path.stat
|
||||
|
||||
def stat_backup_only(path, *args, **kwargs):
|
||||
if path in {backup, rollback.Path(f"{backup}.sha256")}:
|
||||
return SimpleNamespace(st_uid=0, st_mode=0o100600)
|
||||
return original_stat(path, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(deployment_lock, "LOCK_FILE", temporary / "deploy.lock"),
|
||||
patch.object(rollback, "JOURNAL_FILE", temporary / "recovery.json"),
|
||||
patch.object(rollback, "STATE_FILE", temporary / "release-state.json"),
|
||||
patch.object(rollback, "ENV_FILE", temporary / "production.env"),
|
||||
patch.object(rollback, "DEPLOYED_IMAGE_FILE", temporary / "deployed-image.txt"),
|
||||
patch.object(rollback, "BACKUP_DIR", backup_dir),
|
||||
patch.object(rollback, "ROOT", temporary / "checkout"),
|
||||
patch.object(rollback, "COMPOSE_FILE", temporary / "checkout/deploy/production/compose.yaml"),
|
||||
patch.object(rollback, "BACKUP_TOOL", temporary / "archive.py"),
|
||||
patch("rollback.Path.is_file", return_value=True),
|
||||
patch("rollback.Path.is_symlink", return_value=False),
|
||||
patch("pathlib.Path.stat", autospec=True, side_effect=stat_backup_only),
|
||||
patch("rollback.compose", return_value=json.dumps(rendered)) as compose_mock,
|
||||
patch("rollback.running_image_ids", return_value={
|
||||
"backend": backend_id,
|
||||
"opensearch": self.manifest["images"]["opensearch"]["image_id"],
|
||||
}),
|
||||
patch("rollback.run", side_effect=[backend_id, backend_id, opensearch_id]) as run_mock,
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "not supported safely"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
compose_mock.assert_called_once_with("config", "--format", "json", capture=True)
|
||||
self.assertEqual(run_mock.call_count, 3)
|
||||
self.assertTrue(all(call.args[0][:3] == ["docker", "image", "inspect"]
|
||||
for call in run_mock.call_args_list))
|
||||
|
||||
def test_mismatched_release_backup_is_rejected_before_stopping_services(self):
|
||||
current = {
|
||||
@@ -158,30 +193,144 @@ class RollbackPlanTests(unittest.TestCase):
|
||||
digest=lambda path: "compose-hash" if path.name == "compose.yaml" else "caddy-hash",
|
||||
verify_archive=lambda _backup: manifest,
|
||||
)
|
||||
lock_dir = tempfile.TemporaryDirectory()
|
||||
backup = rollback.BACKUP_DIR / "synthetic.tar.gz"
|
||||
with (
|
||||
patch.object(deployment_lock, "LOCK_FILE", rollback.Path(lock_dir.name) / "deploy.lock"),
|
||||
patch("rollback.Path.exists", autospec=True,
|
||||
side_effect=lambda path: path == rollback.STATE_FILE),
|
||||
patch("rollback.Path.is_file", return_value=True),
|
||||
patch("rollback.Path.is_symlink", return_value=False),
|
||||
patch("rollback.Path.stat", return_value=SimpleNamespace(st_uid=0, st_mode=0o100600)),
|
||||
patch("rollback.compose", side_effect=lambda *args, **_kwargs: (
|
||||
json.dumps(rendered) if args[:2] == ("config", "--format")
|
||||
else "backend-container" if args[-1] == "backend"
|
||||
else "search-container"
|
||||
)) as compose_mock,
|
||||
patch("rollback.run", side_effect=lambda command, **_kwargs: "true\t" + current[
|
||||
"backend" if command[-1] == "backend-container" else "opensearch"
|
||||
]["image_id"]),
|
||||
patch("release_state.read_json", return_value=state),
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "backup does not match the pinned previous backend"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
self.assertFalse(any(call.args and call.args[0] == "stop" for call in compose_mock.call_args_list))
|
||||
lock_dir.cleanup()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = rollback.Path(directory)
|
||||
backup_dir = root / "backups"
|
||||
backup_dir.mkdir()
|
||||
backup = backup_dir / "synthetic.tar.gz"
|
||||
state_file = root / "release-state.json"
|
||||
checksum = rollback.Path(f"{backup}.sha256")
|
||||
original_stat = rollback.Path.stat
|
||||
|
||||
def stat_backup_only(path, *args, **kwargs):
|
||||
if path in {backup, checksum}:
|
||||
return SimpleNamespace(st_uid=0, st_mode=0o100600)
|
||||
return original_stat(path, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(deployment_lock, "LOCK_FILE", root / "deploy.lock"),
|
||||
patch.object(rollback, "JOURNAL_FILE", root / "recovery.json"),
|
||||
patch.object(rollback, "STATE_FILE", state_file),
|
||||
patch.object(rollback, "ENV_FILE", root / "production.env"),
|
||||
patch.object(rollback, "DEPLOYED_IMAGE_FILE", root / "deployed-image.txt"),
|
||||
patch.object(rollback, "BACKUP_DIR", backup_dir),
|
||||
patch.object(rollback, "ROOT", root / "checkout"),
|
||||
patch.object(rollback, "COMPOSE_FILE", root / "checkout/deploy/production/compose.yaml"),
|
||||
patch.object(rollback, "BACKUP_TOOL", root / "archive.py"),
|
||||
patch("pathlib.Path.exists", autospec=True, side_effect=lambda path: path == state_file),
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
patch("pathlib.Path.is_symlink", return_value=False),
|
||||
patch("pathlib.Path.stat", autospec=True, side_effect=stat_backup_only),
|
||||
patch("rollback.compose", side_effect=lambda *args, **_kwargs: (
|
||||
json.dumps(rendered) if args[:2] == ("config", "--format")
|
||||
else "backend-container" if args[-1] == "backend"
|
||||
else "search-container"
|
||||
)) as compose_mock,
|
||||
patch("rollback.run", side_effect=lambda command, **_kwargs: "true\t" + current[
|
||||
"backend" if command[-1] == "backend-container" else "opensearch"
|
||||
]["image_id"]),
|
||||
patch("release_state.read_json", return_value=state),
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "backup does not match the pinned previous backend"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
self.assertFalse(any(call.args and call.args[0] == "stop" for call in compose_mock.call_args_list))
|
||||
|
||||
def test_mismatched_running_rollback_images_preserve_state_and_recovery_journal(self):
|
||||
current = {
|
||||
"backend": {"configured": "registry/backend@sha256:" + "d" * 64, "image_id": "sha256:" + "e" * 64},
|
||||
"opensearch": {"configured": "registry/search@sha256:" + "f" * 64, "image_id": "sha256:" + "1" * 64},
|
||||
}
|
||||
previous = {
|
||||
"backend": {"configured": "akyldash/backend:previous", "image_id": "sha256:" + "a" * 64},
|
||||
"opensearch": {"configured": "akyldash/opensearch:previous", "image_id": "sha256:" + "b" * 64},
|
||||
}
|
||||
manifest = {
|
||||
"images": previous,
|
||||
"deployment_files": {"deploy/production/compose.yaml": "compose-hash",
|
||||
"deploy/production/Caddyfile": "caddy-hash"},
|
||||
}
|
||||
state = {"schema_version": 1, "commit": "a" * 40, "manifest_sha256": "b" * 64,
|
||||
"current": current, "previous": previous}
|
||||
current_refs = {"BACKEND_IMAGE": current["backend"]["configured"],
|
||||
"OPENSEARCH_IMAGE": current["opensearch"]["configured"]}
|
||||
previous_refs = {"BACKEND_IMAGE": previous["backend"]["configured"],
|
||||
"OPENSEARCH_IMAGE": previous["opensearch"]["configured"]}
|
||||
rendered = {"services": {"backend": {"image": current_refs["BACKEND_IMAGE"]},
|
||||
"opensearch": {"image": current_refs["OPENSEARCH_IMAGE"]}}}
|
||||
fake_archive = SimpleNamespace(
|
||||
digest=lambda path: "compose-hash" if path.name == "compose.yaml" else "caddy-hash",
|
||||
verify_archive=lambda _backup: manifest,
|
||||
)
|
||||
expected_current_ids = {service: current[service]["image_id"] for service in current}
|
||||
unexpected_ids = {"backend": previous["backend"]["image_id"],
|
||||
"opensearch": "sha256:" + "c" * 64}
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = rollback.Path(directory)
|
||||
backup_dir = root / "backups"
|
||||
backup_dir.mkdir()
|
||||
backup = backup_dir / "verified.tar.gz"
|
||||
state_file = root / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
env_file = root / "production.env"
|
||||
env_file.write_text(
|
||||
f"BACKEND_IMAGE={current_refs['BACKEND_IMAGE']}\n"
|
||||
f"OPENSEARCH_IMAGE={current_refs['OPENSEARCH_IMAGE']}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
checksum = rollback.Path(f"{backup}.sha256")
|
||||
original_stat = rollback.Path.stat
|
||||
|
||||
def stat_backup_only(path, *args, **kwargs):
|
||||
if path in {backup, checksum}:
|
||||
return SimpleNamespace(st_uid=0, st_mode=0o100600)
|
||||
return original_stat(path, *args, **kwargs)
|
||||
|
||||
original_replace = release_refs.replace_images
|
||||
|
||||
def replace_without_root(path, expected, replacement):
|
||||
return original_replace(path, expected, replacement, require_root=False)
|
||||
|
||||
with (
|
||||
patch.object(deployment_lock, "LOCK_FILE", root / "deploy.lock"),
|
||||
patch.object(rollback, "JOURNAL_FILE", root / "recovery.json"),
|
||||
patch.object(rollback, "STATE_FILE", state_file),
|
||||
patch.object(rollback, "ENV_FILE", env_file),
|
||||
patch.object(rollback, "DEPLOYED_IMAGE_FILE", root / "deployed-image.txt"),
|
||||
patch.object(rollback, "BACKUP_DIR", backup_dir),
|
||||
patch.object(rollback, "ROOT", root / "checkout"),
|
||||
patch.object(rollback, "COMPOSE_FILE", root / "checkout/deploy/production/compose.yaml"),
|
||||
patch.object(rollback, "BACKUP_TOOL", root / "archive.py"),
|
||||
patch("pathlib.Path.is_file", return_value=True),
|
||||
patch("pathlib.Path.is_symlink", return_value=False),
|
||||
patch("pathlib.Path.stat", autospec=True, side_effect=stat_backup_only),
|
||||
patch("rollback.compose", side_effect=lambda *args, **_kwargs: (
|
||||
json.dumps(rendered) if args[:2] == ("config", "--format")
|
||||
else "backend-container" if args[-1] == "backend"
|
||||
else "search-container" if args[-1] == "opensearch"
|
||||
else ""
|
||||
)),
|
||||
patch("rollback.running_image_ids", side_effect=[
|
||||
expected_current_ids, unexpected_ids, expected_current_ids
|
||||
]),
|
||||
patch("rollback.run", side_effect=lambda command, **_kwargs: (
|
||||
command[-1] if command[1:3] == ["image", "inspect"]
|
||||
else current["backend"]["image_id"] if command[:2] == ["docker", "inspect"]
|
||||
else ""
|
||||
)),
|
||||
patch("release_refs.replace_images", side_effect=replace_without_root),
|
||||
patch("release_state.read_json", return_value=state),
|
||||
patch("release_state.write_json_atomic", wraps=release_state.write_json_atomic),
|
||||
patch.dict(sys.modules, {"archive": fake_archive}),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "rollback plan"):
|
||||
rollback.rollback(backup, data_compatible=True)
|
||||
journal = json.loads((root / "recovery.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(journal["status"], "rollback-failed")
|
||||
self.assertIn("current release references restored", journal["recovery"])
|
||||
self.assertEqual(json.loads(state_file.read_text(encoding="utf-8")), state)
|
||||
self.assertEqual(release_refs.read_images(env_file)[1], current_refs)
|
||||
|
||||
def test_tag_rollback_keeps_existing_image_tag_operation(self):
|
||||
plan = image_restore_plan(self.manifest, self.configured)
|
||||
|
||||
Reference in new issue
Block a user