feat(storage): add verified offline MinIO migration pilot
This commit is contained in:
2
scripts/storage/.dockerignore
Normal file
2
scripts/storage/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
tests
|
||||
6
scripts/storage/Dockerfile
Normal file
6
scripts/storage/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32
|
||||
WORKDIR /opt/fedeo-storage
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
|
||||
COPY s3.mjs cli.mjs ./
|
||||
ENTRYPOINT ["node", "/opt/fedeo-storage/cli.mjs"]
|
||||
44
scripts/storage/README.md
Normal file
44
scripts/storage/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Speicherwerkzeug für den betreuten Pilotbetrieb
|
||||
|
||||
AWS SDK `3.962.0` entspricht dem bei Entwicklung vorliegenden Backend-Lockfile.
|
||||
Der konkrete Ablauf auf einer Instanz steht in
|
||||
[`docs/minio-umstellung-pilot.md`](../../docs/minio-umstellung-pilot.md).
|
||||
|
||||
Lokaler Test (Linux amd64, Docker, Node 22):
|
||||
|
||||
```sh
|
||||
npm ci --prefix scripts/storage
|
||||
npm test --prefix scripts/storage
|
||||
docker pull chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882
|
||||
docker build -f scripts/storage/tests/Dockerfile.minio -t fedeo-minio-fixture:2025-09-07 scripts/storage/tests
|
||||
docker build -t fedeo-storage-tools:pilot scripts/storage
|
||||
node scripts/storage/tests/integration.mjs
|
||||
python3 -B scripts/storage/tests/host_integration.py
|
||||
```
|
||||
|
||||
Der S3-Test verwendet ausschließlich neu angelegte Testcontainer mit künstlichen
|
||||
Daten. Er prüft über 1.000 Objekte, Pagination, 256 MiB, leere Dateien,
|
||||
Sonderzeichen, Metadaten, SigV4, 24-Stunden-Links, Streaming, verbotene Zugriffe,
|
||||
beschädigte Zielobjekte, erneute Kopie und echten Restore in ein frisches
|
||||
Verzeichnis. Die gesicherten Testdaten bleiben unter `/tmp/fedeo-storage-*`
|
||||
zur Prüfung erhalten; die Testcontainer werden entfernt.
|
||||
|
||||
Der Host-Test verwendet einen echten PostgreSQL-/MinIO-/SeaweedFS-Stack und
|
||||
künstliche Anwendungscontainer. Er ersetzt keine fachliche Abnahme von FEDEO
|
||||
(PDF, WebDAV, Exporte, Mandantenrechte) auf der konkreten Instanz.
|
||||
|
||||
Die erste Übernahme ist auf 200.000 Objekte und 256 MiB pro Objekt begrenzt.
|
||||
Das größte Objekt wird auf Platte gespult; RAM-Verbrauch wächst nicht mit dessen
|
||||
Größe. Multipart-Übernahmen und Sonderkonfigurationen werden abgelehnt.
|
||||
|
||||
SeaweedFS 4.47 bewahrt bei SDK-Streaming mit automatischen Prüfsummen teilweise
|
||||
`Content-Encoding: aws-chunked` auf. Der Kopierer setzt einen vorher berechneten
|
||||
SHA-256-Checksum-Header. Die Pilot-Anwendung erhält
|
||||
`AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED`; der Vertragstest prüft diese
|
||||
Einstellung mit leeren und nicht leeren Streams. SigV4 bleibt aktiv.
|
||||
|
||||
SeaweedFS-Herkunft: [Release 4.47](https://github.com/seaweedfs/seaweedfs/releases/tag/4.47).
|
||||
Das isolierte MinIO-Testimage verwendet das offizielle, per SHA-256 verifizierte
|
||||
[Release-Binary](https://github.com/minio/minio/releases/tag/RELEASE.2025-09-07T16-13-09Z),
|
||||
da die früheren Containerreferenzen nicht mehr abrufbar sind. Es wird nicht im
|
||||
Pilotpaket mitgeliefert und nicht über Kundendaten gestartet.
|
||||
39
scripts/storage/build-pilot.sh
Normal file
39
scripts/storage/build-pilot.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
storage_repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
storage_output="${1:-$storage_repo/.release/storage-pilot}"
|
||||
test ! -e "$storage_output"
|
||||
mkdir -p "$storage_output/scripts/storage" "$storage_output/docs"
|
||||
storage_weed="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["image"])' "$storage_repo/scripts/storage/seaweedfs.json")"
|
||||
if ! docker image inspect "$storage_weed" >/dev/null 2>&1; then docker pull "$storage_weed"; fi
|
||||
docker build -t fedeo-storage-tools:pilot "$storage_repo/scripts/storage"
|
||||
storage_tools_id="$(docker image inspect fedeo-storage-tools:pilot --format '{{.Id}}')"
|
||||
storage_weed_id="$(docker image inspect "$storage_weed" --format '{{.Id}}')"
|
||||
storage_tools="fedeo-storage-tools:pilot-${storage_tools_id#sha256:}"
|
||||
storage_weed_local="fedeo-seaweedfs:pilot-${storage_weed_id#sha256:}"
|
||||
docker image tag "$storage_tools_id" "$storage_tools"
|
||||
docker image tag "$storage_weed_id" "$storage_weed_local"
|
||||
cp "$storage_repo/scripts/selfhost-storage-migrate.py" "$storage_repo/scripts/selfhost-update.py" "$storage_output/scripts/"
|
||||
cp "$storage_repo/scripts/storage/"{s3.mjs,cli.mjs,seaweedfs.json,start-seaweedfs.sh,package.json,package-lock.json} "$storage_output/scripts/storage/"
|
||||
cp "$storage_repo/scripts/storage/pilot.py" "$storage_output/pilot.py"
|
||||
cp "$storage_repo/docs/minio-umstellung-pilot.md" "$storage_output/docs/"
|
||||
docker image save -o "$storage_output/images.tar" "$storage_tools" "$storage_weed_local"
|
||||
python3 - "$storage_output" "$storage_tools" "$storage_weed_local" "$storage_weed" <<'PY'
|
||||
import hashlib,json,pathlib,sys
|
||||
root=pathlib.Path(sys.argv[1]).resolve()
|
||||
spec=root/'scripts/storage/seaweedfs.json'
|
||||
config=json.loads(spec.read_text()); config['image']=sys.argv[3]
|
||||
spec.write_text(json.dumps(config,indent=2)+'\n')
|
||||
checksums={}
|
||||
for p in sorted(root.rglob('*')):
|
||||
if p.is_file():
|
||||
h=hashlib.sha256()
|
||||
with p.open('rb') as f:
|
||||
for chunk in iter(lambda:f.read(1024*1024), b''): h.update(chunk)
|
||||
checksums[str(p.relative_to(root))]=h.hexdigest()
|
||||
(root/'bundle.json').write_text(json.dumps({'schemaVersion':1,'toolsImage':sys.argv[2],
|
||||
'seaweedImage':sys.argv[3],'upstreamSeaweedImage':sys.argv[4],'sha256':checksums},indent=2)+'\n')
|
||||
PY
|
||||
tar -czf "$storage_output.tar.gz" -C "$(dirname "$storage_output")" "$(basename "$storage_output")"
|
||||
(cd "$(dirname "$storage_output")" && sha256sum "$(basename "$storage_output").tar.gz" > "$(basename "$storage_output").tar.gz.sha256")
|
||||
printf 'Pilotpaket: %s.tar.gz\n' "$storage_output"
|
||||
39
scripts/storage/cli.mjs
Normal file
39
scripts/storage/cli.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { client, call, inventory, transfer, contract, verifyManifest, atomicJson, cleanupProbe } from './s3.mjs';
|
||||
|
||||
// Secrets enter through a root-only mounted JSON file, never through argv or logs.
|
||||
const [command, configPath = '/config/job.json'] = process.argv.slice(2);
|
||||
if (command === 'contract') process.env.AWS_REQUEST_CHECKSUM_CALCULATION = 'WHEN_REQUIRED';
|
||||
let source, target;
|
||||
try {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8'));
|
||||
source = config.source && client(config.source);
|
||||
target = config.target && client(config.target);
|
||||
const bucket = config.bucket;
|
||||
let result;
|
||||
if (command === 'inventory') result = await inventory(source, bucket);
|
||||
else if (command === 'copy' || command === 'verify') {
|
||||
result = await transfer(source, target, bucket, config.directory || '/work', { verifyOnly: command === 'verify' });
|
||||
result = { ...result, objects: undefined };
|
||||
} else if (command === 'init') {
|
||||
try { await call(target, 'HeadBucket', { Bucket: bucket }); }
|
||||
catch (error) {
|
||||
if (error.$metadata?.httpStatusCode !== 404) throw error;
|
||||
await call(target, 'CreateBucket', { Bucket: bucket });
|
||||
}
|
||||
result = { bucket, initialized: true };
|
||||
} else if (command === 'contract') result = await contract(target, config.target, bucket, { ...config, probePath: '/work/probe.json' });
|
||||
else if (command === 'cleanup-probe') { await cleanupProbe(target, bucket, '/work/probe.json'); result = { cleaned: true }; }
|
||||
else if (command === 'restore-verify') {
|
||||
result = await verifyManifest(target, bucket, JSON.parse(await readFile(config.manifest || '/work/verified.json', 'utf8')));
|
||||
} else throw new Error('Unbekannter Befehl.');
|
||||
await atomicJson(config.result || '/work/result.json', result);
|
||||
console.log(JSON.stringify(command === 'inventory' ? { count: result.count, bytes: result.bytes } : result));
|
||||
} catch (error) {
|
||||
// SDK error messages/URLs may contain identifiers; keep public output bounded.
|
||||
const detail = { error: error.name, code: error.code, status: error.$metadata?.httpStatusCode,
|
||||
message: error.$metadata ? 'S3-Prüfung fehlgeschlagen; Zugriff/Konfiguration prüfen.' : error.message };
|
||||
try { await atomicJson('/work/error.json', detail); } catch { /* Preserve the original error. */ }
|
||||
console.error(JSON.stringify(detail));
|
||||
process.exitCode = 1;
|
||||
} finally { source?.destroy(); target?.destroy(); }
|
||||
1740
scripts/storage/package-lock.json
generated
Normal file
1740
scripts/storage/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
scripts/storage/package.json
Normal file
12
scripts/storage/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@fedeo/storage-migration",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": { "test": "node --test tests/*.test.mjs" },
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.962.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.962.0"
|
||||
}
|
||||
}
|
||||
51
scripts/storage/pilot.py
Normal file
51
scripts/storage/pilot.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point shipped inside a verified, self-contained storage pilot bundle."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def checksum(path):
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for block in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["load-images", "plan", "migrate", "activate", "resume-start", "rollback", "status"])
|
||||
parser.add_argument("--directory", default="/opt/fedeo")
|
||||
parser.add_argument("--writers-exclusive", action="store_true")
|
||||
args = parser.parse_args()
|
||||
manifest = json.loads((ROOT / "bundle.json").read_text())
|
||||
for name, expected in manifest["sha256"].items():
|
||||
# The large image archive is checked when loading, not on every status call.
|
||||
if name == "images.tar" and args.command != "load-images":
|
||||
continue
|
||||
path = ROOT / name
|
||||
if path.resolve().is_relative_to(ROOT) and checksum(path) == expected:
|
||||
continue
|
||||
raise SystemExit("Paket-Prüfsumme stimmt nicht: " + name)
|
||||
if args.command == "load-images":
|
||||
subprocess.run(["docker", "image", "load", "-i", str(ROOT / "images.tar")], check=True)
|
||||
for image in (manifest["toolsImage"], manifest["seaweedImage"]):
|
||||
subprocess.run(["docker", "image", "inspect", image], check=True, stdout=subprocess.DEVNULL)
|
||||
print("Offline-Images geprüft und geladen. Nächster Schritt: plan.")
|
||||
return
|
||||
command = [sys.executable, str(ROOT / "scripts/selfhost-storage-migrate.py"), args.command, "--directory", args.directory]
|
||||
if args.command == "plan":
|
||||
command += ["--tools-image", manifest["toolsImage"]]
|
||||
if args.writers_exclusive:
|
||||
command += ["--writers-exclusive"]
|
||||
raise SystemExit(subprocess.run(command).returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
283
scripts/storage/s3.mjs
Normal file
283
scripts/storage/s3.mjs
Normal file
@@ -0,0 +1,283 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
||||
import { Transform } from 'node:stream';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// A pilot also runs this contract inside the exact, already installed backend
|
||||
// image. Its own SDK dependency tree must pass before the host can cut over.
|
||||
const requireSdk = createRequire(process.env.FEDEO_STORAGE_SDK_ROOT
|
||||
? join(process.env.FEDEO_STORAGE_SDK_ROOT, 'package.json') : import.meta.url);
|
||||
const S3 = requireSdk('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = requireSdk('@aws-sdk/s3-request-presigner');
|
||||
|
||||
// The pilot deliberately has a tested bound; no untested multipart conversion.
|
||||
export const MAX_OBJECT_BYTES = 256 * 1024 * 1024;
|
||||
export const MAX_OBJECTS = 200_000;
|
||||
export const client = config => new S3.S3Client({ ...config, forcePathStyle: true, maxAttempts: 3 });
|
||||
export const call = (s3, operation, input) => s3.send(new S3[`${operation}Command`](input));
|
||||
export const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v);
|
||||
export const digest = value => createHash('sha256').update(canonical(value)).digest('hex');
|
||||
const requireThat = (condition, reason) => { if (!condition) throw new Error(reason); };
|
||||
|
||||
export async function atomicJson(path, value) {
|
||||
const temp = `${path}.tmp`;
|
||||
const file = await open(temp, 'w', 0o600);
|
||||
try { await file.writeFile(JSON.stringify(value, null, 2) + '\n'); await file.sync(); }
|
||||
finally { await file.close(); }
|
||||
await rename(temp, path);
|
||||
const parent = await open(join(path, '..'), 'r');
|
||||
try { await parent.sync(); } finally { await parent.close(); }
|
||||
}
|
||||
|
||||
export function validateKeys(objects) {
|
||||
requireThat(objects.length <= MAX_OBJECTS, `Pilotgrenze: höchstens ${MAX_OBJECTS} Objekte.`);
|
||||
const keys = new Set(objects.map(o => o.Key));
|
||||
requireThat(keys.size === objects.length, 'Doppelte Objektschlüssel.');
|
||||
for (const { Key: key, Size: size } of objects) {
|
||||
requireThat(typeof key === 'string' && key.length > 0 && Buffer.byteLength(key) <= 1024,
|
||||
'Ungültiger Objektschlüssel.');
|
||||
const segments = key.split('/');
|
||||
requireThat(!/[\x00-\x1f\x7f\\]/.test(key) && segments.every(s => s && s !== '.' && s !== '..'),
|
||||
`Nicht verlustfrei übertragbarer Schlüssel: ${JSON.stringify(key)}`);
|
||||
requireThat(Number.isSafeInteger(size) && size >= 0 && size <= MAX_OBJECT_BYTES,
|
||||
`Objekt außerhalb der getesteten Größe (256 MiB): ${JSON.stringify(key)}`);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
requireThat(!keys.has(segments.slice(0, i).join('/')), `Datei-/Verzeichniskonflikt: ${JSON.stringify(key)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(s3, Bucket) {
|
||||
const result = [];
|
||||
let ContinuationToken;
|
||||
do {
|
||||
const page = await call(s3, 'ListObjectsV2', { Bucket, ContinuationToken });
|
||||
result.push(...(page.Contents || []).map(o => ({ Key: o.Key, Size: o.Size, ETag: o.ETag,
|
||||
LastModified: o.LastModified?.toISOString() })));
|
||||
requireThat(result.length <= MAX_OBJECTS, `Pilotgrenze: höchstens ${MAX_OBJECTS} Objekte.`);
|
||||
requireThat(!page.IsTruncated || (page.NextContinuationToken && page.NextContinuationToken !== ContinuationToken),
|
||||
'Ungültige S3-Paginierung.');
|
||||
ContinuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
|
||||
} while (ContinuationToken);
|
||||
return result.sort((a, b) => Buffer.compare(Buffer.from(a.Key), Buffer.from(b.Key)));
|
||||
}
|
||||
|
||||
async function optional(s3, operation, Bucket, absentCodes) {
|
||||
try { return await call(s3, operation, { Bucket }); }
|
||||
catch (error) { if (absentCodes.includes(error.name)) return {}; throw error; }
|
||||
}
|
||||
|
||||
function privateAcl(acl) {
|
||||
return (acl.Grants || []).every(g => g.Grantee?.Type === 'CanonicalUser' &&
|
||||
(g.Grantee.ID || '') === (acl.Owner?.ID || '') && g.Permission === 'FULL_CONTROL');
|
||||
}
|
||||
|
||||
export async function inspectBucket(s3, Bucket) {
|
||||
const buckets = await call(s3, 'ListBuckets', {});
|
||||
requireThat(buckets.Buckets?.length === 1 && buckets.Buckets[0].Name === Bucket,
|
||||
'Die Quelle muss genau den FEDEO-Bucket enthalten; weitere Buckets separat klären.');
|
||||
const v = await call(s3, 'GetBucketVersioning', { Bucket });
|
||||
requireThat(!v.Status, 'Aktivierte oder ausgesetzte Versionierung wird nicht unterstützt.');
|
||||
const versions = await call(s3, 'ListObjectVersions', { Bucket, MaxKeys: 1000 });
|
||||
// Even for an unversioned bucket MinIO returns current versions with VersionId=null.
|
||||
requireThat(!(versions.DeleteMarkers?.length) && (versions.Versions || []).every(x =>
|
||||
x.IsLatest && x.VersionId === 'null'), 'Alte Versionen oder Delete-Marker vorhanden.');
|
||||
// A versioning flag above is mandatory; still inspect all version pages to detect old history.
|
||||
let page = versions;
|
||||
while (page.IsTruncated) {
|
||||
const next = await call(s3, 'ListObjectVersions', { Bucket, KeyMarker: page.NextKeyMarker,
|
||||
VersionIdMarker: page.NextVersionIdMarker });
|
||||
requireThat(!(next.DeleteMarkers?.length) && (next.Versions || []).every(x => x.IsLatest && x.VersionId === 'null'),
|
||||
'Alte Versionen oder Delete-Marker vorhanden.');
|
||||
requireThat(next.NextKeyMarker !== page.NextKeyMarker || !next.IsTruncated, 'Ungültige Versionspaginierung.');
|
||||
page = next;
|
||||
}
|
||||
const lock = await optional(s3, 'GetObjectLockConfiguration', Bucket, ['ObjectLockConfigurationNotFoundError', 'ObjectLockConfigurationNotFound']);
|
||||
requireThat(!lock.ObjectLockConfiguration?.ObjectLockEnabled, 'Object Lock wird nicht unterstützt.');
|
||||
const encryption = await optional(s3, 'GetBucketEncryption', Bucket, ['ServerSideEncryptionConfigurationNotFoundError']);
|
||||
requireThat(!encryption.ServerSideEncryptionConfiguration?.Rules?.length, 'Bucket-Verschlüsselung benötigt einen eigenen Adapter.');
|
||||
const policy = await optional(s3, 'GetBucketPolicy', Bucket, ['NoSuchBucketPolicy']);
|
||||
requireThat(!policy.Policy, 'Eigene Bucket-Policy benötigt eine gesonderte Übernahme.');
|
||||
const lifecycle = await optional(s3, 'GetBucketLifecycleConfiguration', Bucket, ['NoSuchLifecycleConfiguration']);
|
||||
requireThat(!lifecycle.Rules?.length, 'Lifecycle-Regeln vorhanden.');
|
||||
const replication = await optional(s3, 'GetBucketReplication', Bucket, ['ReplicationConfigurationNotFoundError']);
|
||||
requireThat(!replication.ReplicationConfiguration, 'Bucket-Replikation vorhanden.');
|
||||
const notification = await call(s3, 'GetBucketNotificationConfiguration', { Bucket });
|
||||
requireThat(!['TopicConfigurations', 'QueueConfigurations', 'LambdaFunctionConfigurations'].some(k => notification[k]?.length)
|
||||
&& !notification.EventBridgeConfiguration, 'Bucket-Benachrichtigungen vorhanden.');
|
||||
const uploads = await call(s3, 'ListMultipartUploads', { Bucket, MaxUploads: 1 });
|
||||
requireThat(!uploads.Uploads?.length, 'Unvollständige Multipart-Uploads vorhanden.');
|
||||
const tags = await optional(s3, 'GetBucketTagging', Bucket, ['NoSuchTagSet', 'NoSuchTagSetError']);
|
||||
requireThat(!tags.TagSet?.length, 'Bucket-Tags vorhanden.');
|
||||
const cors = await optional(s3, 'GetBucketCors', Bucket, ['NoSuchCORSConfiguration']);
|
||||
requireThat(!cors.CORSRules?.length, 'Eigene CORS-Konfiguration vorhanden.');
|
||||
requireThat(privateAcl(await call(s3, 'GetBucketAcl', { Bucket })), 'Bucket-ACL ist nicht privat.');
|
||||
}
|
||||
|
||||
export function metadata(head) {
|
||||
requireThat(!head.ServerSideEncryption && !head.SSECustomerAlgorithm && !head.ObjectLockMode &&
|
||||
!head.ObjectLockLegalHoldStatus && !head.WebsiteRedirectLocation && (!head.StorageClass || head.StorageClass === 'STANDARD'),
|
||||
'Nicht unterstützte Objektattribute gefunden.');
|
||||
const result = {};
|
||||
for (const key of ['ContentType', 'ContentDisposition', 'ContentEncoding', 'ContentLanguage', 'CacheControl', 'Metadata']) {
|
||||
if (head[key] !== undefined) result[key] = head[key];
|
||||
}
|
||||
result.Metadata ||= {};
|
||||
if (head.Expires) result.Expires = head.Expires.toISOString();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function inventory(s3, Bucket) {
|
||||
await inspectBucket(s3, Bucket);
|
||||
const objects = await list(s3, Bucket);
|
||||
validateKeys(objects);
|
||||
for (const object of objects) {
|
||||
const input = { Bucket, Key: object.Key };
|
||||
const head = await call(s3, 'HeadObject', input);
|
||||
requireThat(head.ETag === object.ETag && head.ContentLength === object.Size, 'Quelle während Inventarisierung verändert.');
|
||||
object.metadata = metadata(head);
|
||||
requireThat(!(await call(s3, 'GetObjectTagging', input)).TagSet?.length, `Objekt-Tags vorhanden: ${JSON.stringify(object.Key)}`);
|
||||
requireThat(privateAcl(await call(s3, 'GetObjectAcl', input)), 'Objekt-ACL ist nicht privat.');
|
||||
}
|
||||
return { schemaVersion: 1, bucket: Bucket, count: objects.length,
|
||||
bytes: objects.reduce((n, o) => n + o.Size, 0), objects };
|
||||
}
|
||||
|
||||
export async function readHash(s3, Bucket, object, spool) {
|
||||
const response = await call(s3, 'GetObject', { Bucket, Key: object.Key, ...(object.ETag ? { IfMatch: object.ETag } : {}) });
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const inspect = new Transform({ transform(chunk, _, next) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_OBJECT_BYTES) return next(new Error('Objekt überschreitet Größenlimit.'));
|
||||
hash.update(chunk); next(null, chunk);
|
||||
}});
|
||||
if (spool) await pipeline(response.Body, inspect, createWriteStream(spool, { mode: 0o600 }));
|
||||
else for await (const chunk of response.Body) { bytes += chunk.length; hash.update(chunk);
|
||||
requireThat(bytes <= MAX_OBJECT_BYTES, 'Objekt überschreitet Größenlimit.'); }
|
||||
requireThat(bytes === object.Size, 'Objektgröße während Lesen verändert.');
|
||||
return { sha256: hash.digest('hex'), size: bytes, metadata: metadata(response) };
|
||||
}
|
||||
|
||||
export async function transfer(source, target, Bucket, directory, { verifyOnly = false } = {}) {
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const current = await inventory(source, Bucket);
|
||||
const manifestPath = join(directory, 'inventory.json');
|
||||
let previous;
|
||||
try { previous = JSON.parse(await readFile(manifestPath, 'utf8')); }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
requireThat(!previous || canonical(previous) === canonical(current), 'Quellinventar seit Beginn verändert.');
|
||||
const targetObjects = await list(target, Bucket);
|
||||
requireThat(previous || targetObjects.length === 0, 'Ziel ist bei erster Kopie nicht leer.');
|
||||
const sourceKeys = new Set(current.objects.map(o => o.Key));
|
||||
requireThat(targetObjects.every(o => sourceKeys.has(o.Key)), 'Unerklärte zusätzliche Zielobjekte vorhanden.');
|
||||
if (!previous) await atomicJson(manifestPath, current);
|
||||
const existing = new Map(targetObjects.map(o => [o.Key, o]));
|
||||
const journal = await open(join(directory, 'objects.jsonl'), 'a', 0o600);
|
||||
const hashes = [];
|
||||
try {
|
||||
for (const object of current.objects) {
|
||||
const spool = join(directory, 'object.spool');
|
||||
try {
|
||||
const original = await readHash(source, Bucket, object, verifyOnly ? undefined : spool);
|
||||
requireThat(canonical(original.metadata) === canonical(object.metadata), 'Quellmetadaten verändert.');
|
||||
let copied;
|
||||
if (existing.has(object.Key) && existing.get(object.Key).Size === object.Size) {
|
||||
copied = await readHash(target, Bucket, { Key: object.Key, Size: object.Size });
|
||||
}
|
||||
if (canonical(copied) !== canonical(original)) {
|
||||
requireThat(!verifyOnly, `Integritätsfehler: ${JSON.stringify(object.Key)}`);
|
||||
const meta = { ...object.metadata };
|
||||
if (meta.Expires) meta.Expires = new Date(meta.Expires);
|
||||
await call(target, 'PutObject', { Bucket, Key: object.Key, ...meta,
|
||||
ContentLength: object.Size, ChecksumSHA256: Buffer.from(original.sha256, 'hex').toString('base64'),
|
||||
Body: object.Size === 0 ? Buffer.alloc(0) : createReadStream(spool) });
|
||||
copied = await readHash(target, Bucket, { Key: object.Key, Size: object.Size });
|
||||
requireThat(canonical(copied) === canonical(original), `Integritätsfehler nach Kopie: ${JSON.stringify(object.Key)}`);
|
||||
}
|
||||
hashes.push({ key: object.Key, ...original });
|
||||
await journal.writeFile(JSON.stringify({ key: object.Key, ...original, verifiedAt: new Date().toISOString() }) + '\n');
|
||||
await journal.sync();
|
||||
process.stdout.write(JSON.stringify({ phase: verifyOnly ? 'verifying' : 'copying', completed: hashes.length, total: current.count }) + '\n');
|
||||
} finally { await rm(spool, { force: true }); }
|
||||
}
|
||||
} finally { await journal.close(); }
|
||||
const after = await inventory(source, Bucket);
|
||||
requireThat(canonical(after) === canonical(current), 'Quelle während Kopie/Prüfung verändert.');
|
||||
const result = await list(target, Bucket);
|
||||
requireThat(canonical(result.map(({ Key, Size }) => ({ Key, Size }))) ===
|
||||
canonical(current.objects.map(({ Key, Size }) => ({ Key, Size }))), 'Schlüsselmenge oder Größen weichen ab.');
|
||||
const report = { schemaVersion: 1, verifiedAt: new Date().toISOString(), count: current.count, bytes: current.bytes,
|
||||
inventorySha256: digest(current), objectsSha256: digest(hashes), objects: hashes };
|
||||
await atomicJson(join(directory, 'verified.json'), report);
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function verifyManifest(target, Bucket, report) {
|
||||
const objects = await list(target, Bucket);
|
||||
requireThat(canonical(objects.map(o => ({ key: o.Key, size: o.Size }))) ===
|
||||
canonical(report.objects.map(o => ({ key: o.key, size: o.size }))), 'Restore: Schlüsselmenge/Größen abweichend.');
|
||||
for (const object of report.objects) {
|
||||
requireThat(canonical(await readHash(target, Bucket, { Key: object.key, Size: object.size })) ===
|
||||
canonical({ sha256: object.sha256, size: object.size, metadata: object.metadata }), 'Restore: Hash/Metadaten abweichend.');
|
||||
}
|
||||
return { count: report.count, bytes: report.bytes, restored: true };
|
||||
}
|
||||
|
||||
export async function cleanupProbe(s3, Bucket, probePath) {
|
||||
let record;
|
||||
try { record = JSON.parse(await readFile(probePath, 'utf8')); }
|
||||
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
||||
requireThat(/^fedeo-contract-[0-9a-f-]{36}\//.test(record.key), 'Ungültiges Probejournal.');
|
||||
await call(s3, 'DeleteObject', { Bucket, Key: record.key });
|
||||
await call(s3, 'DeleteObject', { Bucket, Key: record.key + '.stream' });
|
||||
}
|
||||
|
||||
export async function contract(s3, config, Bucket, { foreignBucket, probePath } = {}) {
|
||||
if (probePath) await cleanupProbe(s3, Bucket, probePath);
|
||||
const Key = `fedeo-contract-${randomUUID()}/Mandant ä + % #/Prüfung.pdf`;
|
||||
if (probePath) await atomicJson(probePath, { key: Key });
|
||||
const body = Buffer.from('FEDEO S3 Prüfung\n');
|
||||
try {
|
||||
await call(s3, 'PutObject', { Bucket, Key, Body: body, ContentType: 'application/pdf',
|
||||
ContentDisposition: 'inline; filename="test.pdf"', CacheControl: 'private, max-age=0', Metadata: { test: 'fedeo' } });
|
||||
const head = await call(s3, 'HeadObject', { Bucket, Key });
|
||||
requireThat(head.ContentLength === body.length && head.Metadata.test === 'fedeo', 'HEAD-Vertrag fehlgeschlagen.');
|
||||
const got = await call(s3, 'GetObject', { Bucket, Key });
|
||||
requireThat(Buffer.from(await got.Body.transformToByteArray()).equals(body), 'GET-Vertrag fehlgeschlagen.');
|
||||
// Application exports use streams. Exercise exactly the SDK environment setting
|
||||
// applied by the pilot Compose file, including empty streams.
|
||||
for (const bytes of [Buffer.alloc(0), Buffer.alloc(128 * 1024, 42)]) {
|
||||
await call(s3, 'PutObject', { Bucket, Key: Key + '.stream', ContentLength: bytes.length,
|
||||
Body: Readable.from([bytes]), ContentType: 'application/zip' });
|
||||
const streamHead = await call(s3, 'HeadObject', { Bucket, Key: Key + '.stream' });
|
||||
requireThat(!streamHead.ContentEncoding && streamHead.ContentLength === bytes.length, 'Streaming-Metadaten abweichend.');
|
||||
const downloaded = await call(s3, 'GetObject', { Bucket, Key: Key + '.stream' });
|
||||
requireThat(Buffer.from(await downloaded.Body.transformToByteArray()).equals(bytes), 'Streaming-Inhalt abweichend.');
|
||||
}
|
||||
const url = await getSignedUrl(s3, new S3.GetObjectCommand({ Bucket, Key }), { expiresIn: 86400 });
|
||||
const signed = await fetch(url);
|
||||
requireThat(signed.ok && Buffer.from(await signed.arrayBuffer()).equals(body), 'Presigned-URL fehlgeschlagen.');
|
||||
const anonymous = await fetch(url.split('?')[0]);
|
||||
await anonymous.arrayBuffer();
|
||||
requireThat([401, 403].includes(anonymous.status), 'Anonymer Objektzugriff erlaubt!');
|
||||
const wrong = client({ ...config, credentials: { accessKeyId: 'fedeo-invalid', secretAccessKey: 'fedeo-invalid-secret' } });
|
||||
try { await denied(wrong, 'GetObject', { Bucket, Key }); } finally { wrong.destroy(); }
|
||||
if (foreignBucket) await denied(s3, 'ListObjectsV2', { Bucket: foreignBucket });
|
||||
await call(s3, 'DeleteObject', { Bucket, Key });
|
||||
try { await call(s3, 'HeadObject', { Bucket, Key }); throw new Error('DELETE nicht wirksam.'); }
|
||||
catch (error) { if (error.$metadata?.httpStatusCode !== 404) throw error; }
|
||||
} finally { await call(s3, 'DeleteObject', { Bucket, Key }); await call(s3, 'DeleteObject', { Bucket, Key: Key + '.stream' }); }
|
||||
return { operations: ['PUT', 'HEAD', 'GET', 'DELETE', 'SigV4', 'presigned-24h', 'anonymous-denied', 'wrong-key-denied'] };
|
||||
}
|
||||
|
||||
async function denied(s3, operation, input) {
|
||||
try { const value = await call(s3, operation, input); value.Body?.destroy(); }
|
||||
catch (error) { if ([401, 403].includes(error.$metadata?.httpStatusCode)) return; throw error; }
|
||||
throw new Error('Nicht autorisierter Zugriff wurde erlaubt.');
|
||||
}
|
||||
16
scripts/storage/seaweedfs.json
Normal file
16
scripts/storage/seaweedfs.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"image": "chrislusf/seaweedfs:4.47@sha256:ce9e796f1fe6f06968f4c04bdaf8f678dad9c8acdfef3d244133d71bfa6bf882",
|
||||
"entrypoint": ["/bin/sh", "/etc/fedeo/start-seaweedfs.sh"],
|
||||
"command": [
|
||||
"server", "-dir=/data", "-master.dir=/data/master", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
|
||||
"-master.telemetry=false", "-master.volumeSizeLimitMB=1024", "-volume.max=0", "-volume.minFreeSpace=1GiB",
|
||||
"-filer", "-s3", "-s3.ip.bind=0.0.0.0", "-s3.port=9000", "-s3.config=/etc/fedeo/s3.json",
|
||||
"-s3.iam=false", "-s3.port.iceberg=0", "-s3.port.lance=0", "-s3.autoCreateBucket=false",
|
||||
"-s3.allowDeleteBucketNotEmpty=false"
|
||||
],
|
||||
"stop_grace_period": "60s",
|
||||
"healthcheck": {
|
||||
"test": ["CMD-SHELL", "wget -S -O /dev/null http://127.0.0.1:9000/ 2>&1 | grep -q ' 403 '"],
|
||||
"interval": "5s", "timeout": "3s", "retries": 30, "start_period": "10s"
|
||||
}
|
||||
}
|
||||
9
scripts/storage/start-seaweedfs.sh
Normal file
9
scripts/storage/start-seaweedfs.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Never start an anonymously writable gateway when its configuration is absent,
|
||||
# truncated, or replaced. The host generated and validated this exact file.
|
||||
test -n "${FEDEO_S3_CONFIG_SHA256:-}"
|
||||
test -s /etc/fedeo/s3.json
|
||||
actual_sha256="$(sha256sum /etc/fedeo/s3.json)"
|
||||
test "${actual_sha256%% *}" = "$FEDEO_S3_CONFIG_SHA256"
|
||||
exec /usr/bin/weed "$@"
|
||||
31
scripts/storage/sync-sdk-lock.mjs
Normal file
31
scripts/storage/sync-sdk-lock.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Preserve the backend's exact transitive SDK graph; top-level version equality alone is insufficient.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const backend = JSON.parse(readFileSync(new URL('../../backend/package-lock.json', import.meta.url)));
|
||||
const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)));
|
||||
const packages = { '': { name: manifest.name, version: manifest.version, dependencies: manifest.dependencies, engines: manifest.engines } };
|
||||
function resolve(from, dependency) {
|
||||
const parts = from.split('/').filter(Boolean);
|
||||
while (true) {
|
||||
const candidate = [...parts, 'node_modules', dependency].join('/');
|
||||
if (backend.packages[candidate]) return candidate;
|
||||
if (!parts.length) return null;
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
const queue = Object.keys(manifest.dependencies).map(name => resolve('', name));
|
||||
while (queue.length) {
|
||||
const path = queue.pop();
|
||||
if (!path) throw new Error('Missing dependency in backend lockfile');
|
||||
if (packages[path]) continue;
|
||||
const entry = { ...backend.packages[path] };
|
||||
delete entry.dev;
|
||||
packages[path] = entry;
|
||||
for (const name of Object.keys(entry.dependencies || {})) queue.push(resolve(path, name));
|
||||
for (const name of Object.keys(entry.optionalDependencies || {})) {
|
||||
const resolved = resolve(path, name);
|
||||
if (resolved) queue.push(resolved);
|
||||
}
|
||||
}
|
||||
writeFileSync(new URL('./package-lock.json', import.meta.url), JSON.stringify({ name: manifest.name, version: manifest.version,
|
||||
lockfileVersion: 3, requires: true, packages: Object.fromEntries(Object.entries(packages).sort(([a], [b]) => a.localeCompare(b))) }, null, 2) + '\n');
|
||||
console.log(`Copied ${Object.keys(packages).length - 1} locked backend dependencies.`);
|
||||
6
scripts/storage/tests/Dockerfile.minio
Normal file
6
scripts/storage/tests/Dockerfile.minio
Normal file
@@ -0,0 +1,6 @@
|
||||
# Test fixture only. The pilot bundle uses the instance's already installed MinIO.
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32
|
||||
RUN wget -q -O /usr/local/bin/minio https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-amd64.RELEASE.2025-09-07T16-13-09Z \
|
||||
&& echo '7c5bd8512c6e966455b1d198209358b2d191c77a83ab377c4073281065fb855f /usr/local/bin/minio' | sha256sum -c - \
|
||||
&& chmod 755 /usr/local/bin/minio
|
||||
ENTRYPOINT ["minio"]
|
||||
135
scripts/storage/tests/host_integration.py
Normal file
135
scripts/storage/tests/host_integration.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real Docker host-state test with synthetic services, never a real FEDEO instance."""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("storage_migration", ROOT / "selfhost-storage-migrate.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
def docker(*args):
|
||||
return subprocess.check_output(["docker", *map(str, args)], text=True, stderr=subprocess.PIPE).strip()
|
||||
|
||||
|
||||
def fixture():
|
||||
root = Path(tempfile.mkdtemp(prefix="fedeo-host-migration-"))
|
||||
project = "fedeo-pilot-" + uuid.uuid4().hex[:8]
|
||||
(root / "minio").mkdir()
|
||||
(root / "postgres").mkdir()
|
||||
(root / ".env").write_text("# synthetic test only\n")
|
||||
secret = "test-with-$-interpolation"
|
||||
env = {"S3_ENDPOINT": "http://minio:9000", "S3_REGION": "eu-central-1", "S3_BUCKET": "fedeo",
|
||||
"S3_ACCESS_KEY": "test-user", "S3_SECRET_KEY": secret, "FEDEO_VERSION": "3.3.0",
|
||||
"DATABASE_URL": "postgres://fedeo:test-db@db:5432/fedeo", "FEDEO_FILE_BACKEND": "s3"}
|
||||
idle = {"image": "node:22-alpine", "command": ["node", "-e", "setInterval(()=>{},1000)"],
|
||||
"restart": "unless-stopped", "networks": ["internal"],
|
||||
"healthcheck": {"test": ["CMD", "node", "-e", "process.exit(0)"], "interval": "1s"}}
|
||||
import copy
|
||||
services = {n: copy.deepcopy(idle) for n in ("backend", "frontend", "traefik", "node-exporter", "createbuckets")}
|
||||
services["createbuckets"].update(command=["node", "-e", "process.exit(0)"], restart="no")
|
||||
services["backend"]["environment"] = env
|
||||
services["backend"]["image"] = "fedeo-storage-tools:pilot"
|
||||
services["backend"]["entrypoint"] = ["node"]
|
||||
services["backend"]["command"] = ["-e", "setInterval(()=>{},1000)"]
|
||||
services["backend"]["depends_on"] = {"minio": {"condition": "service_healthy"}, "db": {"condition": "service_healthy"},
|
||||
"createbuckets": {"condition": "service_completed_successfully"}}
|
||||
services["minio"] = {"image": "fedeo-minio-fixture:2025-09-07", "restart": "unless-stopped",
|
||||
"command": ["server", "/data", "--console-address", ":9001"], "networks": ["internal"],
|
||||
"volumes": [str(root / "minio") + ":/data"],
|
||||
"environment": {"MINIO_ROOT_USER": "test-user", "MINIO_ROOT_PASSWORD": secret},
|
||||
"healthcheck": {"test": ["CMD", "node", "-e", "fetch('http://127.0.0.1:9000/minio/health/live').then(r=>process.exit(r.ok?0:1))"], "interval": "1s"}}
|
||||
services["db"] = {"image": "postgres:16-alpine", "restart": "unless-stopped", "networks": ["internal"],
|
||||
"volumes": [str(root / "postgres") + ":/var/lib/postgresql/data"],
|
||||
"environment": {"POSTGRES_USER": "fedeo", "POSTGRES_PASSWORD": "test-db", "POSTGRES_DB": "fedeo"},
|
||||
"healthcheck": {"test": ["CMD", "pg_isready", "-U", "fedeo"], "interval": "1s"}}
|
||||
config = {"name": project, "services": services, "networks": {"internal": {"name": project + "-internal"}}}
|
||||
(root / "docker-compose.yml").write_text(json.dumps(m.literal(config)))
|
||||
return root, project
|
||||
|
||||
|
||||
def seed(root, project, hidden=False):
|
||||
code = """import { client, call } from './s3.mjs';
|
||||
const s=client({endpoint:'http://minio:9000',region:'eu-central-1',credentials:{accessKeyId:'test-user',secretAccessKey:'test-with-$-interpolation'}});
|
||||
await call(s,'CreateBucket',{Bucket:'fedeo'});
|
||||
await call(s,'PutObject',{Bucket:'fedeo',Key:'tenant/filesbyid/1/ä + % #.pdf',Body:Buffer.from('fixture'),ContentType:'application/pdf'});"""
|
||||
if hidden:
|
||||
code += "for(const Key of ['hidden','hidden/child']) await call(s,'PutObject',{Bucket:'fedeo',Key,Body:'fixture'});"
|
||||
code += "s.destroy();"
|
||||
docker("run", "--rm", "--pull", "never", "--network", project + "-internal", "--entrypoint", "node", "fedeo-storage-tools:pilot", "--input-type=module", "-e", code)
|
||||
|
||||
|
||||
def run(case):
|
||||
root, project = fixture()
|
||||
base = ["compose", "-p", project, "-f", str(root / "docker-compose.yml")]
|
||||
try:
|
||||
docker(*base, "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "90")
|
||||
seed(root, project, hidden=case == "hidden-prefix")
|
||||
migration = m.Migration(root, "fedeo-storage-tools:pilot")
|
||||
with migration.engine.lock():
|
||||
if case == "hidden-prefix":
|
||||
try:
|
||||
migration.plan()
|
||||
raise AssertionError("hidden key conflict must block planning")
|
||||
except m.Error as error:
|
||||
assert "konflikt" in str(error)
|
||||
assert not (root / ".updates/status.json").exists()
|
||||
print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True)
|
||||
return
|
||||
migration.plan()
|
||||
assert migration.state["count"] == 1
|
||||
migration.migrate(True)
|
||||
assert migration.state["phase"] == "verified"
|
||||
assert m.u.read_json(root / ".updates/status.json")["recoveryRequired"]
|
||||
# Simulate restart by reconstructing the process and persisted state.
|
||||
migration = m.Migration(root)
|
||||
if case == "rollback":
|
||||
migration.rollback()
|
||||
assert migration.state["phase"] == "rolled_back"
|
||||
assert not m.u.read_json(root / ".updates/status.json").get("recoveryRequired")
|
||||
assert migration.inspect(migration.state["containers"]["minio"]["id"])["State"]["Running"]
|
||||
else:
|
||||
migration.activate()
|
||||
assert migration.state["phase"] == "complete" and migration.state["writesReleased"]
|
||||
assert migration.state["restoreVerifiedAt"]
|
||||
old = migration.inspect(migration.state["containers"]["minio"]["id"])
|
||||
assert not old["State"]["Running"] and old["HostConfig"]["RestartPolicy"]["Name"] == "no"
|
||||
assert migration.state["network"] not in old["NetworkSettings"]["Networks"]
|
||||
try:
|
||||
migration.rollback()
|
||||
raise AssertionError("rollback after writes must fail")
|
||||
except m.Error:
|
||||
pass
|
||||
print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True)
|
||||
except BaseException:
|
||||
print("Failure evidence: " + str(root), flush=True)
|
||||
log = root / ".updates/storage-migration/commands.log"
|
||||
if log.exists():
|
||||
print(log.read_text()[-8000:], flush=True)
|
||||
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project,
|
||||
"--filter", "label=com.docker.compose.service=objectstore").split()
|
||||
for cid in ids:
|
||||
logs = subprocess.run(["docker", "logs", "--tail", "60", cid], capture_output=True, text=True)
|
||||
print(logs.stdout + logs.stderr, flush=True)
|
||||
raise
|
||||
finally:
|
||||
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
||||
if ids:
|
||||
docker("rm", "-f", *ids)
|
||||
remaining = docker("ps", "-aq", "--filter", "label=io.fedeo.storage-job=" + str(root / ".updates/storage-migration")).split()
|
||||
if remaining:
|
||||
docker("rm", "-f", *remaining)
|
||||
docker("network", "rm", project + "-internal")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for case in (sys.argv[1:] or ["hidden-prefix", "rollback", "activate"]):
|
||||
assert case in ("hidden-prefix", "rollback", "activate")
|
||||
run(case)
|
||||
112
scripts/storage/tests/integration.mjs
Normal file
112
scripts/storage/tests/integration.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
// Explicit integration run; never discovers or connects to an existing FEDEO stack.
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { client, call, contract, inventory, transfer, verifyManifest, MAX_OBJECT_BYTES } from '../s3.mjs';
|
||||
|
||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
const spec = JSON.parse(await readFile(new URL('../seaweedfs.json', import.meta.url), 'utf8'));
|
||||
const root = await mkdtemp(join(tmpdir(), 'fedeo-storage-contract-'));
|
||||
const prefix = `fedeo-contract-${randomUUID().slice(0, 8)}`;
|
||||
const containers = [];
|
||||
const Bucket = 'fedeo';
|
||||
const credentials = { accessKeyId: 'fedeo-test-user', secretAccessKey: 'fedeo-test-secret-only' };
|
||||
const adminCredentials = { accessKeyId: 'fedeo-test-admin', secretAccessKey: 'fedeo-test-admin-secret' };
|
||||
process.env.AWS_REQUEST_CHECKSUM_CALCULATION = 'WHEN_REQUIRED';
|
||||
const config = endpoint => ({ endpoint, region: 'eu-central-1', credentials });
|
||||
let source, target, admin;
|
||||
function run(name, ...args) { containers.push(name); return docker('run', '-d', '--pull', 'never', '--name', name, ...args); }
|
||||
function endpoint(name) { return 'http://' + docker('port', name, '9000/tcp'); }
|
||||
async function ready(s3) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
try { await call(s3, 'ListBuckets', {}); return; }
|
||||
catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
|
||||
}
|
||||
throw new Error('S3 did not become ready');
|
||||
}
|
||||
async function weed(name, data) {
|
||||
await mkdir(join(data, 'master'), { recursive: true });
|
||||
run(name, '-p', '127.0.0.1::9000', '-v', `${data}:/data`, '-v', `${root}/config:/etc/fedeo:ro`,
|
||||
'-e', 'FEDEO_S3_CONFIG_SHA256=' + createHash('sha256').update(await readFile(join(root, 'config/s3.json'))).digest('hex'),
|
||||
'--entrypoint', spec.entrypoint[0], spec.image, ...spec.entrypoint.slice(1), ...spec.command);
|
||||
return endpoint(name);
|
||||
}
|
||||
try {
|
||||
await mkdir(join(root, 'config'));
|
||||
await writeFile(join(root, 'config/start-seaweedfs.sh'), await readFile(new URL('../start-seaweedfs.sh', import.meta.url)));
|
||||
assert.throws(() => docker('run', '--rm', '--pull', 'never', '-v', `${root}/config:/etc/fedeo:ro`,
|
||||
'--entrypoint', spec.entrypoint[0], spec.image, ...spec.entrypoint.slice(1), ...spec.command));
|
||||
await writeFile(join(root, 'config/s3.json'), JSON.stringify({ identities: [
|
||||
{ name: 'admin', credentials: [adminCredentials], actions: ['Admin', 'Read', 'Write', 'List', 'Tagging'] },
|
||||
{ name: 'fedeo', credentials: [credentials], actions: [`Read:${Bucket}`, `Write:${Bucket}`, `List:${Bucket}`] }
|
||||
] }));
|
||||
// Seaweed's credential JSON uses accessKey/secretKey rather than SDK names.
|
||||
const auth = JSON.parse(await readFile(join(root, 'config/s3.json'), 'utf8'));
|
||||
for (const identity of auth.identities) identity.credentials = identity.credentials.map(c => ({ accessKey: c.accessKeyId, secretKey: c.secretAccessKey }));
|
||||
await writeFile(join(root, 'config/s3.json'), JSON.stringify(auth));
|
||||
run(`${prefix}-source`, '-p', '127.0.0.1::9000', '-e', `MINIO_ROOT_USER=${credentials.accessKeyId}`,
|
||||
'-e', `MINIO_ROOT_PASSWORD=${credentials.secretAccessKey}`, process.env.FEDEO_MINIO_TEST_IMAGE || 'fedeo-minio-fixture:2025-09-07',
|
||||
'server', '/data', '--console-address', ':9001');
|
||||
const sourceConfig = config(endpoint(`${prefix}-source`));
|
||||
source = client(sourceConfig);
|
||||
await ready(source);
|
||||
await call(source, 'CreateBucket', { Bucket });
|
||||
await contract(source, sourceConfig, Bucket);
|
||||
const targetConfig = config(await weed(`${prefix}-target`, join(root, 'target')));
|
||||
target = client(targetConfig);
|
||||
admin = client({ ...targetConfig, credentials: adminCredentials });
|
||||
await ready(admin);
|
||||
await call(admin, 'CreateBucket', { Bucket });
|
||||
await call(admin, 'CreateBucket', { Bucket: 'foreign-private' });
|
||||
await contract(target, targetConfig, Bucket, { foreignBucket: 'foreign-private' });
|
||||
// Confirm unauthenticated management ports are not reachable through the container IP.
|
||||
const ip = JSON.parse(docker('inspect', `${prefix}-target`))[0].NetworkSettings.Networks.bridge.IPAddress;
|
||||
for (const port of [9333, 19333, 8888, 18888, 8080, 18080, 8181, 9101]) {
|
||||
assert.throws(() => docker('exec', `${prefix}-source`, 'node', '-e',
|
||||
`const s=require('node:net').connect(${port},${JSON.stringify(ip)});s.on('connect',()=>process.exit(0));s.on('error',()=>process.exit(1));s.setTimeout(1500,()=>process.exit(1))`));
|
||||
}
|
||||
for (let i = 0; i < 1003; i++) await call(source, 'PutObject', { Bucket, Key: `tenant/filesbyid/${i}/ä + % #.txt`,
|
||||
Body: Buffer.from(`fixture-${i}`), ContentType: 'text/plain', Metadata: { custom: 'preserved' } });
|
||||
await call(source, 'PutObject', { Bucket, Key: 'empty', Body: Buffer.alloc(0), ContentType: 'application/octet-stream' });
|
||||
await call(source, 'PutObject', { Bucket, Key: 'large.zip', ContentLength: MAX_OBJECT_BYTES,
|
||||
Body: Readable.from((function* () { for (let i = 0; i < 256; i++) yield Buffer.alloc(1024 * 1024, i); })()),
|
||||
ContentType: 'application/zip', ContentDisposition: 'attachment; filename="large.zip"',
|
||||
CacheControl: 'private', ContentLanguage: 'de', Expires: new Date('2030-01-01T00:00:00Z') });
|
||||
const before = await inventory(source, Bucket);
|
||||
assert.equal(before.count, 1005);
|
||||
await call(source, 'PutObject', { Bucket, Key: 'unsupported-marker/', Body: '' });
|
||||
await assert.rejects(inventory(source, Bucket), /Schlüssel/);
|
||||
await call(source, 'DeleteObject', { Bucket, Key: 'unsupported-marker/' });
|
||||
const start = Date.now();
|
||||
const report = await transfer(source, target, Bucket, join(root, 'work'));
|
||||
await call(target, 'PutObject', { Bucket, Key: 'empty', Body: 'corrupt' });
|
||||
await assert.rejects(transfer(source, target, Bucket, join(root, 'work'), { verifyOnly: true }), /Integritätsfehler/);
|
||||
await transfer(source, target, Bucket, join(root, 'work')); // Resume repairs and re-reads, never trusts journal alone.
|
||||
await transfer(source, target, Bucket, join(root, 'work'), { verifyOnly: true });
|
||||
docker('stop', '-t', '60', `${prefix}-target`);
|
||||
docker('run', '--rm', '--pull', 'never', '-v', `${root}:/test`, '--entrypoint', '/bin/sh', spec.image,
|
||||
'-ec', 'tar -cpf /test/backup.tar -C /test/target .; mkdir /test/restored; tar -xpf /test/backup.tar -C /test/restored');
|
||||
const restoredConfig = config(await weed(`${prefix}-restored`, join(root, 'restored')));
|
||||
const restored = client(restoredConfig);
|
||||
const restoredAdmin = client({ ...restoredConfig, credentials: adminCredentials });
|
||||
try {
|
||||
await ready(restoredAdmin);
|
||||
await verifyManifest(restored, Bucket, report);
|
||||
await contract(restored, restoredConfig, Bucket, { foreignBucket: 'foreign-private' });
|
||||
} finally { restored.destroy(); restoredAdmin.destroy(); }
|
||||
console.log(JSON.stringify({ result: 'passed', objects: report.count, bytes: report.bytes,
|
||||
seconds: (Date.now() - start) / 1000, seaweed: spec.image, source: docker('image', 'inspect',
|
||||
process.env.FEDEO_MINIO_TEST_IMAGE || 'fedeo-minio-fixture:2025-09-07', '--format', '{{.Id}}'),
|
||||
evidence: root }, null, 2));
|
||||
} catch (error) {
|
||||
for (const name of containers) { try { console.error(docker('logs', '--tail', '12', name)); } catch {} }
|
||||
throw error;
|
||||
} finally {
|
||||
source?.destroy(); target?.destroy(); admin?.destroy();
|
||||
for (const name of containers.reverse()) { try { docker('rm', '-f', name); } catch {} }
|
||||
// Preserve the isolated backup and report for inspection; no production paths are touched.
|
||||
}
|
||||
37
scripts/storage/tests/keys.test.mjs
Normal file
37
scripts/storage/tests/keys.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateKeys, MAX_OBJECT_BYTES, metadata } from '../s3.mjs';
|
||||
import { cleanupProbe, atomicJson } from '../s3.mjs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('accepts FEDEO keys, special characters, empty objects and size boundary', () => {
|
||||
validateKeys([{ Key: 'tenant/filesbyid/123/ä + %#.pdf', Size: 0 }, { Key: 'export.zip', Size: MAX_OBJECT_BYTES }]);
|
||||
});
|
||||
test('blocks lossy paths and file/directory collisions regardless of ordering', () => {
|
||||
for (const Key of ['/a', 'a/', 'a//b', 'a/../b', './a', 'a\\b', 'a\nb'])
|
||||
assert.throws(() => validateKeys([{ Key, Size: 1 }]));
|
||||
assert.throws(() => validateKeys([{ Key: 'a/b', Size: 1 }, { Key: 'a', Size: 1 }]));
|
||||
assert.throws(() => validateKeys([{ Key: 'a', Size: MAX_OBJECT_BYTES + 1 }]));
|
||||
});
|
||||
test('rejects encryption and nonportable attributes rather than dropping them', () => {
|
||||
for (const value of [{ ServerSideEncryption: 'AES256' }, { ObjectLockMode: 'GOVERNANCE' },
|
||||
{ StorageClass: 'GLACIER' }, { WebsiteRedirectLocation: '/other' }]) assert.throws(() => metadata(value));
|
||||
assert.deepEqual(metadata({ ContentType: 'application/pdf', Metadata: { custom: 'value' } }),
|
||||
{ ContentType: 'application/pdf', Metadata: { custom: 'value' } });
|
||||
});
|
||||
|
||||
test('interrupted contract cleanup deletes only its two journalled probe objects', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'fedeo-probe-test-'));
|
||||
try {
|
||||
const path = join(directory, 'probe.json');
|
||||
const key = 'fedeo-contract-11111111-1111-1111-1111-111111111111/test';
|
||||
await atomicJson(path, { key });
|
||||
const inputs = [];
|
||||
await cleanupProbe({ send: async command => { inputs.push(command.input); return {}; } }, 'bucket', path);
|
||||
assert.deepEqual(inputs, [{ Bucket: 'bucket', Key: key }, { Bucket: 'bucket', Key: key + '.stream' }]);
|
||||
await atomicJson(path, { key: 'real-business-file.pdf' });
|
||||
await assert.rejects(cleanupProbe({ send: async () => { throw new Error('must not send'); } }, 'bucket', path), /Probejournal/);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
125
scripts/storage/tests/test_host.py
Normal file
125
scripts/storage/tests/test_host.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
spec = importlib.util.spec_from_file_location("storage_migration", Path(__file__).resolve().parents[2] / "selfhost-storage-migrate.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
|
||||
class HostSafetyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
for name in ("minio", "postgres"):
|
||||
(self.root / name).mkdir()
|
||||
self.config = {"services": {n: {} for n in ("db", "minio", "createbuckets", "backend", "frontend", "traefik", "node-exporter")}}
|
||||
for name, path, target in (("minio", "minio", "/data"), ("db", "postgres", "/var/lib/postgresql/data")):
|
||||
self.config["services"][name]["volumes"] = [{"type": "bind", "source": str(self.root / path), "target": target}]
|
||||
self.config["services"]["minio"].update(networks={"internal": {}}, environment={"MINIO_ROOT_USER": "user", "MINIO_ROOT_PASSWORD": "secret"})
|
||||
self.config["services"]["db"]["environment"] = {"POSTGRES_USER": "u", "POSTGRES_PASSWORD": "p", "POSTGRES_DB": "db"}
|
||||
self.config["services"]["backend"]["environment"] = {"DATABASE_URL": "postgres://u:p@db:5432/db", "S3_ENDPOINT": "http://minio:9000",
|
||||
"S3_REGION": "eu-central-1", "S3_BUCKET": "fedeo", "S3_ACCESS_KEY": "user", "S3_SECRET_KEY": "secret"}
|
||||
|
||||
def test_rejects_external_or_unbacked_storage_and_secret_drift(self):
|
||||
m.validate_config(self.config, self.root)
|
||||
mutations = [lambda c: c["services"]["minio"].update(ports=["9000:9000"]),
|
||||
lambda c: c["services"]["backend"]["environment"].update(S3_ENDPOINT="https://external.example"),
|
||||
lambda c: c["services"]["backend"]["environment"].update(S3_SECRET_KEY="different"),
|
||||
lambda c: c["services"]["minio"]["volumes"][0].update(source="/elsewhere"),
|
||||
lambda c: c["services"].update(unknown={}),
|
||||
lambda c: c["services"]["backend"]["environment"].update(DATABASE_URL="postgres://u:p@db:5432/other")]
|
||||
for mutate in mutations:
|
||||
config = copy.deepcopy(self.config)
|
||||
mutate(config)
|
||||
with self.assertRaises(m.Error):
|
||||
m.validate_config(config, self.root)
|
||||
|
||||
def test_rejects_symlink_source(self):
|
||||
(self.root / "minio").rmdir()
|
||||
(self.root / "minio").symlink_to(self.root / "postgres")
|
||||
with self.assertRaises(m.Error):
|
||||
m.validate_config(self.config, self.root)
|
||||
|
||||
def test_cold_index_detects_objects_hidden_by_minio_listing(self):
|
||||
source = self.root / "minio"
|
||||
(source / ".minio.sys").mkdir()
|
||||
(source / ".minio.sys/format.json").write_text(json.dumps({"format": "xl-single", "xl": {"version": "3"}}))
|
||||
(source / "fedeo/a").mkdir(parents=True)
|
||||
(source / "fedeo/a/xl.meta").write_bytes(b"opaque")
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}]}, exact=True)
|
||||
(source / "fedeo/a/b").mkdir()
|
||||
(source / "fedeo/a/b/xl.meta").write_bytes(b"hidden-from-S3-LIST")
|
||||
with self.assertRaisesRegex(m.Error, "Konflikt|konflikt"):
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}]}, exact=True)
|
||||
(source / "fedeo/a/b/xl.meta").unlink()
|
||||
with self.assertRaisesRegex(m.Error, "weichen ab"):
|
||||
m.validate_minio_listing(self.root, "fedeo", {"objects": [{"Key": "a"}, {"Key": "missing"}]}, exact=True)
|
||||
|
||||
def test_update_and_migration_share_exclusive_lock(self):
|
||||
a, b = m.Migration(self.root), m.Migration(self.root)
|
||||
with a.engine.lock():
|
||||
with self.assertRaises(m.Error):
|
||||
with b.engine.lock():
|
||||
self.fail("lock must be exclusive")
|
||||
|
||||
def test_failed_freeze_keeps_persistent_recovery_guard(self):
|
||||
migration = m.Migration(self.root)
|
||||
migration.state = {"startedAt": "test", "writesReleased": False, "containers": {"backend": {"id": "backend"}}}
|
||||
def fail(*args, **kwargs):
|
||||
state = m.u.read_json(migration.engine.home / "status.json")
|
||||
self.assertTrue(state["recoveryRequired"])
|
||||
self.assertEqual(m.u.read_json(migration.home / "state.json")["phase"], "freezing")
|
||||
raise m.Error("power loss")
|
||||
migration.docker = fail
|
||||
with self.assertRaises(m.Error):
|
||||
migration.freeze()
|
||||
self.assertFalse(m.u.read_json(migration.home / "state.json")["writesReleased"])
|
||||
|
||||
def test_backup_corruption_prevents_progress(self):
|
||||
migration = m.Migration(self.root)
|
||||
backup = migration.home / "backup"
|
||||
backup.mkdir()
|
||||
(backup / "files.tar").write_bytes(b"valid")
|
||||
m.u.write_json(backup / "complete.json", {"sha256": {"files.tar": m.sha_file(backup / "files.tar")}})
|
||||
migration.check_backup()
|
||||
(backup / "files.tar").write_bytes(b"corrupt")
|
||||
with self.assertRaises(m.Error):
|
||||
migration.check_backup()
|
||||
|
||||
def test_start_failure_is_already_past_rollback_boundary(self):
|
||||
migration = m.Migration(self.root)
|
||||
migration.state = {"startedAt": "test", "phase": "verified", "network": "internal", "writesReleased": False,
|
||||
"containers": {"minio": {"id": "old"}}}
|
||||
for method in ("assert_frozen", "verify_original", "check_backup", "cleanup_helper", "helper", "docker", "compose"):
|
||||
setattr(migration, method, Mock())
|
||||
migration.inspect = lambda _: {"NetworkSettings": {"Networks": {}}}
|
||||
migration.restore_test = Mock()
|
||||
migration.runner = Mock()
|
||||
(migration.home / "target.json").write_text('{}')
|
||||
def fail_start():
|
||||
self.assertTrue(m.u.read_json(migration.home / "state.json")["writesReleased"])
|
||||
raise m.Error("backend did not start")
|
||||
migration.start_application = fail_start
|
||||
from unittest.mock import patch
|
||||
(migration.home / "credentials.json").write_text('{"bucket":"fedeo"}')
|
||||
with patch.object(m, "validate_minio_listing"):
|
||||
(migration.home / "copy").mkdir()
|
||||
(migration.home / "copy/inventory.json").write_text('{}')
|
||||
with self.assertRaises(m.Error):
|
||||
migration.activate()
|
||||
with self.assertRaises(m.Error):
|
||||
migration.rollback()
|
||||
|
||||
def test_compose_roundtrip_preserves_dollar_signs(self):
|
||||
value = {"environment": {"KEY": "literal-$-$$-${NOT_AN_ENV_VAR}"}, "command": ["echo $VALUE"]}
|
||||
self.assertEqual(m.resolved(m.literal(value)), value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user