Record populated pilot capture and prepare encrypted export
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
This commit is contained in:
parent
8fd2c9772d
commit
d2e1377354
11 changed files with 713 additions and 8 deletions
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"schema": "railiance.vergabe-pilot-user-return.v1",
|
||||
"reported_on": "2026-11-06",
|
||||
"workplan": "RAPPS-WP-0014",
|
||||
"task": "RAPPS-WP-0014-T03",
|
||||
"run_id": "20261105T233543Z-89bfbe04dece",
|
||||
"evidence_kind": "user_reported",
|
||||
"two_accounts_work_after_restart": true,
|
||||
"user_report": "Both accounts work. The Ausschreibung is no longer listed on the \u00dcbersicht; everything else seems as before.",
|
||||
"human_restart_check": "confirmed_with_overview_followup",
|
||||
"overview_omission_reported": true,
|
||||
"overview_followup": {
|
||||
"deployed_source_revision": "c067993037f1cdeeb7b22d5600b51c649bc239db",
|
||||
"source_file": "vergabe_teilnahme/apps/ausschreibungen/views.py",
|
||||
"function": "dashboard",
|
||||
"ongoing_list_filter": "status__range=(1, 9); latest ten by geaendert_am",
|
||||
"final_statuses_omitted": [
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13
|
||||
],
|
||||
"fixture_current_status": "not_reported; clarification requested",
|
||||
"explanation": "A final status would explain exclusion; phase 8 alone is included. No live status or data-loss cause is inferred."
|
||||
},
|
||||
"provider_publication_verified": false,
|
||||
"restore_verified": false,
|
||||
"authentication_policy_changed": false
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"schema": "railiance.vergabe-pilot-three-store-capture.v1",
|
||||
"run_id": "20261105T233543Z-89bfbe04dece",
|
||||
"status": "captured_and_resumed",
|
||||
"database": "vergabe_demo_company",
|
||||
"helm_revision": 4,
|
||||
"app_resumed": true,
|
||||
"off_host_verified": false,
|
||||
"restore_verified": false,
|
||||
"media_files": 1,
|
||||
"media_bytes": 803,
|
||||
"media_logical_sha256": "24fe90da88973ac03c8a475dff93c5866cebd797aeecdca2286989a1250779ed",
|
||||
"fixture_document_sha256": "23a6850fa2391a58280c8515361ca29e9e92fa65fdadc36be7b44b552192e381",
|
||||
"sqlite": {
|
||||
"tables": 9,
|
||||
"rows": 7,
|
||||
"logical_sha256": "579dedb0e6a6b30f6cf3a5cec27ae423dbdaf0de959837cb9d697f6d5a789258",
|
||||
"linked_issue_found": true,
|
||||
"integrity_verified": true
|
||||
},
|
||||
"stores_sha256": "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66",
|
||||
"stores_bytes": 3489,
|
||||
"database_sha256": "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c",
|
||||
"database_bytes": 169232,
|
||||
"database_tables": 39,
|
||||
"database_rows": 247,
|
||||
"database_logical_sha256": "33191c43c437f1b05da410a3f22989c86ce50f4fca8fd32d40fbb327b8b73765",
|
||||
"writers_quiesced": true,
|
||||
"anonymous_document_http_status": 302,
|
||||
"anonymous_document_denied": true,
|
||||
"helper_cleanup_confirmed": true,
|
||||
"restart_fixture_verified": true,
|
||||
"elapsed_seconds": 98.61,
|
||||
"evidence_kind": "user_reported_terminal_output",
|
||||
"reported_on": "2026-11-06",
|
||||
"workplan": "RAPPS-WP-0014",
|
||||
"task": "RAPPS-WP-0014-T03",
|
||||
"command": "python3 tools/vergabe_pilot_snapshot_remote.py --execute",
|
||||
"founder_executed_reviewed_change": true,
|
||||
"elapsed_seconds_scope": "Whole helper operation after preflight; not separately measured outage duration",
|
||||
"human_two_account_post_restart_walkthrough": "confirmed_with_overview_followup",
|
||||
"production_data_restored": false,
|
||||
"human_return_receipt": "2026-11-06-vergabe-post-restart-user-return.json"
|
||||
}
|
||||
85
docs/proposals/2026-11-06-vergabe-recovery-export/README.md
Normal file
85
docs/proposals/2026-11-06-vergabe-recovery-export/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Export the populated Vergabe recovery point
|
||||
|
||||
The founder's [native capture receipt](../../evidence/2026-11-06-vergabe-three-store-capture-return.json)
|
||||
passes for run `20261105T233543Z-89bfbe04dece`. The app has resumed and both
|
||||
accounts work, with the [overview UI follow-up](../../evidence/2026-11-06-vergabe-post-restart-user-return.json).
|
||||
This step transfers the frozen point; it needs no further application pause.
|
||||
[Exact scope, pins and validation](plan.json).
|
||||
|
||||
## Founder workstation command
|
||||
|
||||
```bash
|
||||
git pull --ff-only
|
||||
python3 tools/vergabe_pilot_export_remote.py --fetch
|
||||
```
|
||||
|
||||
Default without `--fetch` reviews locally. `--fetch` uses the same existing
|
||||
interactive SSH/sudo lane as inventory and capture. The agent does not execute
|
||||
the founder's credential path. No remote checkout is needed.
|
||||
|
||||
The node helper requires the successful capture receipt and the exact database
|
||||
and stores sizes/hashes from the returned receipt. It packages only
|
||||
`database.dump`, `stores.tar.gz`, `manifest.private.json`, `receipt.json` and a
|
||||
generated packet manifest. Raw SQLite copies/work directories are excluded.
|
||||
|
||||
Using the existing platform **public** age recipient, it encrypts on railiance01
|
||||
and retains the encrypted point plus its hash manifest in the root-private run
|
||||
directory. A temporary plaintext package exists only inside that protected
|
||||
directory and is removed after sealing. The original capture is preserved.
|
||||
An interrupted, inconsistent seal stops for review rather than being overwritten.
|
||||
|
||||
The helper copies ciphertext and safe transfer metadata to two 0600 files in
|
||||
the existing founder SSH account's home (`tegwick`), named
|
||||
`vergabe-pilot-20261105T233543Z-89bfbe04dece.{zip.age,transfer.json}`.
|
||||
Only those encrypted handoff files are made readable by that account; the
|
||||
private capture files remain root-only. Existing different handoff bytes refuse.
|
||||
|
||||
The wrapper uses scp to fetch the pair into a private temporary directory, then
|
||||
checks the source run/image/database, captured database/store hashes, encrypted
|
||||
size and ciphertext hash. Only a verified pair becomes the final directory:
|
||||
|
||||
```text
|
||||
~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/
|
||||
```
|
||||
|
||||
It refuses a destination inside a Git tree. Files are 0600 and the directory is
|
||||
0700. An already verified pair can be reused on retry. Successful final JSON
|
||||
has `status: encrypted_workstation_copy_verified` and includes the local path.
|
||||
Return that JSON, without opening or pasting backup contents.
|
||||
|
||||
No Kubernetes/database access, application change, OpenBao access, provider call
|
||||
or recovery-key retrieval occurs. Age must already be available on the node at
|
||||
one of the standard or existing platform vendor paths. Missing age fails before
|
||||
sealing; this command does not install tools or alter the platform configuration.
|
||||
|
||||
## What this establishes
|
||||
|
||||
The encrypted bytes are now copied to the founder's workstation outside the
|
||||
production node, with transport hashes verified. This is a separate milestone
|
||||
from publication to the declared backup provider. The receipt keeps
|
||||
`provider_publication_verified`, `decryption_verified` and `restore_verified`
|
||||
false. Public-key encryption alone does not prove the escrow identity can recover
|
||||
the point, and no recurring protection or accepted recovery objective is inferred.
|
||||
|
||||
Credential routing confirms the existing `railiance-backup-offsite-lane` belongs
|
||||
to railiance-platform. Its Nextcloud file-drop credential is create-only;
|
||||
independent readback uses the Backup account, and age decryption uses recovery
|
||||
escrow in a contained founder login. Reuse those owner helpers for publication
|
||||
and readback; do not borrow another application's Scaleway publisher identity.
|
||||
No new credential lane, role, provider folder, retention change or timer is
|
||||
created by this export. The upload-only token is not readback authority.
|
||||
|
||||
After provider readback, rehearse an isolated restore of this matched point with
|
||||
outgoing integrations disabled. Verify every PostgreSQL table digest, documents
|
||||
and local issue linkage, restored account behavior, source/image availability and
|
||||
elapsed recovery time. Compatible rollback, access denial and named backup/support
|
||||
ownership, cadence, retention and accepted RPO/RTO remain T03 requirements.
|
||||
|
||||
## Validation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_vergabe_pilot_export.py`: seven
|
||||
synthetic checks pass. They include actual age encryption/decryption, complete
|
||||
packet comparison, changed capture/ciphertext rejection, unsafe source refusal,
|
||||
handoff preservation, verified transfer/retry and Git destination refusal.
|
||||
No native backup or real key is read by those tests. Native export remains
|
||||
founder-executed and unverified until its receipt returns.
|
||||
49
docs/proposals/2026-11-06-vergabe-recovery-export/plan.json
Normal file
49
docs/proposals/2026-11-06-vergabe-recovery-export/plan.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"schema": "railiance.vergabe-pilot-recovery-export-plan.v1",
|
||||
"prepared_on": "2026-11-06",
|
||||
"workplan": "RAPPS-WP-0014",
|
||||
"task": "RAPPS-WP-0014-T03",
|
||||
"run_id": "20261105T233543Z-89bfbe04dece",
|
||||
"sources": {
|
||||
"vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c",
|
||||
"vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a"
|
||||
},
|
||||
"execution": "Founder workstation --fetch: interactive SSH/sudo on railiance01, followed by scp of ciphertext and a safe manifest",
|
||||
"source": "/var/lib/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/",
|
||||
"scope": [
|
||||
"Verify accepted capture receipt and pinned database/stores byte sizes and SHA256",
|
||||
"Package only database.dump, stores.tar.gz, manifest.private.json, receipt.json and generated packet.json",
|
||||
"Encrypt on the node to the existing platform public age recipient",
|
||||
"Create mode-0600 encrypted handoff files for the existing founder SSH account",
|
||||
"Copy and checksum-verify ciphertext and transfer metadata in a private workstation directory outside Git"
|
||||
],
|
||||
"recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
|
||||
"recipient_source": "railiance-platform/lib/railiance-backup-common.sh",
|
||||
"node_ciphertext": "recovery-point.zip.age",
|
||||
"node_public_manifest": "seal.json",
|
||||
"founder_handoff_basename": "vergabe-pilot-20261105T233543Z-89bfbe04dece",
|
||||
"workstation_directory": "~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/",
|
||||
"application_mutations": false,
|
||||
"database_access": false,
|
||||
"secret_reads": false,
|
||||
"openbao_access": false,
|
||||
"provider_access": false,
|
||||
"provider_publication_verified": false,
|
||||
"decryption_verified": false,
|
||||
"restore_verified": false,
|
||||
"next_provider_route": {
|
||||
"id": "railiance-backup-offsite-lane",
|
||||
"owner_repo": "railiance-platform",
|
||||
"provider": "Existing Nextcloud create-only file-drop lane; independent Backup account for readback; age escrow for contained decrypt verification",
|
||||
"route_only_not_publication_evidence": true
|
||||
},
|
||||
"validation": {
|
||||
"unit_cases_passed": 7,
|
||||
"actual_age_synthetic_round_trip": true,
|
||||
"tampered_capture_or_ciphertext_refused": true,
|
||||
"existing_handoff_not_overwritten": true,
|
||||
"transport_retry_uses_verified_pair": true,
|
||||
"git_destination_refused": true,
|
||||
"native_export_executed": false
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,14 @@ data. A failed check stops before any capture mutation.
|
|||
|
||||
## Prepared capture, after the founder's go
|
||||
|
||||
Executed by the founder: run `20261105T233543Z-89bfbe04dece` reports
|
||||
[`captured_and_resumed`](../../evidence/2026-11-06-vergabe-three-store-capture-return.json).
|
||||
The matched populated point and restart checks pass; the full helper took
|
||||
98.61 seconds. Both accounts work after restart, with an overview-list UI
|
||||
follow-up. Off-host publication and isolated restore remain open. The command
|
||||
below is retained for reproducibility; **do not recapture this accepted point**.
|
||||
Next: [encrypt and fetch the frozen point](../2026-11-06-vergabe-recovery-export/README.md).
|
||||
|
||||
Live production changes require the founder's go for this change under
|
||||
`the-custodian/docs/agent-environment-orientation.md` section 0. The following
|
||||
command is prepared for that later maintenance window:
|
||||
|
|
|
|||
|
|
@ -24,10 +24,21 @@ passes at Helm revision 4 with the expected healthy deployment/database and both
|
|||
Bound claims. The [November 6 capture check](evidence/2026-11-06-vergabe-capture-preflight-return.json)
|
||||
also passes: two active members and OIDC identities, fixture 1/1/1, matching file,
|
||||
valid local-state header, anonymous download denial, no pending migrations and
|
||||
the reader Pod server dry-run. Next is the founder-approved pause/capture/resume
|
||||
in the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md).
|
||||
the reader Pod server dry-run. The founder-approved capture then executed using
|
||||
the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md).
|
||||
Neither preflight nor synthetic validation satisfies the pilot recovery gate.
|
||||
|
||||
The [populated capture and resume](evidence/2026-11-06-vergabe-three-store-capture-return.json)
|
||||
pass: 39 PostgreSQL tables / 247 rows, one PDF and SQLite integrity/link checks;
|
||||
the helper was removed. Total operation time was 98.61 seconds, including
|
||||
resume/postflight, rather than a separately measured outage duration.
|
||||
The [human restart return](evidence/2026-11-06-vergabe-post-restart-user-return.json)
|
||||
confirms both accounts work and everything else appears as before, except the
|
||||
tender is absent from Übersicht. The deployed dashboard lists only status 1–9;
|
||||
final statuses 10–13 would explain exclusion. The actual current status is not
|
||||
yet reported, so this remains a UI follow-up. Off-host publication and isolated
|
||||
restore remain unverified.
|
||||
|
||||
The other session does not update automatically, and a fresh login was needed
|
||||
after a short interval. The user wants a full working day without renewed login
|
||||
and would prefer no OTP at initial app sign-in, with refinement explicitly
|
||||
|
|
@ -93,7 +104,8 @@ could not read native node, Deployment and PVC status. The founder's subsequent
|
|||
[capture preflight](evidence/2026-11-06-vergabe-capture-preflight-return.json)
|
||||
clear that blocker at Helm revision 4. They use the founder's own terminal and
|
||||
existing SSH/sudo lane; the agent receives only sanitized receipts. Native
|
||||
capture and restart have not run.
|
||||
capture and automated restart verification subsequently passed; see the capture
|
||||
receipt above. No additional pause is needed to transfer that frozen point.
|
||||
|
||||
After the populated workflow, prepare the exact attended recovery action from
|
||||
the existing [pilot recovery contract](vergabe-teilnahme-pilot.md#persistence-and-recovery)
|
||||
|
|
@ -122,8 +134,8 @@ and [deployment binding](vergabe-demo-company-binding.md). Its receipt must cove
|
|||
Existing database-only backup/restore targets default to historical `vergabe_db`
|
||||
and omit both file stores. They cannot establish this three-store recovery claim.
|
||||
The two-account functional walkthrough is user-confirmed with UI follow-up.
|
||||
The attended capture/restart action is prepared and its live preflight passes;
|
||||
execution awaits the founder's go. Off-host publication, isolated native restore
|
||||
and rollback remain outstanding.
|
||||
The attended capture/restart action has executed successfully, with automated
|
||||
and human restart checks (overview follow-up above). Off-host publication,
|
||||
isolated restore and rollback remain outstanding.
|
||||
Logged-in account deactivation remains a separate owner-run access-control check
|
||||
from the pilot contract. No account is disabled as part of these walkthrough steps.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
Updated 2026-11-06 under RAPPS-WP-0014 and VERGABE-WP-0019.
|
||||
Release binding is complete (RAPPS-WP-0014-T02). Recovery and invited-recipient acceptance remain open.
|
||||
|
||||
November 6 recovery update: the founder's matched PostgreSQL/media/SQLite capture
|
||||
and app resume pass at revision 4; the full operation took 98.61 seconds. Both
|
||||
accounts work after restart, with an overview-list UI follow-up. See the
|
||||
[capture receipt](evidence/2026-11-06-vergabe-three-store-capture-return.json).
|
||||
Provider publication/readback, isolated restore, rollback/access and operating
|
||||
ownership remain open. The accepted point can be transferred without another
|
||||
pause using the [encrypted export packet](proposals/2026-11-06-vergabe-recovery-export/README.md).
|
||||
|
||||
| Item | Current binding |
|
||||
| --- | --- |
|
||||
| Tenant | tenant:trial:demo-company, active/version 1; three native memberships and one linked directory identity |
|
||||
|
|
|
|||
156
tests/test_vergabe_pilot_export.py
Normal file
156
tests/test_vergabe_pilot_export.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Synthetic recovery export checks; no node, native backup or credential reads."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import vergabe_pilot_export as export
|
||||
import vergabe_pilot_export_remote as remote
|
||||
from vergabe_pilot_recovery_store import Refused, digest
|
||||
|
||||
|
||||
class ExportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="vergabe-export-fixture-")
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.capture = self.root / "capture"
|
||||
self.capture.mkdir(mode=0o700)
|
||||
for name, content in {"database.dump": b"PGDMP synthetic data",
|
||||
"stores.tar.gz": b"synthetic store data"}.items():
|
||||
(self.capture / name).write_bytes(content)
|
||||
(self.capture / name).chmod(0o600)
|
||||
expected = {name: ((self.capture / name).stat().st_size, digest(self.capture / name))
|
||||
for name in export.EXPECTED}
|
||||
self.patch = patch.object(export, "EXPECTED", expected)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
receipt = {"run_id": export.RUN_ID, "status": "captured_and_resumed", "helm_revision": 4,
|
||||
"database": "vergabe_demo_company", "app_resumed": True, "writers_quiesced": True,
|
||||
"helper_cleanup_confirmed": True, "restart_fixture_verified": True}
|
||||
for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]:
|
||||
receipt[prefix + "_bytes"], receipt[prefix + "_sha256"] = expected[name]
|
||||
self.write("receipt.json", receipt)
|
||||
self.write("manifest.private.json", {"image": export.IMAGE, "database": "vergabe_demo_company",
|
||||
"private_fixture": "synthetic-private-sentinel"})
|
||||
|
||||
def write(self, name, value):
|
||||
path = self.capture / name
|
||||
path.write_text(json.dumps(value))
|
||||
path.chmod(0o600)
|
||||
|
||||
def test_changed_capture_refuses_before_crypto(self):
|
||||
(self.capture / "database.dump").write_bytes(b"different capture")
|
||||
with patch.object(export.subprocess, "run") as crypto, self.assertRaises(Refused):
|
||||
export.seal(self.capture, "age")
|
||||
crypto.assert_not_called()
|
||||
|
||||
def test_public_or_linked_source_is_refused(self):
|
||||
path = self.capture / "manifest.private.json"
|
||||
path.chmod(0o644)
|
||||
with self.assertRaises(Refused):
|
||||
export.capture_files(self.capture)
|
||||
path.unlink()
|
||||
path.symlink_to(self.capture / "receipt.json")
|
||||
with self.assertRaises(Refused):
|
||||
export.capture_files(self.capture)
|
||||
|
||||
def test_handoff_never_overwrites_a_different_file(self):
|
||||
home = self.root / "home"
|
||||
home.mkdir(mode=0o700)
|
||||
target = home / "packet.age"
|
||||
target.write_bytes(b"already there")
|
||||
target.chmod(0o600)
|
||||
with self.assertRaises(Refused):
|
||||
export.handoff(self.capture / "database.dump", home, target.name, os.geteuid(), os.getegid())
|
||||
self.assertEqual(target.read_bytes(), b"already there")
|
||||
|
||||
@unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "requires age fixture tools")
|
||||
def test_encrypt_decrypt_transfer_and_retry_preserve_the_whole_packet(self):
|
||||
identity = self.root / "synthetic-identity"
|
||||
generated = subprocess.run(["age-keygen"], capture_output=True, check=True)
|
||||
identity.write_bytes(generated.stdout)
|
||||
identity.chmod(0o600)
|
||||
public = subprocess.run(["age-keygen", "-y", str(identity)], capture_output=True, check=True).stdout.decode().strip()
|
||||
with patch.object(export, "RECIPIENT", public):
|
||||
ciphertext, sealed = export.seal(self.capture, shutil.which("age"))
|
||||
repeated, again = export.seal(self.capture, shutil.which("age"))
|
||||
self.assertEqual(digest(repeated), digest(ciphertext))
|
||||
self.assertEqual(again, sealed)
|
||||
decrypted = subprocess.run(["age", "-d", "-i", str(identity), str(ciphertext)],
|
||||
capture_output=True, check=True)
|
||||
with zipfile.ZipFile(io.BytesIO(decrypted.stdout)) as packet:
|
||||
self.assertEqual(set(packet.namelist()),
|
||||
{"database.dump", "stores.tar.gz", "manifest.private.json", "receipt.json", "packet.json"})
|
||||
metadata = json.loads(packet.read("packet.json"))
|
||||
for name, row in metadata["files"].items():
|
||||
self.assertEqual(packet.read(name), (self.capture / name).read_bytes())
|
||||
self.assertEqual(row["sha256"], digest(self.capture / name))
|
||||
home = self.root / "handoff"
|
||||
home.mkdir(mode=0o700)
|
||||
export.handoff(ciphertext, home, export.NAME + ".zip.age", os.geteuid(), os.getegid())
|
||||
export.handoff(self.capture / "seal.json", home, export.NAME + ".transfer.json", os.geteuid(), os.getegid())
|
||||
result = remote.verify(home)
|
||||
self.assertEqual(result["status"], "encrypted_workstation_copy_verified")
|
||||
self.assertFalse(result["provider_publication_verified"])
|
||||
self.assertFalse(result["decryption_verified"])
|
||||
self.assertFalse(result["restore_verified"])
|
||||
self.assertNotIn("synthetic-private-sentinel", json.dumps(sealed))
|
||||
(home / (export.NAME + ".zip.age")).write_bytes(b"age-encryption.org/v1\ntampered")
|
||||
with self.assertRaises(Refused):
|
||||
remote.verify(home)
|
||||
|
||||
def test_changed_source_pin_prevents_remote_execution(self):
|
||||
with patch.dict(remote.PINS, {"vergabe_pilot_export": "0" * 64}), self.assertRaises(Refused):
|
||||
remote.remote_command()
|
||||
|
||||
def test_fetch_publishes_only_a_verified_pair_and_reuses_it_on_retry(self):
|
||||
handoff = self.root / "remote-home"
|
||||
handoff.mkdir(mode=0o700)
|
||||
cipher = handoff / (export.NAME + ".zip.age")
|
||||
cipher.write_bytes(b"age-encryption.org/v1\nsynthetic-ciphertext")
|
||||
cipher.chmod(0o600)
|
||||
metadata = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": export.RUN_ID,
|
||||
"recipient": export.RECIPIENT, "ciphertext_sha256": digest(cipher),
|
||||
"ciphertext_bytes": cipher.stat().st_size,
|
||||
"packet": {"run_id": export.RUN_ID, "image": export.IMAGE,
|
||||
"database": "vergabe_demo_company", "files": export.capture_files(self.capture)}}
|
||||
transfer = handoff / (export.NAME + ".transfer.json")
|
||||
transfer.write_text(json.dumps(metadata))
|
||||
transfer.chmod(0o600)
|
||||
def execute(args, **kwargs):
|
||||
if args[0] == "scp":
|
||||
source = args[2].split(":", 1)[1]
|
||||
shutil.copy2(handoff / source, Path(args[3]))
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
with patch.object(remote, "remote_command", return_value=["fixture-ssh"]), \
|
||||
patch.object(remote.Path, "home", return_value=self.root / "workstation"), \
|
||||
patch.object(remote, "outside_git", return_value=True), \
|
||||
patch.object(remote.sys.stdin, "isatty", return_value=True), \
|
||||
patch.object(remote.sys, "argv", ["export", "--fetch"]), \
|
||||
patch.object(remote.subprocess, "run", side_effect=execute) as command, \
|
||||
redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(remote.main(), 0)
|
||||
self.assertEqual(remote.main(), 0)
|
||||
self.assertEqual(command.call_count, 3)
|
||||
path = self.root / "workstation/.local/state/vergabe-pilot-recovery" / export.RUN_ID
|
||||
self.assertTrue((path / "workstation-copy.json").exists())
|
||||
self.assertEqual({p.suffix for p in path.iterdir()}, {".age", ".json"})
|
||||
|
||||
def test_nested_backup_destination_inside_a_repository_is_refused(self):
|
||||
(self.root / ".git").mkdir()
|
||||
self.assertFalse(remote.outside_git(self.root / "private/backup"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
160
tools/vergabe_pilot_export.py
Normal file
160
tools/vergabe_pilot_export.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Seal the accepted recovery point for founder-only workstation transfer.
|
||||
|
||||
Run by the founder through the remote wrapper. Reads only this completed
|
||||
capture; writes an age-encrypted packet and private founder handoff files.
|
||||
No Kubernetes, database, OpenBao or provider access.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from vergabe_pilot_recovery_store import Refused, digest, private_directory, require
|
||||
|
||||
RUN_ID = "20261105T233543Z-89bfbe04dece"
|
||||
BASE = Path("/var/lib/vergabe-pilot-recovery")
|
||||
NAME = "vergabe-pilot-" + RUN_ID
|
||||
# Public recipient declared by railiance-platform/lib/railiance-backup-common.sh.
|
||||
RECIPIENT = "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz"
|
||||
EXPECTED = {
|
||||
"database.dump": (169232, "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c"),
|
||||
"stores.tar.gz": (3489, "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66"),
|
||||
}
|
||||
IMAGE = ("forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:"
|
||||
"a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91")
|
||||
|
||||
|
||||
def regular(path, owner):
|
||||
info = path.lstat()
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_uid == owner
|
||||
and not info.st_mode & 0o077, "private_regular_file_required")
|
||||
return info
|
||||
|
||||
|
||||
def capture_files(directory):
|
||||
private_directory(directory)
|
||||
files = {}
|
||||
for name in [*EXPECTED, "manifest.private.json", "receipt.json"]:
|
||||
path = directory / name
|
||||
info = regular(path, os.geteuid())
|
||||
require(0 < info.st_size <= 1024 * 1024, "capture_file_bound")
|
||||
files[name] = {"bytes": info.st_size, "sha256": digest(path)}
|
||||
if name in EXPECTED:
|
||||
require((info.st_size, files[name]["sha256"]) == EXPECTED[name], "capture_hash_mismatch")
|
||||
receipt = json.loads((directory / "receipt.json").read_text())
|
||||
require(receipt["run_id"] == RUN_ID and receipt["status"] == "captured_and_resumed"
|
||||
and receipt["helm_revision"] == 4 and receipt["database"] == "vergabe_demo_company"
|
||||
and all(receipt.get(key) is True for key in ["app_resumed", "writers_quiesced",
|
||||
"helper_cleanup_confirmed", "restart_fixture_verified"]), "capture_not_accepted")
|
||||
for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]:
|
||||
require(receipt[prefix + "_sha256"] == files[name]["sha256"]
|
||||
and receipt[prefix + "_bytes"] == files[name]["bytes"], "receipt_hash_mismatch")
|
||||
manifest = json.loads((directory / "manifest.private.json").read_text())
|
||||
require(manifest["image"] == IMAGE and manifest["database"] == "vergabe_demo_company",
|
||||
"capture_binding_mismatch")
|
||||
return files
|
||||
|
||||
|
||||
def seal(directory, age):
|
||||
files = capture_files(directory)
|
||||
metadata = {"schema": "railiance.vergabe-pilot-recovery-packet.v1", "run_id": RUN_ID,
|
||||
"database": "vergabe_demo_company", "image": IMAGE, "files": files}
|
||||
ciphertext = directory / "recovery-point.zip.age"
|
||||
seal_receipt = directory / "seal.json"
|
||||
if ciphertext.exists() or seal_receipt.exists():
|
||||
regular(ciphertext, os.geteuid())
|
||||
regular(seal_receipt, os.geteuid())
|
||||
result = json.loads(seal_receipt.read_text())
|
||||
require(result["packet"] == metadata and result["recipient"] == RECIPIENT
|
||||
and result["ciphertext_sha256"] == digest(ciphertext)
|
||||
and result["ciphertext_bytes"] == ciphertext.stat().st_size,
|
||||
"existing_seal_mismatch")
|
||||
return ciphertext, result
|
||||
with tempfile.TemporaryDirectory(prefix="seal-", dir=directory) as temporary:
|
||||
packet = Path(temporary) / "recovery-point.zip"
|
||||
with zipfile.ZipFile(packet, "x", compression=zipfile.ZIP_STORED) as archive:
|
||||
for name in files:
|
||||
archive.write(directory / name, name)
|
||||
archive.writestr("packet.json", json.dumps(metadata, sort_keys=True))
|
||||
packet.chmod(0o600)
|
||||
# Recheck frozen source hashes after packing, before encryption/publication.
|
||||
require(capture_files(directory) == files, "capture_changed_during_export")
|
||||
encrypted = Path(temporary) / "packet.age"
|
||||
fd = os.open(encrypted, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
result = subprocess.run([age, "-r", RECIPIENT, str(packet)], stdout=output,
|
||||
stderr=subprocess.DEVNULL, timeout=60)
|
||||
require(result.returncode == 0, "encryption_failed")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
require(encrypted.stat().st_size <= 4 * 1024 * 1024, "encrypted_packet_bound")
|
||||
with encrypted.open("rb") as source:
|
||||
require(source.read(22) == b"age-encryption.org/v1\n", "age_header_required")
|
||||
result = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": RUN_ID,
|
||||
"recipient": RECIPIENT, "ciphertext_sha256": digest(encrypted),
|
||||
"ciphertext_bytes": encrypted.stat().st_size, "packet": metadata,
|
||||
"provider_publication_verified": False, "restore_verified": False}
|
||||
fd = os.open(seal_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
# No overwrite, including after an interrupted export. A partial pair
|
||||
# requires review rather than silently replacing an earlier ciphertext.
|
||||
os.link(encrypted, ciphertext)
|
||||
return ciphertext, result
|
||||
|
||||
|
||||
def handoff(source, directory, name, uid, gid):
|
||||
target = directory / name
|
||||
if target.exists() or target.is_symlink():
|
||||
regular(target, uid)
|
||||
require(digest(target) == digest(source), "existing_handoff_mismatch")
|
||||
return
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as output, source.open("rb") as stream:
|
||||
shutil.copyfileobj(stream, output)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.fchown(output.fileno(), uid, gid)
|
||||
|
||||
|
||||
def main():
|
||||
require(os.geteuid() == 0, "founder_node_sudo_required")
|
||||
account = pwd.getpwnam("tegwick")
|
||||
require(os.environ.get("SUDO_USER") == account.pw_name
|
||||
and os.environ.get("SUDO_UID") == str(account.pw_uid) and account.pw_uid > 0,
|
||||
"founder_ssh_account_required")
|
||||
home = Path(account.pw_dir)
|
||||
info = home.lstat()
|
||||
require(str(home) == "/home/tegwick" and stat.S_ISDIR(info.st_mode)
|
||||
and info.st_uid == account.pw_uid and not info.st_mode & 0o022,
|
||||
"founder_home_required")
|
||||
age = next((str(p) for p in [Path("/usr/bin/age"), Path("/usr/local/bin/age"),
|
||||
Path("/opt/railiance-platform/tools/vendor/bin/age"),
|
||||
home / "railiance-platform/tools/vendor/bin/age"] if p.is_file() and os.access(p, os.X_OK)), None)
|
||||
require(age, "age_unavailable")
|
||||
private_directory(BASE)
|
||||
encrypted, result = seal(BASE / RUN_ID, age)
|
||||
handoff(encrypted, home, NAME + ".zip.age", account.pw_uid, account.pw_gid)
|
||||
handoff(encrypted.parent / "seal.json", home, NAME + ".transfer.json", account.pw_uid, account.pw_gid)
|
||||
print(json.dumps({"status": "encrypted_handoff_ready", "run_id": RUN_ID,
|
||||
"ciphertext_sha256": result["ciphertext_sha256"],
|
||||
"ciphertext_bytes": result["ciphertext_bytes"], "application_changed": False}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused)
|
||||
else "export_command_failed", "details_suppressed": True}))
|
||||
raise SystemExit(1)
|
||||
125
tools/vergabe_pilot_export_remote.py
Normal file
125
tools/vergabe_pilot_export_remote.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Founder workstation: seal and fetch this completed pilot recovery point.
|
||||
|
||||
Default reviews only. --fetch uses the existing interactive SSH/sudo lane to
|
||||
encrypt on railiance01, then scp copies only ciphertext and a safe manifest.
|
||||
No plaintext backup, credential or private recovery key leaves the node.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import vergabe_pilot_export as export
|
||||
from vergabe_pilot_recovery_store import Refused, digest, private_directory, require
|
||||
|
||||
PINS = {
|
||||
"vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c",
|
||||
"vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a",
|
||||
}
|
||||
|
||||
|
||||
def remote_command():
|
||||
sources = {}
|
||||
for name, pin in PINS.items():
|
||||
raw = Path(__file__).with_name(name + ".py").read_bytes()
|
||||
require(hashlib.sha256(raw).hexdigest() == pin, "export_source_pin_mismatch")
|
||||
sources[name] = raw.decode()
|
||||
bootstrap = ("import sys,types;m=types.ModuleType('vergabe_pilot_recovery_store');"
|
||||
"sys.modules[m.__name__]=m;exec(compile(" + repr(sources["vergabe_pilot_recovery_store"]) +
|
||||
",'vergabe_pilot_recovery_store.py','exec'),m.__dict__);exec(compile(" +
|
||||
repr(sources["vergabe_pilot_export"]) + ",'vergabe_pilot_export.py','exec'),"
|
||||
"{'__name__':'__main__'})")
|
||||
return ["ssh", "-t", "railiance01", shlex.join(["sudo", "/usr/bin/python3", "-c", bootstrap])]
|
||||
|
||||
|
||||
def verify(directory):
|
||||
private_directory(directory)
|
||||
ciphertext = directory / (export.NAME + ".zip.age")
|
||||
transfer = directory / (export.NAME + ".transfer.json")
|
||||
export.regular(ciphertext, os.geteuid())
|
||||
export.regular(transfer, os.geteuid())
|
||||
require(0 < transfer.stat().st_size <= 16384, "transfer_metadata_bound")
|
||||
receipt = json.loads(transfer.read_text())
|
||||
require(receipt["schema"] == "railiance.vergabe-pilot-sealed-export.v1"
|
||||
and receipt["run_id"] == export.RUN_ID and receipt["recipient"] == export.RECIPIENT
|
||||
and receipt["packet"]["run_id"] == export.RUN_ID
|
||||
and receipt["packet"]["image"] == export.IMAGE
|
||||
and receipt["packet"]["database"] == "vergabe_demo_company", "wrong_recovery_point")
|
||||
for name, (size, checksum) in export.EXPECTED.items():
|
||||
require(receipt["packet"]["files"][name] == {"bytes": size, "sha256": checksum},
|
||||
"source_hash_mismatch")
|
||||
require(0 < ciphertext.stat().st_size <= 4 * 1024 * 1024
|
||||
and ciphertext.stat().st_size == receipt["ciphertext_bytes"]
|
||||
and digest(ciphertext) == receipt["ciphertext_sha256"], "ciphertext_transfer_mismatch")
|
||||
with ciphertext.open("rb") as stream:
|
||||
require(stream.read(22) == b"age-encryption.org/v1\n", "age_header_required")
|
||||
return {"schema": "railiance.vergabe-pilot-workstation-copy.v1", "run_id": export.RUN_ID,
|
||||
"status": "encrypted_workstation_copy_verified", "ciphertext_sha256": receipt["ciphertext_sha256"],
|
||||
"ciphertext_bytes": receipt["ciphertext_bytes"], "encrypted_on_source_node": True,
|
||||
"off_host_copy_verified": True, "provider_publication_verified": False,
|
||||
"decryption_verified": False, "restore_verified": False, "application_changed": False}
|
||||
|
||||
|
||||
def outside_git(path):
|
||||
return not any((p / ".git").exists() for p in [path, *path.parents])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--fetch", action="store_true")
|
||||
args = parser.parse_args()
|
||||
command = remote_command()
|
||||
if not args.fetch:
|
||||
print(json.dumps({"status": "prepared", "run_id": export.RUN_ID, "ssh_target": "railiance01",
|
||||
"application_changed": False, "next": "--fetch", "sources": PINS}))
|
||||
return 0
|
||||
require(sys.stdin.isatty(), "founder_interactive_terminal_required")
|
||||
destination = Path.home() / ".local/state/vergabe-pilot-recovery" / export.RUN_ID
|
||||
require(outside_git(destination), "private_destination_outside_git_required")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
private_directory(destination.parent)
|
||||
if destination.exists() or destination.is_symlink():
|
||||
result = verify(destination)
|
||||
else:
|
||||
result = subprocess.run(command, check=False)
|
||||
require(result.returncode == 0, "remote_seal_failed")
|
||||
with tempfile.TemporaryDirectory(prefix="fetch-", dir=destination.parent) as temporary:
|
||||
temporary = Path(temporary)
|
||||
for suffix in (".zip.age", ".transfer.json"):
|
||||
name = export.NAME + suffix
|
||||
copied = subprocess.run(["scp", "-p", "railiance01:" + name, str(temporary / name)],
|
||||
capture_output=True, timeout=120)
|
||||
require(copied.returncode == 0, "encrypted_copy_failed")
|
||||
result = verify(temporary)
|
||||
# Keep a verified pair only; temporary failures never become success.
|
||||
temporary.rename(destination)
|
||||
result["local_directory"] = str(destination)
|
||||
receipt = destination / "workstation-copy.json"
|
||||
if receipt.exists() or receipt.is_symlink():
|
||||
export.regular(receipt, os.geteuid())
|
||||
require(json.loads(receipt.read_text()) == result, "existing_copy_receipt_mismatch")
|
||||
else:
|
||||
fd = os.open(receipt, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused)
|
||||
else "export_transport_failed", "details_suppressed": True}))
|
||||
raise SystemExit(1)
|
||||
|
|
@ -196,10 +196,10 @@ id: RAPPS-WP-0014-T03
|
|||
status: wait
|
||||
needs_human: true
|
||||
decision_id: d06307e5-2737-461a-a2f4-a12c3b492a9a
|
||||
intervention_note: "Functional walkthrough is confirmed with UI follow-up. Latest founder inventory returned cluster_read unavailable_or_invalid_response; invoking host is unconfirmed, so no cluster fault is inferred. Next: run tools/vergabe_pilot_inventory_remote.py --run from the founder workstation terminal to target railiance01 explicitly. Agent privileges stay unchanged. Native inventory, populated recovery/access/rollback and ownership remain open."
|
||||
intervention_note: "Native matched PostgreSQL/media/SQLite capture and resume pass at Helm revision 4 (98.61 seconds whole operation). Both accounts work after restart, with an Übersicht omission under UI review. Next: founder-run encrypted export --fetch, then provider publication/readback and isolated restore. Access/rollback and operating ownership remain open."
|
||||
priority: high
|
||||
assignee: the-custodian
|
||||
blocking_reason: "Two-account functional walkthrough is confirmed with UI follow-up. Await attended native inventory, populated restart, coherent PostgreSQL/media/issue-state off-host recovery, isolated restored workflow, rollback/access evidence and operating ownership. September 25 proves database-only restore and restart."
|
||||
blocking_reason: "Matched populated capture, automated restart checks and human two-account return pass with overview UI follow-up. Await encrypted off-host publication/readback, isolated restored workflow, access checks, compatible rollback and operating ownership."
|
||||
state_hub_task_id: "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1"
|
||||
```
|
||||
|
||||
|
|
@ -402,6 +402,35 @@ and guarded resume. No capture or populated restart has executed. T03 remains
|
|||
waiting for that action and subsequent off-host/restore/rollback, access and
|
||||
operating-owner acceptance.
|
||||
|
||||
2026-11-06 founder execution return: run `20261105T233543Z-89bfbe04dece`
|
||||
reports `captured_and_resumed` at Helm revision 4. PostgreSQL: 39 tables / 247
|
||||
rows, 169232-byte custom dump. Media: one 803-byte PDF matching the original
|
||||
SHA256. SQLite: 9 tables / 7 rows, integrity and linked issue verified. Writers
|
||||
were quiesced, helper cleanup confirmed and the app resumed with automated
|
||||
document/issue checks passing. Whole operation: 98.61 seconds; this is not a
|
||||
separately measured outage duration. Native receipt:
|
||||
`docs/evidence/2026-11-06-vergabe-three-store-capture-return.json`.
|
||||
The founder's execution is the go for the reviewed capture change. The app is
|
||||
running; further capture pauses are unnecessary for this point. The human return
|
||||
confirms both accounts work and everything else seems as before, with an
|
||||
Übersicht omission reported. The deployed dashboard filters ongoing tenders to
|
||||
status 1–9; final statuses would explain exclusion, but the current fixture
|
||||
status is not yet confirmed. Receipt:
|
||||
`docs/evidence/2026-11-06-vergabe-post-restart-user-return.json`. Keep that UI
|
||||
follow-up separate from the confirmed restart checks.
|
||||
Off-host publication/readback and isolated restore remain explicitly unverified;
|
||||
rollback/access and operating ownership also remain open. T03 is not complete.
|
||||
|
||||
Prepared `tools/vergabe_pilot_export_remote.py --fetch` and the review packet
|
||||
`docs/proposals/2026-11-06-vergabe-recovery-export/README.md`: seal this exact
|
||||
completed point on the node to the existing platform public age recipient, then
|
||||
fetch only ciphertext/metadata to the founder's private workstation directory
|
||||
and verify hashes. Seven synthetic export cases pass, including real age round
|
||||
trip and transport/retry. This action does not pause the app or access credentials
|
||||
or the provider. Native execution is pending. Warden routing reconfirms the
|
||||
existing platform Nextcloud lane; an encrypted workstation copy is not provider
|
||||
publication, independent escrow readback or an isolated restore receipt.
|
||||
|
||||
|
||||
## Validate synthetic sizing against the exact release and shared contention
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue