Add verified backup and restore for shared data volumes

This commit is contained in:
2026-09-06 10:52:57 +03:00
parent 8d6f3bc3ea
commit b44ec646a0
4 changed files with 171 additions and 0 deletions
+8
View File
@@ -689,3 +689,11 @@ Yerel QA yardımcı sunucusu geçici gerçek SQLite deposuna ilk gözlem ve 22 s
Gerçek geçmiş API'si üzerinden Giresun ayrıntısı → Kaynak değişiklik geçmişi → Geçmişi yükle akışında 20 kayıt; ilk Saat değişti başlığında Önce 11:00 / Sonra 10:00; Daha eski kayıtlar ile toplam 23 kayıt ve ilk gözlem; son sayfada devam düğmesinin kaybolması doğrulandı. Altı kontrol doğru. Kanıt qa-source-history.json. Veriler sentetik ve geçici; canlı resmi zeyilname kabulü değildir.
Yeni açılır başlıkların klavye erişimi için modal odak döngüsüne summary öğeleri eklendi. Üretim derlemesi geçti. Önceki 147 otomatik test bu yalnız QA/odak seçicisi turunda tekrarlanmadı. Ağ hata/yeniden deneme ve gerçek kaynak birikimi kabulü açık. Canlı yayın yapılmadı.
### D11 — Ortak veri volume'u yedeği ve geri yükleme
Kullanıcı yedeğine ek olarak bağımsız veri volume'u aracı eklendi. Seçilen dizinin normal dosyalarını alt dizinlerle kapsar; SQLite başlıklı dosyaları VACUUM INTO ile tutarlı kopyaya çevirir ve integrity_check uygular. İlgili WAL/SHM kopyalanmaz; committed WAL verisi snapshot'a dahil edilir. Diğer dosyalar boyut/akışlı SHA-256 ile doğrulanır. Manifest dışı dosya, eksik/değişmiş içerik, sembolik bağlantı, iç içe hedef ve mevcut hedef reddedilir. Hata durumunda yalnız yeni oluşturulan hedef temizlenir.
Testte iki depo (kalem ve kaynak geçmişi adlarıyla), committed WAL kaydı, alt dizin JSON dosyası yeni volume'a geri yüklendi. Üzerine yazma ve tahrif edilmiş yedek reddi geçti. Tüm 149 test başarılı. Üretim ön yüzü değişmediğinden derleme tekrarlanmadı. Runbook komutları ve sınırlar güncellendi.
Sınırlar: seçilen dizinler dışındaki worker depoları/config/env ayrıca envanter gerektirir; kullanıcı belgesi referans kontrolü kullanıcı yedek aracındadır. Gerçek servis durdurma, tüm volume'ların aynı sürüm setiyle alınması ve platformda geri dönüş yapılmadı. Tam D11 ve canlı yayın açık.
+16
View File
@@ -33,3 +33,19 @@ Yedek aracı SQLite snapshot oluşturur, bütünlük kontrolü yapar, referansl
`20260906_document_file_cleanup` migration'ı, mantıksal olarak silinmiş belgelerin fiziksel dosyalarını kalıcı kuyrukta tutar. Erişim kaydı ve temizleme görevi aynı transaction'da değişir; sonraki veritabanı açılışları temizliği yeniden dener. Dosya sistemi sorunu sürüyorsa kuyrukta kalır. Bu dosyalar aktif belge yedeğine dahil değildir; yedekte kuyruk kaydı bulunup dosya bulunmazsa yeniden açılışta ENOENT başarılı temizleme olarak ele alınır. Fiziksel silmenin anında tamamlandığı varsayılmamalı; işletim kontrolünde kuyruk büyüklüğü ve volume izinleri izlenmelidir.
Kaynak gözlem geçmişi `IHALEIST_SHARED_DATA_DIR/tender-history.sqlite` içinde saklanır (değişken yoksa `server/data`). Kullanıcı verisi yedek aracı bu depoyu kapsamaz; tam yayın/geri dönüş provasında ortak veri volume'uyla birlikte ayrıca yedeklenmelidir.
## Ortak veri volume'u için yedekleme
`server/backup-data-volume.mjs`, seçilen ortak veri dizinindeki normal dosyaları alt dizinleriyle kapsar. Dosya başlığından SQLite olarak tanınan depolar `VACUUM INTO` ile alınır; bağlı WAL/SHM dosyaları ayrı kopyalanmaz. Diğer dosyalar kopyalanır, boyut ve SHA-256 manifesti yazılır. Büyük dosyaların hash'i akışla hesaplanır. Sembolik bağlantılar, iç içe kaynak/hedef ve mevcut hedef dizinleri reddedilir.
Bütün kaynak yazıcılarını durdurduktan sonra, doğrulanmış gerçek volume yollarıyla:
```sh
node server/backup-data-volume.mjs backup /volume/shared /backups/shared-release-001 --offline
node server/backup-data-volume.mjs verify /backups/shared-release-001
node server/backup-data-volume.mjs restore /backups/shared-release-001 /recovery/shared-release-001
```
Bu araç seçilen dizin dışındaki worker depolarını, ortam değişkenlerini veya dağıtım yapılandırmasını kendiliğinden bulmaz. Kullanıcı belgelerinde veritabanı-dosya referans kontrolü için kullanıcı yedek aracını da kullanın. Tüm veri dizinleri ve kullanıcı yedeği aynı durdurulmuş sürümün seti olarak alınmalı; manifest zamanları tek başına servislerin durduğunu kanıtlamaz. Şifreleme/imzalama yapılmaz; backup erişimi ve dış saklama platformda yönetilir. Fiziksel boş dizinler ayrı manifest girdileri değildir.
`tests/data-volume-backup.test.mjs`, iki SQLite deposu, committed WAL verisi, alt dizin dosyası, restore, hedef üzerine yazmama, değişmiş/ek dosya ve sembolik bağlantı reddini doğrular. Gerçek üretim volume'larına bu komutlar henüz uygulanmadı; platform geri dönüş provası açık.
+105
View File
@@ -0,0 +1,105 @@
import fs from 'node:fs';
import path from 'node:path';
import {createHash} from 'node:crypto';
import {DatabaseSync} from 'node:sqlite';
import {fileURLToPath} from 'node:url';
const regular=file=>{if(!fs.lstatSync(file).isFile())throw new Error('Normal dosya gerekli: '+file);};
const inside=(root,target)=>{const relative=path.relative(path.resolve(root),path.resolve(target));return relative==='' || (!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative));};
function safeFile(root,name) {
if(typeof name!=='string'||!name||path.isAbsolute(name)||name.split('/').some(p=>!p||p==='.'||p==='..')||name.includes('\\'))throw new Error('Geçersiz manifest yolu.');
let current=root;
for(const part of name.split('/')) {current=path.join(current,part);if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())throw new Error('Yedekte sembolik bağlantı kullanılamaz.');}
return current;
}
async function hash(file) {
const digest=createHash('sha256');
for await(const chunk of fs.createReadStream(file))digest.update(chunk);
return digest.digest('hex');
}
function sqlite(file) {
const fd=fs.openSync(file,'r'),header=Buffer.alloc(16);
try {fs.readSync(fd,header,0,16,0);}finally {fs.closeSync(fd);}
return header.equals(Buffer.from('SQLite format 3\0'));
}
function integrity(file) {
const db=new DatabaseSync(file,{readOnly:true});
try {const rows=db.prepare('PRAGMA integrity_check').all();if(rows.length!==1||Object.values(rows[0])[0]!=='ok')throw new Error('SQLite bütünlük kontrolü başarısız.');}
finally {db.close();}
}
function inventory(root,relative='') {
const result=[];
for(const entry of fs.readdirSync(path.join(root,relative),{withFileTypes:true})) {
const name=relative?relative+'/'+entry.name:entry.name;
if(entry.isSymbolicLink())throw new Error('Veri volume içinde sembolik bağlantı var: '+name);
if(entry.isDirectory())result.push(...inventory(root,name));
else if(entry.isFile())result.push(name);
else throw new Error('Desteklenmeyen veri dosyası: '+name);
}
return result.sort();
}
export async function verifyDataVolume(backup) {
if(fs.lstatSync(backup).isSymbolicLink())throw new Error('Yedek dizini bağlantı olamaz.');
const manifestPath=safeFile(backup,'volume-manifest.json');regular(manifestPath);
const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
if(manifest.version!==1||manifest.scope!=='data-volume'||!Array.isArray(manifest.files))throw new Error('Geçersiz volume manifesti.');
const names=new Set();
for(const entry of manifest.files) {
if(names.has(entry.name)||entry.name==='volume-manifest.json')throw new Error('Tekrarlı/geçersiz manifest girdisi.');names.add(entry.name);
const file=safeFile(backup,entry.name);regular(file);
if(fs.statSync(file).size!==entry.bytes||await hash(file)!==entry.sha256)throw new Error('Yedek değişmiş veya eksik: '+entry.name);
if(entry.sqlite)integrity(file);
}
const actual=inventory(backup).filter(n=>n!=='volume-manifest.json');
if(actual.length!==names.size||actual.some(n=>!names.has(n)))throw new Error('Manifest dışında dosya var.');
return manifest;
}
export async function backupDataVolume(source,destination,{offline=false}={}) {
if(!offline)throw new Error('Uygulamayı ve tüm yazan işleri durdurun; --offline gerekli.');
if(inside(source,destination)||inside(destination,source))throw new Error('Kaynak ve hedef iç içe olamaz.');
if(fs.lstatSync(source).isSymbolicLink())throw new Error('Kaynak gerçek volume dizini olmalı.');
const names=inventory(source);
if(names.includes('volume-manifest.json'))throw new Error('Kaynak bir yedek dizini gibi görünüyor.');
const dbNames=new Set(names.filter(name=>sqlite(path.join(source,name))));
fs.mkdirSync(destination,{mode:0o700});
try {
const files=[];
for(const name of names) {
if(/-(wal|shm)$/.test(name)&&dbNames.has(name.replace(/-(wal|shm)$/,'')))continue;
const original=safeFile(source,name),output=safeFile(destination,name);
fs.mkdirSync(path.dirname(output),{recursive:true,mode:0o700});
if(dbNames.has(name)) {
const db=new DatabaseSync(original,{readOnly:true});
try {db.prepare('VACUUM INTO ?').run(path.resolve(output));}finally {db.close();}
integrity(output);
} else fs.copyFileSync(original,output,fs.constants.COPYFILE_EXCL);
fs.chmodSync(output,0o600);
files.push({name,sqlite:dbNames.has(name),bytes:fs.statSync(output).size,sha256:await hash(output)});
}
fs.writeFileSync(path.join(destination,'volume-manifest.json'),JSON.stringify({version:1,scope:'data-volume',createdAt:new Date().toISOString(),files},null,2),{flag:'wx',mode:0o600});
return await verifyDataVolume(destination);
} catch(error) {fs.rmSync(destination,{recursive:true,force:true});throw error;}
}
export async function restoreDataVolume(backup,destination) {
const manifest=await verifyDataVolume(backup);
if(inside(backup,destination)||inside(destination,backup))throw new Error('Yedek ve hedef iç içe olamaz.');
fs.mkdirSync(destination,{mode:0o700});
try {
for(const entry of manifest.files) {
const target=safeFile(destination,entry.name);fs.mkdirSync(path.dirname(target),{recursive:true,mode:0o700});
fs.copyFileSync(safeFile(backup,entry.name),target,fs.constants.COPYFILE_EXCL);fs.chmodSync(target,0o600);
if(await hash(target)!==entry.sha256)throw new Error('Geri yüklenen dosya doğrulanamadı.');
if(entry.sqlite)integrity(target);
}
return {files:manifest.files.length,destination};
} catch(error) {fs.rmSync(destination,{recursive:true,force:true});throw error;}
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
const [command,source,destination,flag]=process.argv.slice(2);
try {
if(command==='backup'&&source&&destination)await backupDataVolume(source,destination,{offline:flag==='--offline'});
else if(command==='verify'&&source)await verifyDataVolume(source);
else if(command==='restore'&&source&&destination)await restoreDataVolume(source,destination);
else throw new Error('backup <source> <new-backup> --offline | verify <backup> | restore <backup> <new-volume>');
console.log('Volume işlemi doğrulandı.');
}catch(error){console.error(error.message);process.exitCode=1;}
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {DatabaseSync} from 'node:sqlite';
import {backupDataVolume,verifyDataVolume,restoreDataVolume} from '../server/backup-data-volume.mjs';
test('shared volume backup captures multiple SQLite stores, committed WAL and nested files',async()=>{
const dir=fs.mkdtempSync(join(tmpdir(),'ihaleist-volume-')),source=join(dir,'source'),backup=join(dir,'backup'),restored=join(dir,'restored');
fs.mkdirSync(source);let live;
try {
for(const name of ['kalem.sqlite','tender-history.sqlite']) {
const db=new DatabaseSync(join(source,name));db.exec('PRAGMA journal_mode=WAL;CREATE TABLE records(value TEXT);');db.prepare('INSERT INTO records VALUES (?)').run('Son Türkçe kayıt');
if(name==='kalem.sqlite')live=db;else db.close();
}
fs.mkdirSync(join(source,'nested'));fs.writeFileSync(join(source,'nested','cache.json'),'["İhale"]');
const manifest=await backupDataVolume(source,backup,{offline:true});
assert.equal(manifest.files.length,3);assert.equal(manifest.files.filter(f=>f.sqlite).length,2);
assert.equal(manifest.files.some(f=>/-wal$|-shm$/.test(f.name)),false);
await restoreDataVolume(backup,restored);
const db=new DatabaseSync(join(restored,'kalem.sqlite'));assert.equal(db.prepare('SELECT value FROM records').get().value,'Son Türkçe kayıt');db.close();
assert.equal(fs.readFileSync(join(restored,'nested','cache.json'),'utf8'),'["İhale"]');
await assert.rejects(restoreDataVolume(backup,restored),/EEXIST/);
fs.appendFileSync(join(backup,'nested','cache.json'),'changed');
await assert.rejects(verifyDataVolume(backup),/değişmiş/);
} finally {live?.close();fs.rmSync(dir,{recursive:true,force:true});}
});
test('volume backup refuses unconfirmed offline, symlinks, nested output and unlisted files',async()=>{
const dir=fs.mkdtempSync(join(tmpdir(),'ihaleist-volume-')),source=join(dir,'source'),backup=join(dir,'backup');fs.mkdirSync(source);
try {
fs.writeFileSync(join(source,'data.json'),'{}');
await assert.rejects(backupDataVolume(source,backup),/offline/);
await assert.rejects(backupDataVolume(source,join(source,'inside'),{offline:true}),/iç içe/);
fs.symlinkSync(join(source,'data.json'),join(source,'link'));
await assert.rejects(backupDataVolume(source,backup,{offline:true}),/sembolik/);
fs.unlinkSync(join(source,'link'));
await backupDataVolume(source,backup,{offline:true});
fs.writeFileSync(join(backup,'unlisted'),'extra');
await assert.rejects(verifyDataVolume(backup),/Manifest dışında/);
} finally {fs.rmSync(dir,{recursive:true,force:true});}
});