Bind isolated restore to fetched artifact and document archive integrity gates
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
parent
0cca68da96
commit
480d181549
4 changed files with 41 additions and 3 deletions
|
|
@ -98,3 +98,29 @@ a cleanup failure after CAS can occur after credentials have already changed.
|
||||||
Refresh `actcore-backup-offsite`, verify delivery by contained comparison, and
|
Refresh `actcore-backup-offsite`, verify delivery by contained comparison, and
|
||||||
restart its three environment consumers (`actcore-api`, `actcore-event-router`,
|
restart its three environment consumers (`actcore-api`, `actcore-event-router`,
|
||||||
`actcore-worker`). Record their readiness and loaded-value comparisons.
|
`actcore-worker`). Record their readiness and loaded-value comparisons.
|
||||||
|
|
||||||
|
## Real archive acceptance
|
||||||
|
|
||||||
|
`capture_forgejo_archive.py` requires the exact producer's successful exit marker,
|
||||||
|
checks every transferred chunk's length and the complete SHA-256 against the
|
||||||
|
producer, then validates ZIP structure and member CRCs before encryption. The
|
||||||
|
2026-09-05 recovery attempt exposed a truncated September 4 encrypted archive;
|
||||||
|
successful age decryption and upload alone cannot certify backup completeness.
|
||||||
|
|
||||||
|
`verify_nextcloud_offsite_restore.py` streams a real encrypted archive through
|
||||||
|
the current upload lane and downloads it separately through owner recovery
|
||||||
|
custody. It emits a hash-bound transfer receipt. Run this silent helper through
|
||||||
|
the attended Warden envelope, with fresh private staging and receipt paths.
|
||||||
|
`restore_forgejo_offsite_locally.py` requires both that receipt and the fetched
|
||||||
|
archive. Its disposable local Docker network is internal, exposes no host ports,
|
||||||
|
and uses an independent database password/configuration. Production app.ini is
|
||||||
|
never activated. It checks database import, application health, repository API
|
||||||
|
and clone/fsck, and package blob integrity; cleanup removes its named containers,
|
||||||
|
anonymous volumes, network and extracted staging. Remaining fetched plaintext
|
||||||
|
must be removed after the acceptance receipt is preserved.
|
||||||
|
|
||||||
|
This bounded application-data recovery does not test replacement runners or
|
||||||
|
prove that every restored package can be installed or every image pulled.
|
||||||
|
Package-consumer acceptance and broader disaster-recovery guarantees remain
|
||||||
|
with their owning assurance tasks. Full backups share the account's 10 GiB
|
||||||
|
quota; a newly measured archive size must inform the separate retention decision.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import zipfile
|
||||||
|
|
||||||
def validate_archive(path):
|
def validate_archive(path):
|
||||||
with zipfile.ZipFile(path) as archive:
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
if sum(i.file_size for i in archive.infolist()) > 20*1024**3:
|
||||||
|
raise ValueError('archive exceeds recovery size bound')
|
||||||
names = set(archive.namelist())
|
names = set(archive.namelist())
|
||||||
if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n in names):
|
if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n in names):
|
||||||
raise ValueError('required archive content missing')
|
raise ValueError('required archive content missing')
|
||||||
|
|
@ -48,7 +50,8 @@ def capture(namespace, pod, destination):
|
||||||
piece = call(['dd','if='+remote+'.zip','bs='+str(chunk),'skip='+str(index),'count=1'])
|
piece = call(['dd','if='+remote+'.zip','bs='+str(chunk),'skip='+str(index),'count=1'])
|
||||||
if len(piece) != min(chunk,size-index*chunk): raise ValueError('short archive chunk')
|
if len(piece) != min(chunk,size-index*chunk): raise ValueError('short archive chunk')
|
||||||
out.write(piece)
|
out.write(piece)
|
||||||
actual = hashlib.file_digest(destination.open('rb'),'sha256').hexdigest()
|
with destination.open('rb') as source:
|
||||||
|
actual = hashlib.file_digest(source,'sha256').hexdigest()
|
||||||
if actual != expected: raise ValueError('archive transfer mismatch')
|
if actual != expected: raise ValueError('archive transfer mismatch')
|
||||||
validate_archive(destination)
|
validate_archive(destination)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -154,9 +154,18 @@ LEVEL = Error
|
||||||
def main():
|
def main():
|
||||||
p=argparse.ArgumentParser(description=__doc__)
|
p=argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True)
|
p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True)
|
||||||
|
p.add_argument('--transfer-receipt',required=True,type=Path)
|
||||||
a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','images':[FORGE,POSTGRES]}
|
a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','images':[FORGE,POSTGRES]}
|
||||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||||
try: run(a.archive,receipt)
|
try:
|
||||||
|
transfer=json.loads(a.transfer_receipt.read_text())
|
||||||
|
if (transfer.get('status') != 'offsite_fetched_pending_isolated_restore'
|
||||||
|
or transfer.get('download_http_status') != 200
|
||||||
|
or transfer.get('plaintext_sha256') != file_digest(a.archive)):
|
||||||
|
raise ValueError('verified_offsite_provenance_required')
|
||||||
|
receipt['offsite_artifact']=transfer['artifact']
|
||||||
|
receipt['ciphertext_sha256']=transfer['ciphertext_sha256']
|
||||||
|
run(a.archive,receipt)
|
||||||
except Exception: receipt['error']='isolated_restore_failed'
|
except Exception: receipt['error']='isolated_restore_failed'
|
||||||
finally:
|
finally:
|
||||||
with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2)
|
with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ def run(source, directory, receipt):
|
||||||
result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)],
|
result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)],
|
||||||
input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200)
|
input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200)
|
||||||
require(result.returncode == 0, 'offsite_decryption_failed')
|
require(result.returncode == 0, 'offsite_decryption_failed')
|
||||||
receipt.update(decrypted=True, quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore')
|
receipt.update(decrypted=True, plaintext_sha256=sha(plain), quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore')
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue