173 lines
7.5 KiB
YAML
173 lines
7.5 KiB
YAML
# services/cassandra/migration-jobs/artifact-copy-veles-to-cassandra-job.yaml
|
|
# Suspended by default. Run only after scaling the Veles artifact exporter to 1.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: cassandra-artifact-copy-from-veles-2
|
|
namespace: cassandra
|
|
spec:
|
|
suspend: true
|
|
backoffLimit: 0
|
|
activeDeadlineSeconds: 43200
|
|
ttlSecondsAfterFinished: 86400
|
|
template:
|
|
spec:
|
|
serviceAccountName: cassandra-artifact-migration
|
|
automountServiceAccountToken: false
|
|
restartPolicy: Never
|
|
nodeSelector:
|
|
cassandra.bstein.dev/node-pool: oceanus
|
|
tolerations:
|
|
- key: veles.bstein.dev/simulation
|
|
operator: Equal
|
|
value: "true"
|
|
effect: NoSchedule
|
|
containers:
|
|
- name: copy-artifacts
|
|
image: python:3.12-alpine
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: SOURCE_URL
|
|
value: http://veles-artifact-export-for-cassandra.veles.svc.cluster.local:8765
|
|
- name: DEST_ROOT
|
|
value: /dest
|
|
- name: ALLOW_NONEMPTY_DEST
|
|
value: "0"
|
|
- name: RESET_DESTINATION
|
|
value: "1"
|
|
command: ["python", "-c"]
|
|
args:
|
|
- |
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tarfile
|
|
import time
|
|
import urllib.request
|
|
|
|
SOURCE_URL = os.environ["SOURCE_URL"].rstrip("/")
|
|
DEST = Path(os.environ.get("DEST_ROOT", "/dest")).resolve()
|
|
ALLOW_NONEMPTY = os.environ.get("ALLOW_NONEMPTY_DEST", "") in {"1", "true", "yes"}
|
|
RESET_DESTINATION = os.environ.get("RESET_DESTINATION", "") in {"1", "true", "yes"}
|
|
EXCLUDE_TOP = {"lost+found", ".cassandra-artifact-migration"}
|
|
REPORT_DIR = DEST / ".cassandra-artifact-migration"
|
|
|
|
def visible_entries() -> list[Path]:
|
|
return [entry for entry in DEST.iterdir() if entry.name not in EXCLUDE_TOP]
|
|
|
|
def assert_safe_destination() -> None:
|
|
DEST.mkdir(parents=True, exist_ok=True)
|
|
entries = visible_entries()
|
|
if entries and RESET_DESTINATION:
|
|
for entry in entries:
|
|
if entry.is_dir():
|
|
shutil.rmtree(entry)
|
|
else:
|
|
entry.unlink()
|
|
entries = visible_entries()
|
|
if entries and not ALLOW_NONEMPTY:
|
|
names = ", ".join(entry.name for entry in entries[:10])
|
|
raise SystemExit(f"destination is not empty; refusing to merge into existing data: {names}")
|
|
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
def fetch_manifest() -> dict[str, object]:
|
|
with urllib.request.urlopen(f"{SOURCE_URL}/manifest.json", timeout=120) as response:
|
|
return json.loads(response.read().decode())
|
|
|
|
def wait_for_manifest() -> dict[str, object]:
|
|
last_error = ""
|
|
for _attempt in range(90):
|
|
try:
|
|
return fetch_manifest()
|
|
except Exception as exc:
|
|
last_error = str(exc)
|
|
time.sleep(5)
|
|
raise SystemExit(f"source artifact exporter did not become ready: {last_error}")
|
|
|
|
def safe_extract() -> None:
|
|
root_text = str(DEST)
|
|
with urllib.request.urlopen(f"{SOURCE_URL}/archive.tar", timeout=600) as response:
|
|
with tarfile.open(fileobj=response, mode="r|*") as archive:
|
|
for member in archive:
|
|
target = (DEST / member.name).resolve()
|
|
if target != DEST and not str(target).startswith(root_text + os.sep):
|
|
raise SystemExit(f"unsafe archive path: {member.name}")
|
|
archive.extract(member, path=DEST)
|
|
|
|
def digest_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
def destination_manifest() -> dict[str, object]:
|
|
files = []
|
|
total_bytes = 0
|
|
for path in sorted(DEST.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
rel = path.relative_to(DEST).as_posix()
|
|
if rel.split("/", 1)[0] in EXCLUDE_TOP:
|
|
continue
|
|
size = path.stat().st_size
|
|
total_bytes += size
|
|
files.append({"path": rel, "size": size, "sha256": digest_file(path)})
|
|
manifest_text = "\n".join(
|
|
f"{item['sha256']} {item['size']} {item['path']}" for item in files
|
|
)
|
|
return {
|
|
"schema": "cassandra.artifact_manifest.v1",
|
|
"source": "cassandra-artifacts",
|
|
"file_count": len(files),
|
|
"total_bytes": total_bytes,
|
|
"manifest_sha256": hashlib.sha256(manifest_text.encode()).hexdigest(),
|
|
"files": files,
|
|
}
|
|
|
|
def comparable(manifest: dict[str, object]) -> dict[str, object]:
|
|
return {
|
|
"file_count": manifest.get("file_count"),
|
|
"total_bytes": manifest.get("total_bytes"),
|
|
"manifest_sha256": manifest.get("manifest_sha256"),
|
|
"files": manifest.get("files"),
|
|
}
|
|
|
|
assert_safe_destination()
|
|
source = wait_for_manifest()
|
|
(REPORT_DIR / "source_manifest.json").write_text(json.dumps(source, indent=2, sort_keys=True))
|
|
safe_extract()
|
|
dest = destination_manifest()
|
|
(REPORT_DIR / "destination_manifest.json").write_text(json.dumps(dest, indent=2, sort_keys=True))
|
|
summary = {
|
|
"source_file_count": source.get("file_count"),
|
|
"destination_file_count": dest.get("file_count"),
|
|
"source_total_bytes": source.get("total_bytes"),
|
|
"destination_total_bytes": dest.get("total_bytes"),
|
|
"source_manifest_sha256": source.get("manifest_sha256"),
|
|
"destination_manifest_sha256": dest.get("manifest_sha256"),
|
|
"verified": comparable(source) == comparable(dest),
|
|
}
|
|
(REPORT_DIR / "copy_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True))
|
|
if not summary["verified"]:
|
|
raise SystemExit(f"artifact copy verification failed: {summary}")
|
|
print(json.dumps(summary, sort_keys=True), flush=True)
|
|
resources:
|
|
requests:
|
|
cpu: 500m
|
|
memory: 512Mi
|
|
limits:
|
|
cpu: "2"
|
|
memory: 2Gi
|
|
volumeMounts:
|
|
- name: artifacts
|
|
mountPath: /dest
|
|
volumes:
|
|
- name: artifacts
|
|
persistentVolumeClaim:
|
|
claimName: cassandra-artifacts
|