From 24c444723caa6a292d86201b2f728695bf6f28a4 Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Wed, 26 Aug 2026 15:21:17 +0800 Subject: [PATCH 1/2] add mono wipe job --- .dockerignore | 4 +- .github/workflows/mono-reset-deploy.yml | 54 ++++ .github/workflows/release.yml | 4 + scripts/mono-reset/Dockerfile | 29 ++ scripts/mono-reset/README.md | 71 +++++ scripts/mono-reset/reset_job.py | 346 ++++++++++++++++++++++++ scripts/mono-reset/wipe_sql.py | 113 ++++++++ 7 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mono-reset-deploy.yml create mode 100644 scripts/mono-reset/Dockerfile create mode 100644 scripts/mono-reset/README.md create mode 100644 scripts/mono-reset/reset_job.py create mode 100644 scripts/mono-reset/wipe_sql.py diff --git a/.dockerignore b/.dockerignore index 8f201ba30..0125acc33 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,10 +14,12 @@ tools/** docs # Exclude scripts by default (large / unused in most images), but keep the -# paths required by scripts/init_mega/Dockerfile (mega-init image). +# paths required by scripts/init_mega and scripts/mono-reset Dockerfiles. scripts/* !scripts/init_mega/ !scripts/init_mega/** +!scripts/mono-reset/ +!scripts/mono-reset/** !scripts/import-buck2-deps/ !scripts/import-buck2-deps/** diff --git a/.github/workflows/mono-reset-deploy.yml b/.github/workflows/mono-reset-deploy.yml new file mode 100644 index 000000000..608614767 --- /dev/null +++ b/.github/workflows/mono-reset-deploy.yml @@ -0,0 +1,54 @@ +name: Mono Reset deploy + +on: + push: + branches: + - main + paths: + - ".github/workflows/mono-reset-deploy.yml" + - "scripts/mono-reset/**" + - "scripts/init_mega/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + REPOSITORY: mega/mono-reset + IMAGE_TAG: latest + HARBOR_REGISTRY: registry.xuanwu.openatom.cn + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build, tag, and push docker image to Harbor + run: | + set -euo pipefail + + HARBOR_IMAGE_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" + IMAGE_TAG="${{ env.IMAGE_TAG }}" + SHORT_SHA="${GITHUB_SHA:0:7}" + + docker build \ + -f scripts/mono-reset/Dockerfile \ + -t "$HARBOR_IMAGE_BASE:$IMAGE_TAG" \ + -t "$HARBOR_IMAGE_BASE:$SHORT_SHA" \ + . + + docker push "$HARBOR_IMAGE_BASE:$IMAGE_TAG" + docker push "$HARBOR_IMAGE_BASE:$SHORT_SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a55313e3..33d5601d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,10 @@ jobs: context: scripts/crates-sync dockerfile: scripts/crates-sync/Dockerfile + - name: mono-reset + context: . + dockerfile: scripts/mono-reset/Dockerfile + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/scripts/mono-reset/Dockerfile b/scripts/mono-reset/Dockerfile new file mode 100644 index 000000000..cfbf3e315 --- /dev/null +++ b/scripts/mono-reset/Dockerfile @@ -0,0 +1,29 @@ +# mono-reset: wipe PG git/CL data (keep login), clear RustFS git/lfs, re-init mono. +# Build from repo root: +# docker build -f scripts/mono-reset/Dockerfile -t mega/mono-reset . +FROM python:3.12-slim-bookworm + +# Debian slim defaults to http://deb.debian.org. Through corporate proxies: +# - plain HTTP hangs +# - HTTPS works but MITM breaks apt's CA verify +# Switch to HTTPS and relax peer verify only for this apt fetch. +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false update -qq \ + && apt-get -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + postgresql-client \ + && curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc \ + && chmod +x /usr/local/bin/mc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +# reset_job imports wipe_sql and shells out to init_mega.py +COPY scripts/mono-reset/ scripts/mono-reset/ +COPY scripts/init_mega/ scripts/init_mega/ + +ENTRYPOINT ["python3", "-u", "scripts/mono-reset/reset_job.py"] diff --git a/scripts/mono-reset/README.md b/scripts/mono-reset/README.md new file mode 100644 index 000000000..13761e266 --- /dev/null +++ b/scripts/mono-reset/README.md @@ -0,0 +1,71 @@ +# mono-reset + +Destructive K8s Job: wipe monorepo/git data in Postgres + RustFS `git/`/`lfs/`, keep Campsite login, restart mono so `init_monorepo` runs, then sync `toolchains/buckal-bundles` via `init_mega.py`. + +**Default off.** Only enable when you intend to erase all git content. + +## What it keeps + +- Entire Campsite MySQL database (users / orgs / sessions) +- Postgres: `campsite_member_identity`, `user_approval_status`, `access_token`, `ssh_keys`, `gpg_key`, `cla_sign_status`, `vault`, `path_check_configs`, `seaql_migrations` + +## What it deletes + +- All other public Postgres tables (TRUNCATE … CASCADE), including bots (recreated by `bootstrap-init`) +- RustFS bucket prefixes `git/` and `lfs/` +- Does **not** touch freighter hostPath (crates-sync cache) + +After wipe, `third-party` is an empty root again (`.gitkeep` from `init_monorepo`). Re-run crates-sync if you need crates back. + +## Container image + +Dockerfile: [`Dockerfile`](Dockerfile) (python3 + git + postgresql-client + mc). + +- CI: `.github/workflows/mono-reset-deploy.yml` → `registry.xuanwu.openatom.cn/mega/mono-reset:` +- Local (from repo root): + +```bash +docker build -f scripts/mono-reset/Dockerfile -t mega/mono-reset:local . +``` + +## Terraform (onprem) + +In `envs/onprem/k3s-rust` (or sibling env). **Two values required** — `enable_mono_reset` +alone is not enough; plan fails unless confirm matches the namespace: + +```hcl +enable_mono_reset = true +mono_reset_confirm = "WIPE_GIT_DATA:mega-rust" # WIPE_GIT_DATA: +mono_reset_image = "registry.xuanwu.openatom.cn/mega/mono-reset:" +# mono_reset_args = ["--skip-buckal"] # optional +``` + +Then: + +```bash +terraform apply +kubectl -n mega-rust logs -f job/mono-reset +``` + +After success, set `enable_mono_reset = false` and clear `mono_reset_confirm` (and +apply) so a later unrelated apply does not recreate the Job. Changing +`mono_reset_image` replaces/re-runs the Job only while both switches stay set. + +While the Job scales `mono-engine` (and `orion-server`) to 0, git API is down; **mega-ui** and **campsite-api** stay up so login UI remains reachable. + +## Manual / debug flags + +```text +--dry-run Print actions only +--skip-s3 Skip RustFS wipe +--skip-buckal Skip init_mega buckal sync +--skip-scale Do not scale deployments (unsafe if mono is still writing) +--db-url URL Override MEGA_DATABASE__DB_URL +--base-url URL Mono HTTP base (default MONO_BASE_URL or http://mono-engine:8000) +``` + +## Requirements + +- Job ServiceAccount can get/patch `deployments` / `deployments/scale` for `mono-engine` and `orion-server` +- Env: `MEGA_DATABASE__DB_URL`, S3 `MEGA_OBJECT_STORAGE__S3__*`, `MEGA_INIT_BOOTSTRAP_SECRET` +- Mono must have `MEGA_MONOREPO__ADMIN` (or image config) for post-init admins diff --git a/scripts/mono-reset/reset_job.py b/scripts/mono-reset/reset_job.py new file mode 100644 index 000000000..254b6f978 --- /dev/null +++ b/scripts/mono-reset/reset_job.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""K8s Job entrypoint: wipe mono git/CL data (keep login), clear RustFS git/lfs, re-init. + +Order: + 1. Scale mono-engine (+ optional orion-server) to 0 + 2. TRUNCATE public PG tables except keep-list (MySQL untouched) + 3. mc rm bucket prefixes git/ and lfs/ + 4. Scale deployments back + 5. Wait for mono /api/v1/status (boot runs init_monorepo) + 6. Run scripts/init_mega/init_mega.py (buckal-bundles sync) unless --skip-buckal +""" + +from __future__ import annotations + +import argparse +import json +import os +import ssl +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +from wipe_sql import wipe_public_tables + +SCRIPT_DIR = Path(__file__).resolve().parent +INIT_MEGA_PY = SCRIPT_DIR.parent / "init_mega" / "init_mega.py" + +SA_TOKEN_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/token") +SA_CA_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") +SA_NS_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + + +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +def api_request(method: str, url: str, data=None, headers=None, timeout: int = 15): + if headers is None: + headers = {} + if "accept" not in headers: + headers["accept"] = "application/json" + req_data = None + if data is not None: + req_data = json.dumps(data).encode("utf-8") + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=req_data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + body = response.read().decode("utf-8") + if 200 <= response.status < 300: + return json.loads(body) if body else {} + raise RuntimeError(f"API {method} {url} -> {response.status}: {body}") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"API {method} {url} failed: HTTP {e.code}: {body}") from e + + +def wait_for_server(base_url: str, timeout: int = 300) -> None: + status_url = f"{base_url.rstrip('/')}/api/v1/status" + start = time.time() + print(f"Waiting for server at {status_url}...") + while time.time() - start < timeout: + try: + api_request("GET", status_url) + print("Server is ready.") + return + except Exception: + time.sleep(2) + raise RuntimeError(f"Server at {base_url} did not become ready within {timeout}s") + + +def k8s_session(): + host = env("KUBERNETES_SERVICE_HOST") + port = env("KUBERNETES_SERVICE_PORT", "443") + if not host or not SA_TOKEN_PATH.is_file(): + raise RuntimeError( + "In-cluster Kubernetes credentials not found; " + "Job must run with a ServiceAccount (or pass --skip-scale)." + ) + token = SA_TOKEN_PATH.read_text().strip() + ns = env("MONO_NAMESPACE") or ( + SA_NS_PATH.read_text().strip() if SA_NS_PATH.is_file() else "" + ) + if not ns: + raise RuntimeError("MONO_NAMESPACE unset and no serviceaccount namespace") + ctx = ssl.create_default_context(cafile=str(SA_CA_PATH)) if SA_CA_PATH.is_file() else None + return f"https://{host}:{port}", token, ns, ctx + + +def k8s_request(method: str, path: str, token: str, base: str, ctx, data=None): + url = f"{base}{path}" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + body = None + if data is not None: + body = json.dumps(data).encode("utf-8") + headers["Content-Type"] = "application/strategic-merge-patch+json" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + err = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"k8s {method} {path} -> HTTP {e.code}: {err}") from e + + +def get_replicas(base: str, token: str, ns: str, ctx, name: str) -> int: + path = f"/apis/apps/v1/namespaces/{ns}/deployments/{name}/scale" + scale = k8s_request("GET", path, token, base, ctx) + return int(scale.get("spec", {}).get("replicas", 0)) + + +def set_replicas(base: str, token: str, ns: str, ctx, name: str, replicas: int) -> None: + path = f"/apis/apps/v1/namespaces/{ns}/deployments/{name}/scale" + # Scale subresource accepts merge patch on spec.replicas + url = f"{base}{path}" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Content-Type": "application/merge-patch+json", + } + body = json.dumps({"spec": {"replicas": replicas}}).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers, method="PATCH") + try: + with urllib.request.urlopen(req, context=ctx, timeout=30) as resp: + resp.read() + except urllib.error.HTTPError as e: + err = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"scale {name} -> {replicas} failed: HTTP {e.code}: {err}") from e + print(f"Scaled deployment/{name} to {replicas}") + + +def wait_replicas(base: str, token: str, ns: str, ctx, name: str, want: int, timeout: int = 300): + path = f"/apis/apps/v1/namespaces/{ns}/deployments/{name}" + start = time.time() + while time.time() - start < timeout: + dep = k8s_request("GET", path, token, base, ctx) + status = dep.get("status", {}) + ready = int(status.get("readyReplicas") or 0) + replicas = int(status.get("replicas") or 0) + if want == 0 and replicas == 0: + print(f"deployment/{name} scaled to 0") + return + if want > 0 and ready >= want: + print(f"deployment/{name} readyReplicas={ready}") + return + time.sleep(2) + raise RuntimeError(f"Timed out waiting for deployment/{name} replicas={want}") + + +def scale_deployments( + names: list[str], + *, + to_zero: bool, + saved: dict[str, int] | None, + dry_run: bool, +) -> dict[str, int]: + base, token, ns, ctx = k8s_session() + result: dict[str, int] = dict(saved or {}) + for name in names: + if not name: + continue + if to_zero: + current = get_replicas(base, token, ns, ctx, name) + result[name] = current if current > 0 else result.get(name, 1) + print(f"deployment/{name} current replicas={current}") + if dry_run: + print(f"DRY-RUN: would scale {name} -> 0") + continue + set_replicas(base, token, ns, ctx, name, 0) + wait_replicas(base, token, ns, ctx, name, 0) + else: + want = result.get(name, 1) + if want < 1: + want = 1 + if dry_run: + print(f"DRY-RUN: would scale {name} -> {want}") + continue + set_replicas(base, token, ns, ctx, name, want) + wait_replicas(base, token, ns, ctx, name, want) + return result + + +def resolve_db_url(cli: str | None) -> str: + db_url = ( + (cli or "").strip() + or env("MEGA_DATABASE__DB_URL") + or env("DATABASE_URL") + ) + if not db_url: + raise RuntimeError("Missing --db-url / MEGA_DATABASE__DB_URL / DATABASE_URL") + return db_url + + +def wipe_s3(*, dry_run: bool) -> None: + endpoint = env("MEGA_OBJECT_STORAGE__S3__ENDPOINT_URL") or env("S3_ENDPOINT") + access = env("MEGA_OBJECT_STORAGE__S3__ACCESS_KEY_ID") or env("S3_ACCESS_KEY") + secret = env("MEGA_OBJECT_STORAGE__S3__SECRET_ACCESS_KEY") or env("S3_SECRET_KEY") + bucket = env("MEGA_OBJECT_STORAGE__S3__BUCKET") or env("S3_BUCKET") + if not all([endpoint, access, secret, bucket]): + raise RuntimeError( + "S3 wipe requires MEGA_OBJECT_STORAGE__S3__ENDPOINT_URL, " + "ACCESS_KEY_ID, SECRET_ACCESS_KEY, BUCKET (or S3_* aliases)" + ) + + def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: + print(f"Running: {' '.join(cmd)}") + return subprocess.run(cmd, capture_output=True, text=True, check=check) + + run(["mc", "alias", "set", "rfs", endpoint, access, secret]) + print("=== S3 before ===") + before = run(["mc", "du", f"rfs/{bucket}"], check=False) + print(before.stdout or before.stderr) + for prefix in ("git", "lfs"): + target = f"rfs/{bucket}/{prefix}/" + if dry_run: + print(f"DRY-RUN: would mc rm --recursive --force --dangerous {target}") + continue + print(f"Wiping {target} ...") + # --dangerous required for non-empty recursive remove of prefix + rm = run( + ["mc", "rm", "--recursive", "--force", "--dangerous", target], + check=False, + ) + if rm.stdout: + print(rm.stdout) + if rm.stderr: + print(rm.stderr) + if rm.returncode not in (0,): + # Empty prefix may still exit 0; treat non-zero as warning if "does not exist" + err = (rm.stderr or "") + (rm.stdout or "") + if "does not exist" in err.lower() or "not found" in err.lower(): + print(f"Prefix {prefix}/ absent; ok") + else: + print(f"WARN: mc rm {prefix}/ exited {rm.returncode}") + print("=== S3 after ===") + after = run(["mc", "du", f"rfs/{bucket}"], check=False) + print(after.stdout or after.stderr) + print("S3_WIPE_OK") + + +def run_init_mega(base_url: str, *, skip_buckal: bool, init_secret: str | None) -> None: + if not INIT_MEGA_PY.is_file(): + raise RuntimeError(f"init_mega.py not found at {INIT_MEGA_PY}") + cmd = [sys.executable, "-u", str(INIT_MEGA_PY), "--base-url", base_url] + if skip_buckal: + cmd.append("--skip-buckal") + if init_secret: + cmd.extend(["--init-secret", init_secret]) + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, check=False) + if result.returncode != 0: + raise RuntimeError(f"init_mega.py exited {result.returncode}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-url", + default=env("MONO_BASE_URL") or "http://mono-engine:8000", + help="In-cluster mono-engine base URL", + ) + parser.add_argument("--db-url", default=None) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--skip-s3", action="store_true") + parser.add_argument("--skip-buckal", action="store_true") + parser.add_argument("--skip-scale", action="store_true") + parser.add_argument( + "--init-secret", + default=None, + help="Passed through to init_mega.py (default: MEGA_INIT_BOOTSTRAP_SECRET)", + ) + args = parser.parse_args(argv) + + mono_deploy = env("MONO_DEPLOYMENT", "mono-engine") + orion_deploy = env("ORION_DEPLOYMENT", "orion-server") + scale_names = [n for n in (mono_deploy, orion_deploy) if n] + + print("=== mono-reset start ===") + print(f"base_url={args.base_url} dry_run={args.dry_run}") + + saved_replicas: dict[str, int] = {} + try: + if not args.skip_scale: + print("--- scale down writers ---") + saved_replicas = scale_deployments( + scale_names, to_zero=True, saved=None, dry_run=args.dry_run + ) + else: + print("Skipping scale (--skip-scale)") + + print("--- wipe Postgres (keep login tables) ---") + db_url = resolve_db_url(args.db_url) + wipe_public_tables(db_url, dry_run=args.dry_run) + + if args.skip_s3: + print("Skipping S3 wipe (--skip-s3)") + else: + print("--- wipe RustFS git/ + lfs/ ---") + wipe_s3(dry_run=args.dry_run) + + if not args.skip_scale: + print("--- scale writers back ---") + scale_deployments( + scale_names, to_zero=False, saved=saved_replicas, dry_run=args.dry_run + ) + + if args.dry_run: + print("DRY-RUN: skip wait / init_mega") + print("=== mono-reset dry-run done ===") + return 0 + + print("--- wait for mono + re-init ---") + wait_for_server(args.base_url, timeout=300) + # Give init_monorepo a moment after status becomes ready + time.sleep(3) + run_init_mega( + args.base_url, + skip_buckal=args.skip_buckal, + init_secret=args.init_secret, + ) + print("=== mono-reset complete ===") + return 0 + except Exception as e: + print(f"\nmono-reset FAILED: {e}", file=sys.stderr) + # Best-effort restore writers so the cluster is not left at 0 replicas + if not args.skip_scale and saved_replicas and not args.dry_run: + try: + print("Attempting to restore deployment replicas after failure...") + scale_deployments( + scale_names, to_zero=False, saved=saved_replicas, dry_run=False + ) + except Exception as restore_err: + print(f"Restore scale failed: {restore_err}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mono-reset/wipe_sql.py b/scripts/mono-reset/wipe_sql.py new file mode 100644 index 000000000..657fe16d0 --- /dev/null +++ b/scripts/mono-reset/wipe_sql.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Truncate all public Postgres tables except the login/identity keep-list. + +MySQL (Campsite) is never touched. Keep-list tables that are missing are skipped. +""" + +from __future__ import annotations + +import subprocess +import sys + +# PG tables that must survive a mono git/monorepo reset. +KEEP_TABLES = frozenset( + { + "campsite_member_identity", + "user_approval_status", + "access_token", + "ssh_keys", + "gpg_key", + "cla_sign_status", + "vault", + "path_check_configs", + # SeaORM / migration bookkeeping — never truncate + "seaql_migrations", + } +) + + +def run_psql(db_url: str, sql: str, *, tuples_only: bool = False) -> str: + cmd = ["psql", db_url, "-v", "ON_ERROR_STOP=1", "-At" if tuples_only else "-q"] + result = subprocess.run( + cmd, + input=sql, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"psql failed ({result.returncode}): {result.stderr.strip() or result.stdout.strip()}" + ) + return result.stdout + + +def list_public_tables(db_url: str) -> list[str]: + out = run_psql( + db_url, + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1;", + tuples_only=True, + ) + return [line.strip() for line in out.splitlines() if line.strip()] + + +def wipe_public_tables(db_url: str, *, dry_run: bool = False) -> list[str]: + tables = list_public_tables(db_url) + if not tables: + print("No public tables found; nothing to wipe.") + return [] + + keep_present = sorted(t for t in tables if t in KEEP_TABLES) + wipe = sorted(t for t in tables if t not in KEEP_TABLES) + + print(f"Public tables: {len(tables)}") + print(f"Keeping ({len(keep_present)}): {', '.join(keep_present) or '(none present)'}") + missing_keep = sorted(KEEP_TABLES - set(tables) - {"seaql_migrations"}) + if missing_keep: + print(f"Keep-list missing from DB (ok): {', '.join(missing_keep)}") + + if not wipe: + print("Nothing to truncate.") + return [] + + print(f"Will TRUNCATE CASCADE ({len(wipe)}): {', '.join(wipe)}") + if dry_run: + print("DRY-RUN: skipping TRUNCATE.") + return wipe + + # Quote identifiers; single statement for one CASCADE graph. + quoted = ", ".join(f'"{t}"' for t in wipe) + run_psql(db_url, f"TRUNCATE TABLE {quoted} RESTART IDENTITY CASCADE;") + print("TRUNCATE complete.") + return wipe + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--db-url", + default=None, + help="Postgres URL (default: MEGA_DATABASE__DB_URL or DATABASE_URL)", + ) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + + import os + + db_url = ( + (args.db_url or "").strip() + or os.environ.get("MEGA_DATABASE__DB_URL", "").strip() + or os.environ.get("DATABASE_URL", "").strip() + ) + if not db_url: + print("Missing --db-url / MEGA_DATABASE__DB_URL / DATABASE_URL", file=sys.stderr) + return 2 + + wipe_public_tables(db_url, dry_run=args.dry_run) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 12b703422bbb0b6ab03e3fcb799513d6c0fb282a Mon Sep 17 00:00:00 2001 From: "benjamin.747" Date: Thu, 27 Aug 2026 14:18:22 +0800 Subject: [PATCH 2/2] fix(crates-sync): reap git zombie processes and harden worker loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import Job died after ~2h with 85k "push rejected" failures that never reached mono-engine. Root cause: Python runs as PID 1 and never reaps adopted orphans — every git HTTP op leaves a `git remote-http` shim behind, so zombies accumulate (~36/s at full speed) until the pod hits its pids cgroup limit (19122) and every fork fails with EAGAIN. git push then exits non-zero before sending any HTTP, and an uncaught BlockingIOError from subprocess.run kills a worker thread, letting the run "finish" at 5.7% with the rest of the queue stranded. - Dockerfile: run tini -g as PID 1 to reap orphaned git helpers - _run_cmd: retry subprocess spawn on OSError (EAGAIN/ENOMEM) so one fork failure no longer kills a worker thread - worker_loop: per-item catch-all turns a bad item into a "fail" record instead of a dead thread stranding the queue - fail-fast breaker: 500 consecutive failures abort the run with exit code 3 instead of burning t queue into false "fail" records - git_push_main: always log a bounded stderr detail on rejection (was VERBOSE-only, which hid the root cause) --- Cargo.lock | 44 ++--- ceres/src/transport/pack/import_repo.rs | 4 +- ceres/src/transport/pack/monorepo.rs | 13 +- ceres/src/transport/protocol/mod.rs | 8 +- jupiter/src/storage/git_db_storage.rs | 3 +- scripts/crates-sync/Dockerfile | 7 +- scripts/crates-sync/crates-sync.py | 77 ++++++-- ...12\346\211\213\346\214\207\345\215\227.md" | 181 +++++++++++------- scripts/mono-reset/README.md | 21 +- 9 files changed, 230 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 988d77aa5..8dc41e619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,7 +297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" dependencies = [ "base64ct", - "blake2 0.11.0-rc.6", + "blake2 0.11.0", "cpufeatures 0.3.0", "password-hash 0.6.1", ] @@ -1032,9 +1032,9 @@ dependencies = [ [[package]] name = "blake2" -version = "0.11.0-rc.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ "digest 0.11.3", ] @@ -1130,9 +1130,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -1141,15 +1141,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -1641,9 +1641,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -3365,9 +3365,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -5197,7 +5197,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "serde", "smallvec", "zeroize", @@ -6039,7 +6039,7 @@ dependencies = [ "p256 0.13.2", "p384 0.13.1", "p521 0.13.3", - "rand 0.8.7", + "rand 0.8.8", "regex", "replace_with", "ripemd", @@ -6109,7 +6109,7 @@ dependencies = [ "p256 0.13.2", "p384 0.13.1", "p521 0.13.3", - "rand 0.8.7", + "rand 0.8.8", "replace_with", "ripemd", "rsa 0.9.10", @@ -6710,9 +6710,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7463,7 +7463,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "rkyv 0.7.46", "serde", "serde_json", @@ -8868,7 +8868,7 @@ dependencies = [ "crossbeam-channel", "getrandom 0.2.17", "parking_lot 0.12.5", - "rand 0.8.7", + "rand 0.8.8", "seahash", "thiserror 1.0.69", "tracing", @@ -10116,9 +10116,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10145,7 +10145,7 @@ dependencies = [ "libvault", "openssl", "pgp 0.20.0", - "rand 0.8.7", + "rand 0.8.8", "secp256k1", "serde", "serde_json", diff --git a/ceres/src/transport/pack/import_repo.rs b/ceres/src/transport/pack/import_repo.rs index aa1003358..ee714effb 100644 --- a/ceres/src/transport/pack/import_repo.rs +++ b/ceres/src/transport/pack/import_repo.rs @@ -448,9 +448,7 @@ impl RepoHandler for ImportRepo { .expect("command_list lock poisoned"); cmds.iter() .find(|c| { - c.ref_type == RefTypeEnum::Branch - && c.status == "ok" - && c.new_id != ZERO_ID + c.ref_type == RefTypeEnum::Branch && c.status == "ok" && c.new_id != ZERO_ID }) .map(|c| c.new_id.clone()) }; diff --git a/ceres/src/transport/pack/monorepo.rs b/ceres/src/transport/pack/monorepo.rs index 6c400396a..1778f686e 100644 --- a/ceres/src/transport/pack/monorepo.rs +++ b/ceres/src/transport/pack/monorepo.rs @@ -85,14 +85,11 @@ impl RepoHandler for MonoRepo { // Tip metadata was captured at handler construction, before the // protocol layer rejected commands (deletions, missing targets); keep // it pointing at surviving work so finalize events stay valid. - if let Some(command) = commands - .iter() - .find(|x| { - x.ref_type == RefTypeEnum::Branch - && x.command_type != CommandType::Delete - && x.status == "ok" - }) - { + if let Some(command) = commands.iter().find(|x| { + x.ref_type == RefTypeEnum::Branch + && x.command_type != CommandType::Delete + && x.status == "ok" + }) { let mut tip = self.tip.lock().expect("branch tip lock poisoned"); tip.from_hash = command.old_id.clone(); tip.to_hash = command.new_id.clone(); diff --git a/ceres/src/transport/protocol/mod.rs b/ceres/src/transport/protocol/mod.rs index 2a8579c5a..353da5c83 100644 --- a/ceres/src/transport/protocol/mod.rs +++ b/ceres/src/transport/protocol/mod.rs @@ -19,7 +19,9 @@ use tokio::sync::RwLock; use crate::{ bus::TransportRuntime, transport::pack::{ - RepoHandler, import_repo::ImportRepo, monorepo::{BranchTip, MonoRepo}, + RepoHandler, + import_repo::ImportRepo, + monorepo::{BranchTip, MonoRepo}, }, }; @@ -252,7 +254,9 @@ impl SmartSession { // deletion itself is rejected and other updates land. let tip = commands .iter() - .find(|x| x.ref_type == RefTypeEnum::Branch && x.command_type != CommandType::Delete) + .find(|x| { + x.ref_type == RefTypeEnum::Branch && x.command_type != CommandType::Delete + }) .map(|command| BranchTip { base_branch: command .ref_name diff --git a/jupiter/src/storage/git_db_storage.rs b/jupiter/src/storage/git_db_storage.rs index 29fc3b29a..f0580f71c 100644 --- a/jupiter/src/storage/git_db_storage.rs +++ b/jupiter/src/storage/git_db_storage.rs @@ -548,7 +548,8 @@ impl GitDbStorage { } /// Find single tag by repo id and tag name - pub async fn get_tag_by_repo_and_name( &self, + pub async fn get_tag_by_repo_and_name( + &self, repo_id: i64, name: &str, ) -> Result, MegaError> { diff --git a/scripts/crates-sync/Dockerfile b/scripts/crates-sync/Dockerfile index ea3eff5ba..0507d9150 100644 --- a/scripts/crates-sync/Dockerfile +++ b/scripts/crates-sync/Dockerfile @@ -14,6 +14,7 @@ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.l && apt-get -o Acquire::https::Verify-Peer=false -o Acquire::https::Verify-Host=false install -y --no-install-recommends \ ca-certificates \ git \ + tini \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -24,4 +25,8 @@ ENV PYTHONUNBUFFERED=1 # Build context must be the scripts/crates-sync directory (this folder). COPY . scripts/crates-sync/ -ENTRYPOINT ["python3", "-u", "scripts/crates-sync/run_job.py"] +# tini as PID 1: every git HTTP op leaves an orphaned `git remote-http` shim +# behind; a Python PID 1 never reaps adopted orphans, so they accumulate as +# zombies until the pod hits its pids cgroup limit and all forks fail (EAGAIN), +# which surfaces as mass "push rejected" errors. tini reaps them (-g: group-wide). +ENTRYPOINT ["tini", "-g", "--", "python3", "-u", "scripts/crates-sync/run_job.py"] diff --git a/scripts/crates-sync/crates-sync.py b/scripts/crates-sync/crates-sync.py index cba13792f..00e668a6c 100644 --- a/scripts/crates-sync/crates-sync.py +++ b/scripts/crates-sync/crates-sync.py @@ -68,6 +68,15 @@ _progress_jobs = 1 _work_queue: queue.Queue | None = None +# Fail-fast breaker: N consecutive failed items with no ok/skip in between +# means a systemic problem (e.g. fork exhaustion, server unreachable), not +# bad crates — abort the run instead of burning the whole queue into the +# manifest as false "fail" records. +FAIL_FAST_THRESHOLD = 500 +_fail_lock = threading.Lock() +_consecutive_fails = 0 +_abort_event = threading.Event() + def _record_push_ok() -> None: now = time.monotonic() with _push_ok_lock: @@ -588,10 +597,26 @@ def download_once() -> bool: return crate_path +def _run_cmd(cmd: list[str], *, retries: int = 4, base_delay_s: float = 0.5, **kwargs) -> subprocess.CompletedProcess: + """subprocess.run with retry on transient spawn failures. + + Under PID pressure (pids cgroup limit) fork fails with EAGAIN + (BlockingIOError) or ENOMEM; a single failure must not kill a worker + thread, so retry briefly before giving up. + """ + for attempt in range(retries + 1): + try: + return subprocess.run(cmd, **kwargs) + except OSError: + if attempt >= retries: + raise + time.sleep(base_delay_s * (2 ** attempt)) + + def run_git_command(repo_path, command, *, check: bool = True, log_on_error: bool = True): # Run a git command in the specified repository try: - result = subprocess.run(command, cwd=repo_path, check=check, capture_output=True, text=True) + result = _run_cmd(command, cwd=repo_path, check=check, capture_output=True, text=True) return result.stdout.strip() except subprocess.CalledProcessError as e: if log_on_error: @@ -606,7 +631,7 @@ def run_git_command(repo_path, command, *, check: bool = True, log_on_error: boo return None def ensure_git_remote(repo_path: str, remote_name: str, remote_url: str) -> None: - existing = subprocess.run( + existing = _run_cmd( ["git", "remote", "get-url", remote_name], cwd=repo_path, capture_output=True, @@ -630,7 +655,7 @@ def maybe_wrap_git_with_bearer(cmd: list[str], token: str | None) -> list[str]: return ["git", "-c", f"http.extraHeader={header}", *cmd[1:]] def _git_has_any_commit(repo_path: str) -> bool: - res = subprocess.run( + res = _run_cmd( ["git", "rev-parse", "--verify", "HEAD"], cwd=repo_path, capture_output=True, @@ -652,7 +677,7 @@ def remote_repo_has_commits( remote_url = f"{git_base_url.rstrip('/')}/{rel}" cmd = maybe_wrap_git_with_bearer(["git", "ls-remote", remote_url], auth_token) try: - res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s) + res = _run_cmd(cmd, capture_output=True, text=True, timeout=timeout_s) except subprocess.TimeoutExpired: warn(f"ls-remote timed out for {rel}") return False @@ -697,7 +722,7 @@ def git_push_main( push_args.insert(2, "--force") push_cmd = maybe_wrap_git_with_bearer(push_args, auth_token) try: - result = subprocess.run( + result = _run_cmd( push_cmd, cwd=repo_path, capture_output=True, @@ -714,11 +739,11 @@ def git_push_main( if not force and not force_with_lease and _is_non_fast_forward_rejection(out, err): return "exists" warn("Git command failed: push rejected") - if VERBOSE: - if out.strip(): - warn(f"stdout: {out.strip()}") - if err.strip(): - warn(f"stderr: {err.strip()}") + # Always surface the reason (bounded): client-side failures (e.g. fork + # exhaustion) never reach the server, so logs are the only evidence. + detail = (err or "").strip() or (out or "").strip() + if detail: + warn(f"push error detail: {detail[-400:]}") return "fail" def _cleanup_local_repo(repo_path: str, *, note: str) -> None: @@ -1307,10 +1332,23 @@ def process_one(crate_name: str, v: str) -> tuple[str, str, str]: def record_result_status(status: str, c_name: str, v: str) -> None: nonlocal succeeded, failed, skipped + global _consecutive_fails # "present" = remote already had the crate; skip work, persist as ok. persist_status = "ok" if status in ("ok", "present") else status progress_status = "skip" if status == "present" else status _progress_note_result(progress_status) + with _fail_lock: + if persist_status == "fail": + _consecutive_fails += 1 + if _consecutive_fails == FAIL_FAST_THRESHOLD: + warn( + f"Fail-fast: {FAIL_FAST_THRESHOLD} consecutive failures without any " + "success; aborting run (systemic failure suspected, e.g. fork " + "exhaustion or mono-engine unreachable)." + ) + _abort_event.set() + else: + _consecutive_fails = 0 # Counters feed the sticky progress footer (heartbeat ~2s). Per-crate OK # lines are verbose-only so a full import does not scroll millions of times. if status == "ok" and VERBOSE: @@ -1355,6 +1393,9 @@ def index_producer() -> None: for crate_name, versions in stream_index_crate_versions( index_path, max_versions_per_crate ): + if _abort_event.is_set(): + warn("Index scan stopped early: fail-fast breaker tripped.") + break for v in versions: work_q.put((crate_name, v)) _progress_mark_scan_complete() @@ -1371,12 +1412,21 @@ def index_producer() -> None: def worker_loop() -> None: while True: + if _abort_event.is_set(): + return item = work_q.get() try: if item is None: return crate_name, v = item - status, c_name, ver = process_one(crate_name, v) + try: + status, c_name, ver = process_one(crate_name, v) + except Exception as e: + # Never let one item kill the worker: a dead worker + # strands the queue and the run "finishes" at a few + # percent done. + warn(f"{_fmt_repo(crate_name, v)} worker error: {e}") + status, c_name, ver = "fail", crate_name, v record_result_status(status, c_name, ver) finally: work_q.task_done() @@ -1618,6 +1668,11 @@ def main(): total_crates = succeeded + skipped + failed info(f"Total processed: {total_crates} (ok={succeeded}, skipped={skipped}, failed={failed})") info(f"Finished at {total_end_time} (duration {total_duration})") + if _abort_event.is_set(): + # Distinct exit code so operators can tell "aborted early on systemic + # failure" from a normal completed-with-failures run. + warn(f"Aborted early by fail-fast breaker ({FAIL_FAST_THRESHOLD} consecutive failures).") + sys.exit(3) if failed > 0: sys.exit(1) diff --git "a/scripts/crates-sync/\344\270\212\346\211\213\346\214\207\345\215\227.md" "b/scripts/crates-sync/\344\270\212\346\211\213\346\214\207\345\215\227.md" index 2722245f9..af4bbbc59 100644 --- "a/scripts/crates-sync/\344\270\212\346\211\213\346\214\207\345\215\227.md" +++ "b/scripts/crates-sync/\344\270\212\346\211\213\346\214\207\345\215\227.md" @@ -2,6 +2,8 @@ 面向**不熟悉当前系统**的同学:搞清「要导入什么、数据在哪、怎么本地试、怎么用 Terraform 在 k3s-rust 上跑 Job、怎么看进度/排错」。集群部署测试需 **fork mega + mega-terraform**,在 mega 配 Harbor Secret,等 CI 出镜像后改 tf 的 image tag 并 `terraform apply`(详见 §3)。 +**k3s-rust 现状(2026-08)**:Job **默认关闭**(`enable_crates_sync = false`,集群里没有 `crates-sync`);freighter 在 `storage-server-01`;mono-engine 与 RustFS 钉在 **hwc-2**。RustFS PVC 目标是 **local-path / 50Gi**(从 Longhorn 切盘步骤见 terraform 环境 README,切盘期间不要开 Job)。 + --- ## 1. 一句话目标 @@ -29,35 +31,40 @@ third-party/rust/crates/// ### 2.2 rust 环境里有什么(`mega-rust` 命名空间) +Job 和对象存储**不在同一台机器**上:freighter 盘只在 `storage-server-01`;mono 把 pack 写到同节点的 RustFS。 + ```text 用户 / CI │ ▼ -┌─────────────────────────────────────────────────────────┐ -│ k3s 集群 · namespace: mega-rust │ -│ │ -│ mono-engine (git HTTP) ←── git push 目标 │ -│ git.rust.xuanwu.openatom.cn │ -│ │ -│ mega-ui / campsite-api / … │ -│ │ -│ RustFS (S3) ←── mono 存 pack/对象(PVC,当前约 50Gi) │ -│ │ -│ Job: crates-sync ──hostPath──► storage-server-01 │ -│ │ /opt/data/freighter/ │ -│ │ ├── crates.io-index │ -│ │ ├── crates/ (.crate) │ -│ │ └── mega-crates-work/ │ -│ └── run_job.py → crates-sync.py │ -└─────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ k3s · namespace: mega-rust │ +│ │ +│ storage-server-01 (污点 observe-only=true:NoSchedule) │ +│ Job: crates-sync ──hostPath──► /opt/data/freighter/ │ +│ run_job.py → crates-sync.py ├── crates.io-index │ +│ (只读 index / crates) ├── crates/ (.crate) │ +│ └── mega-crates-work/ │ +│ │ git push (ClusterIP) │ +│ ▼ │ +│ hwc-2 │ +│ mono-engine (git HTTP) ←── 集群内 http://mono-engine:8000 │ +│ 对外:git.rust.xuanwu.openatom.cn │ +│ │ │ +│ ▼ S3 │ +│ RustFS PVC data-rustfs-0 · 50Gi · local-path │ +│ (节点盘 /var/lib/rancher/k3s/storage,无 Longhorn 副本)│ +│ │ +│ mega-ui / campsite-api / … │ +└──────────────────────────────────────────────────────────────────┘ ``` 要点: -1. **导入进程**跑在节点 `storage-server-01` 上(有 freighter 磁盘),不是随便一个 worker。 -2. **index / .crate 缓存**在宿主机 `/opt/data/freighter`,Job 里挂载为 `/freighter`。Job **只读**这两棵树(不 `git pull`、不往 `crates/` 下载/删除);更新由 freighter 负责。可写的只有 `mega-crates-work/`(workdir + manifest)。 -3. **git push** 打到集群内的 **mono-engine**;对象最终进 **RustFS**。磁盘不够会导致 push/保存失败。 -4. 该节点有污点 `observe-only=true:NoSchedule`;Terraform 已给 Job 配了 toleration,否则 Pod 会一直 Pending。 +1. **导入进程**必须跑在 `storage-server-01`(有 freighter 磁盘),不是随便一个 worker。该节点有污点 `observe-only=true:NoSchedule`;Terraform 已给 Job 配了 toleration,否则 Pod 会一直 Pending。 +2. **index / .crate 缓存**在宿主机 `/opt/data/freighter`,Job 里挂载为 `/freighter`。Job **只读**这两棵树:不 `git pull`(除非显式 `--pull-index`)、不往 `crates/` 下载/删除。更新由 **freighter** 负责。可写的只有 `mega-crates-work/`(workdir + manifest)。 +3. **git push** 打到集群内 **mono-engine**(`http://mono-engine.mega-rust.svc.cluster.local:8000`)。mono 与 RustFS **同节点亲和**(hwc-2),对象进 RustFS。RustFS 未 Running 或节点盘满 → push/保存失败。 +4. Job **默认不创建**。`terraform.tfvars` 里 `enable_crates_sync = true` 并 `apply` 后才会有 `job/crates-sync`。 ### 2.3 脚本在干什么(流水线) @@ -65,8 +72,9 @@ third-party/rust/crates/// 读 crates.io-index(流式扫文件) │ 并行 ▼ -下载 .crate(若缓存没有)→ 解压 → git init/commit → git push → Mega - │ +取 .crate → 解压 → git init/commit → git push → Mega + │ ▲ + │ └── 本地默认可下载;K8s Job 为只读缓存,缺包直接 fail ▼ 写 manifest(JSONL):crate@version → ok | skip | fail ``` @@ -74,11 +82,13 @@ third-party/rust/crates/// 全量模式下: - **Producer**:全速扫 index,分母(`versions_found`)涨到真实规模(约 **200 万** version)。 -- **Workers(`--jobs`)**:同时从队列取任务做下载/推送。 +- **Workers(`--jobs`)**:同时从队列取任务做解压/推送。 - 已成功写入 manifest 的 `status=ok` **默认跳过**(可断点续跑)。 -- 远端路径上已有历史时,先 `ls-remote` **跳过**下载/推送(进度里算 skip,manifest 写成 `ok`);要用新提交覆盖需 `--reimport-ok` 且配合 `--force` / `--force-with-lease`。 +- 远端路径上已有历史时,先 `ls-remote` **跳过**解压/推送(进度里算 skip,manifest 写成 `ok`);要用新提交覆盖需 `--reimport-ok` 且配合 `--force` / `--force-with-lease`。 - 若仍走到 push 且遇到 non-fast-forward,同样按已存在处理,不再反复 fail。 +K8s Job 入口 `run_job.py` 会:等 mono `/api/v1/status` → 用 `MEGA_INIT_BOOTSTRAP_SECRET` 调 `POST /api/v1/bots/bootstrap-init` 换 bot token → 再调 `crates-sync.py`。 + --- ## 3. 改代码到集群部署测试(完整流程) @@ -92,7 +102,7 @@ flowchart LR A[Fork 两仓 + Harbor Secret] --> B[改脚本 / 推送 mega] B --> C[等 CI 出镜像 tag] C --> D[tfvars 换 crates_sync_image] - D --> E[terraform apply] + D --> E["terraform apply(enable_crates_sync=true)"] E --> F[kubectl logs -f job/crates-sync] ``` @@ -130,7 +140,7 @@ CI workflow:`.github/workflows/crates-sync-deploy.yml`。 - `registry.xuanwu.openatom.cn/mega/crates-sync:<短 sha>`(commit 前 7 位,**部署请用这个**) - `registry.xuanwu.openatom.cn/mega/crates-sync:latest` -从 Actions 日志或 `GITHUB_SHA` 前 7 位确认实际 tag。 +镜像:`python:3.12-slim`,`PYTHONUNBUFFERED=1`,构建上下文是 `scripts/crates-sync/`。从 Actions 日志或 `GITHUB_SHA` 前 7 位确认实际 tag。 ### 3.4 在 mega-terraform 中替换 image tag @@ -142,28 +152,37 @@ crates_sync_freighter_host_path = "/opt/data/freighter" crates_sync_node_hostname = "storage-server-01" crates_sync_image = "registry.xuanwu.openatom.cn/mega/crates-sync:<短sha>" -# 部署测试可先限流,确认链路后再去掉: +# 全量常见写法(覆盖 run_job 默认 --jobs 2): +crates_sync_args = ["--jobs", "4"] + +# 部署测试可先限流,确认链路后再改回上面: # crates_sync_args = ["--jobs", "2", "--limit-crates", "20", "--max-versions-per-crate", "1"] ``` `crates_sync_image` 每换一次 tag,Terraform 会 **替换重建** `crates-sync` Job(异步,apply 不等待导入跑完)。 +当前环境 pin 的 tag / args 以 **`terraform.tfvars` 为准**(不要用 `images.tfvars` 里的注释当生效配置)。 + ### 3.5 执行 `terraform apply` 部署测试 +先确认 **RustFS 与 mono-engine 在 Running**(`kubectl -n mega-rust get pod -l app=rustfs,app=mono-engine -o wide`)。RustFS 切盘或 `rustfs-0` 不在时不要开 Job。 + ```bash cd mega-terraform/envs/onprem/k3s-rust terraform init # 首次或 backend/provider 变更后 -terraform plan # 确认会替换 Job / 更新 image-trigger +terraform plan # 确认会创建/替换 Job terraform apply ``` 然后跟日志验证: ```bash -kubectl -n mega-rust get pods -l job-name=crates-sync -o wide +kubectl -n mega-rust get pods -l app=crates-sync -o wide kubectl -n mega-rust logs -f job/crates-sync ``` +期望:Pod 在 **`storage-server-01`**,状态 Running;日志应立刻有 `Waiting for server` / `Running:`(镜像与 Job 都设了 `PYTHONUNBUFFERED=1`)。 + 仅本地跑脚本、不发 Job 时,可跳过 Harbor / CI / apply,见 §5。 --- @@ -176,10 +195,10 @@ kubectl -n mega-rust logs -f job/crates-sync | `mega/scripts/crates-sync/run_job.py` | K8s Job 入口:等 mono、bootstrap token、调 sync | | `mega/scripts/crates-sync/Dockerfile` | 镜像构建上下文(目录即本目录) | | `mega/.github/workflows/crates-sync-deploy.yml` | 构建并推送镜像到 Harbor | -| `mega-terraform/modules/.../gitmono_stack/crates_sync.tf` | Job / hostPath / 亲和 / toleration | -| `mega-terraform/envs/onprem/k3s-rust/terraform.tfvars` | 该环境开关与镜像 tag | +| `mega-terraform/modules/.../gitmono_stack/crates_sync.tf` | Job / hostPath / 亲和 / toleration / 资源 | +| `mega-terraform/envs/onprem/k3s-rust/terraform.tfvars` | 该环境开关、镜像 tag、`crates_sync_args` | -英文细节与参数列表见同目录 [README.md](./README.md)。 +英文细节与参数列表见同目录 [README.md](./README.md)。RustFS 切盘:`mega-terraform/envs/onprem/k3s-rust/README.md`。 --- @@ -187,14 +206,16 @@ kubectl -n mega-rust logs -f job/crates-sync ### 5.1 依赖 -- Python 3 +- Python 3(Job 镜像是 3.12) - Git -- 一份本地 [crates.io-index](https://github.com/rust-lang/crates.io-index) checkout +- 一份本地 [crates.io-index](https://github.com/rust-lang/crates.io-index) checkout(目录内要有 `config.json`) - 能访问目标 Mega 的 **Bearer token**(`MEGA_TOKEN` 或 `--token`) +本地默认**会下载**缺失的 `.crate`。对齐集群「只读 freighter」时加上 `--readonly-crate-cache`(缺包直接失败,不访问 crates.io)。 + ### 5.2 小规模试跑(强烈推荐先做) -只导几个 crate,**不扫全量 index**: +只导几个 crate,**不扫全量 index**(`crates-sync.py` 默认 `--jobs 1`、`--max-versions-per-crate 1`): ```bash export MEGA_TOKEN="你的token" @@ -241,33 +262,39 @@ python3 scripts/crates-sync/crates-sync.py \ | 参数 | 含义 | |------|------| -| `--jobs N` | 并发 worker 数;同时限制并发 `git push` | -| `--max-versions-per-crate N` | 每 crate 只保留最近 N 个版本;`0` = 全部 | -| `--keep-crate-cache` | 成功后不删 `.crate`(共享 freighter 缓存时必须开) | -| `--readonly-crate-cache` | **只读** `--crates-dir`:不下载、不删、不建目录;缺包/坏包直接失败(由 freighter 更新) | +| `--jobs N` | 并发 worker 数;同时限制并发 `git push`。脚本默认 1;Job 入口默认 2 | +| `--max-versions-per-crate N` | 每 crate 只保留最近 N 个版本;`0` = 全部。脚本默认 1;Job 默认 0 | +| `--keep-crate-cache` | 成功后不删 `.crate`(共享缓存时开;`--readonly-crate-cache` 会隐含此项) | +| `--readonly-crate-cache` | **只读** `--crates-dir`:不下载、不删、不建目录;缺包/坏包直接失败。Job **始终**打开 | | `--manifest PATH` | 断点清单;默认 `/crates-import-manifest.jsonl` | | `--reimport-ok` | 强制重导 manifest 里已是 `ok` 的版本 | | `--force` / `--force-with-lease` | 只影响 git push,**不会**绕过 manifest 的 ok 跳过 | +| `--repush-existing` | workdir 里已有 git 仓库时只再 push,不解压 | +| `--status-interval` | sticky 进度刷新间隔,默认 2s | +| `--no-status-sticky` | 进度改成普通日志行(默认 sticky) | +| `--verbose` | 打印每条 `[OK] … pushed` 和 git 输出 | + +`run_job.py` 另有:`--pull-index`(默认关闭;freighter 负责更新 index)、`--no-pull-index`(已是默认,保留仅为兼容)。 ### 5.4 进度条(本地 / Job 日志底部) -默认开启 sticky heartbeat,大致形如: +默认开启 sticky heartbeat(约 2s 刷新一块,**不会**每导入一个版本就刷一行),形如: ```text progress: [##----------------------------] 0.8% 16000/1850000 push/s=1.20 eta=48.2h -scan: crates=... versions_found=... status=running ... -config: jobs=2 queue_depth=... +scan: crates=... versions_found=... status=running (denom grows until full index walk) +config: jobs=4 queue_depth=... counts: ok=... skip=... fail=... done=... -status: downloading=... pushing=... -push: ok_60s=... per_s=1.20 per_min=... +status: downloading=... extracting=... waiting_push=... pushing=... +push: ok_60s=... fail_60s=... ok_total=... fail_total=... per_s=1.20 per_min=... ``` 说明: - 扫 index 未完成时,分母会随 `versions_found` **一直涨**(目标约 200 万),不要用早期百分比当「快做完了」。 -- 默认约 **2s** 刷新一次 sticky 进度(可用 `--status-interval` 调整);**不会**每导入一个版本就刷一行。 - `push/s` / `per_s` 为近 60 秒成功 push 速率(`ok_60s / 60`)。 -- `[OK] xxx pushed` 仅在 `--verbose` 时打印;平时看底部进度条的 `ok/skip/fail` 计数即可。 +- Job 只读缓存时 `downloading=` 应接近 0;缺 `.crate` 会计入 `fail`。 +- `[OK] xxx pushed` 仅在 `--verbose` 时打印;平时看底部 `ok/skip/fail` 即可。 --- @@ -277,36 +304,43 @@ push: ok_60s=... per_s=1.20 per_min=... 环境目录:`mega-terraform/envs/onprem/k3s-rust` 命名空间:`mega-rust` -域名示例:`https://git.rust.xuanwu.openatom.cn`(mono)、`https://app.rust.xuanwu.openatom.cn`(UI)。 +域名:`https://git.rust.xuanwu.openatom.cn`(mono)、`https://app.rust.xuanwu.openatom.cn`(UI)、`https://rustfs.rust.xuanwu.openatom.cn`(RustFS 控制台)。 ### 6.1 前置条件 Checklist 1. 已按 §3 fork 两仓库,mega 已配 Harbor Secret,CI 已产出目标镜像 tag。 2. 能访问集群:`kubeconfig`(如 `~/.kube/k3s.yaml`),`kubectl -n mega-rust get pods` 正常。 3. 节点 `storage-server-01` 存在,且已有目录: - - `/opt/data/freighter/crates.io-index`(完整 index checkout) - - `/opt/data/freighter/crates`(.crate 缓存,可空) -4. mono / RustFS 已在该命名空间跑着(Job `depends_on` apps)。 + - `/opt/data/freighter/crates.io-index`(完整 index checkout,含 `config.json`) + - `/opt/data/freighter/crates`(**.crate 缓存目录必须存在**;Job 只读、不会创建或下载。空目录能启动,但几乎每个 version 都会 fail) +4. **mono-engine 与 rustfs-0 均为 Running**(Job `depends_on` apps,但 apply 不等待 RustFS 健康)。切盘期间 `rustfs-0` 会不在,此时不要 `enable_crates_sync`。 ### 6.2 配置要点 ```hcl -enable_crates_sync = true +enable_crates_sync = true # 默认 false;false 时集群里没有这个 Job crates_sync_freighter_host_path = "/opt/data/freighter" crates_sync_node_hostname = "storage-server-01" # 与 kubectl get nodes 主机名一致 crates_sync_image = "registry.xuanwu.openatom.cn/mega/crates-sync:<短sha>" -# 小流量试跑可加(全量导入时不要限制): -# crates_sync_args = ["--jobs", "4", "--limit-crates", "20", "--max-versions-per-crate", "1"] +# 全量(当前环境用法): +crates_sync_args = ["--jobs", "4"] -# 全量常见写法(run_job 默认已是 max-versions=0、keep-cache): -# crates_sync_args = ["--jobs", "4"] +# 小流量通链路: +# crates_sync_args = ["--jobs", "2", "--limit-crates", "20", "--max-versions-per-crate", "1"] ``` -说明: +`run_job.py` **固定传给** `crates-sync.py` 的: + +- `--max-versions-per-crate 0`(全版本;可被 `crates_sync_args` 覆盖) +- `--jobs`(默认 2;当前 tfvars 用 args 改成 4) +- `--keep-crate-cache`、`--readonly-crate-cache`、`--status-sticky` +- 等 mono 就绪后用 `MEGA_INIT_BOOTSTRAP_SECRET` 换 bot token +- **不** pull index(要更新加 `--pull-index`) -- `run_job.py` 默认:`--jobs 2`、`--max-versions-per-crate 0`、`--keep-crate-cache`、等 mono 就绪后用 `MEGA_INIT_BOOTSTRAP_SECRET` 换 bot token。 -- 额外参数通过 `crates_sync_args` 拼到 `run_job.py` 后面。 +`crates_sync_args` 拼在 `run_job.py` 后面:`--jobs` / `--max-versions-per-crate` 等由 `run_job` 自己解析;其余未知参数原样转给 `crates-sync.py`。 + +Job 资源(`crates_sync.tf`):request `500m` / `1Gi`,limit `4000m` / `8Gi`。 ### 6.3 Apply(异步 Job) @@ -317,14 +351,14 @@ terraform plan terraform apply ``` -- Job **`wait_for_completion = false`**:apply 成功 ≠ 导入完成,只表示 Job 对象已创建/替换。 +- Job **`wait_for_completion = false`**:apply 成功 ≠ 导入完成,只表示 Job 对象已创建/替换。`backoff_limit = 1`,完成后 86400s 删除。 - **换镜像 tag** 或改触发 `terraform_data.crates_sync_image` 的内容 → Job **替换重建** → 新一轮导入(manifest 仍在 freighter 盘上,ok 会继续 skip)。 ### 6.4 看日志与状态 ```bash kubectl -n mega-rust get job crates-sync -kubectl -n mega-rust get pods -l job-name=crates-sync -o wide +kubectl -n mega-rust get pods -l app=crates-sync -o wide kubectl -n mega-rust logs -f job/crates-sync ``` @@ -333,12 +367,17 @@ kubectl -n mega-rust logs -f job/crates-sync ### 6.5 重新跑一轮 1. mega:等 **Crates Sync deploy** CI 成功,记下新 `<短sha>`。 -2. mega-terraform:改 `crates_sync_image`。 +2. mega-terraform:改 `crates_sync_image`(需要跑时保持 `enable_crates_sync = true`)。 3. `terraform apply`。 +关掉 Job:设 `enable_crates_sync = false` 再 apply(会销毁 Job 对象;**不会**清 freighter 上的 manifest / 缓存)。 + ### 6.6 小流量建议 -先用 `crates_sync_args` 加 `--limit-crates` / `--max-versions-per-crate` 通链路,再去掉限流做全量;盯 RustFS PVC 与 freighter 磁盘。 +先用 `crates_sync_args` 加 `--limit-crates` / `--max-versions-per-crate` 通链路,再去掉限流做全量。盯: + +- hwc-2 上 RustFS 节点盘(local-path) +- `storage-server-01` 上 `/opt/data/freighter` --- @@ -353,14 +392,19 @@ kubectl -n mega-rust logs -f job/crates-sync ## 8. 常见问题 -全量导入体量大,优先关注资源是否够用: +全量导入体量大,优先看 **RustFS 是否在跑、hwc-2 节点盘是否够、freighter 缓存是否齐**。 | 现象 | 可能原因 | 处理 | |------|----------|------| -| `git push` / S3 报错、对象写失败 | RustFS **磁盘**不足(PVC 写满) | 扩容 PVC(如 `data-rustfs-0`),确认 Longhorn 已 `allowVolumeExpansion`;盯 `kubectl -n mega-rust get pvc` | -| RustFS / mono OOM、请求超时 | RustFS 或 mono **内存**不足 | 调高 `rustfs_resources` / mono 资源后 apply;查对应 Pod 是否被 OOMKilled | -| crates-sync Pod OOMKilled / Evicted | Job **内存/CPU** limit 过紧(全量扫队列也会占内存) | 调高 `crates_sync.tf` 里 container resources,或减小 `--jobs` | -| 节点磁盘打满、下载/解压失败 | freighter 宿主机盘(`/opt/data/freighter`)空间不足 | 清理或扩容 `storage-server-01` 上 freighter 目录所在磁盘 | +| `git push` / S3 报错、对象写失败 | **RustFS 未 Running**(切盘、STS=0)或 **hwc-2 节点盘**满 | `kubectl -n mega-rust get pod -l app=rustfs -o wide`;`exec rustfs-0 -- df -h /data`。local-path 数据在 hwc-2 的 `/var/lib/rancher/k3s/storage`,不是 Longhorn 在线扩容。调 `rustfs_storage_size` 不能原地改 StorageClass | +| RustFS / mono OOM、请求超时 | 内存不足 | 调高 `rustfs_resources` / `app_resources["mono-engine"]` 后 apply;查是否 OOMKilled | +| crates-sync Pod OOMKilled / Evicted | Job limit 过紧(全量扫队列占内存) | 调高 `crates_sync.tf` 的 container resources,或减小 `--jobs` | +| 大量 `fail`、日志缺 `.crate` | Job **只读**缓存,不会去 crates.io 下载 | 等 freighter 更新 `/opt/data/freighter/crates`;确认该目录在节点上存在 | +| Pod 一直 Pending | 节点名不对,或缺少 `observe-only` toleration | `crates_sync_node_hostname` 必须是 `storage-server-01`;TF 已带 toleration | +| Job 对象不存在 | `enable_crates_sync = false` | 改 true 后 apply | +| 节点磁盘打满、解压失败 | freighter 宿主机盘空间不足 | 清理或扩容 `storage-server-01` 上 `/opt/data/freighter` 所在盘 | + +切盘细节(Longhorn → local-path)不要在本目录操作,见 `mega-terraform/envs/onprem/k3s-rust/README.md`。 --- @@ -368,6 +412,7 @@ kubectl -n mega-rust logs -f job/crates-sync - Git:`https://git.rust.xuanwu.openatom.cn` - App:`https://app.rust.xuanwu.openatom.cn` +- RustFS 控制台:`https://rustfs.rust.xuanwu.openatom.cn` - Harbor:`registry.xuanwu.openatom.cn`(镜像 `mega/crates-sync`) - Terraform 环境说明:`mega-terraform/envs/onprem/k3s-rust/README.md` - 脚本英文 README:`scripts/crates-sync/README.md` diff --git a/scripts/mono-reset/README.md b/scripts/mono-reset/README.md index 13761e266..8398a83b0 100644 --- a/scripts/mono-reset/README.md +++ b/scripts/mono-reset/README.md @@ -30,26 +30,23 @@ docker build -f scripts/mono-reset/Dockerfile -t mega/mono-reset:local . ## Terraform (onprem) -In `envs/onprem/k3s-rust` (or sibling env). **Two values required** — `enable_mono_reset` -alone is not enough; plan fails unless confirm matches the namespace: +In `envs/onprem/k3s-rust` (or sibling env). Put **only** `enable_mono_reset = true` +in tfvars; type the confirm string at apply time (plan fails if wrong/missing): ```hcl -enable_mono_reset = true -mono_reset_confirm = "WIPE_GIT_DATA:mega-rust" # WIPE_GIT_DATA: -mono_reset_image = "registry.xuanwu.openatom.cn/mega/mono-reset:" -# mono_reset_args = ["--skip-buckal"] # optional +enable_mono_reset = true +mono_reset_image = "registry.xuanwu.openatom.cn/mega/mono-reset:" # optional ``` -Then: - ```bash -terraform apply +printf 'Confirm wipe (WIPE_GIT_DATA:mega-rust): ' +read -r c +terraform apply -var="mono_reset_confirm=$c" kubectl -n mega-rust logs -f job/mono-reset ``` -After success, set `enable_mono_reset = false` and clear `mono_reset_confirm` (and -apply) so a later unrelated apply does not recreate the Job. Changing -`mono_reset_image` replaces/re-runs the Job only while both switches stay set. +After success, set `enable_mono_reset = false` and apply without the confirm +`-var` so a later unrelated apply does not recreate the Job. While the Job scales `mono-engine` (and `orion-server`) to 0, git API is down; **mega-ui** and **campsite-api** stay up so login UI remains reachable.