fix: address production release review findings
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 2s

This commit is contained in:
agent committed 2026-10-25 13:44:56 +03:00
1 parent 852d639c45
commit 9d8ce6b67f
16 files changed
+505 -128

No files matched your search

+10
View File
@@ -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
+10 -5
View File
@@ -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.'
+5
View File
@@ -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 и
+34 -12
View File
@@ -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}")
+5 -5
View File
@@ -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'));
+91 -1
View File
@@ -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
View File
@@ -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
+9 -7
View File
@@ -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
+9
View File
@@ -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
+6 -3
View File
@@ -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.
+6 -3
View File
@@ -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.
+28 -21
View File
@@ -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
View File
@@ -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:
+33 -1
View File
@@ -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")
+12 -3
View File
@@ -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
View File
@@ -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)