diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index d537b39b..03f548ed 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -2820,6 +2820,135 @@ fi git -C "\${repo_path}" update-server-info git -C "\${repo_path}" rev-parse refs/heads/v0.2 | tee /cache/HWLAB-v0.2.head date -u +%Y-%m-%dT%H:%M:%SZ > /cache/HWLAB.last-sync +`, + "http.mjs": `import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createReadStream, statSync } from "node:fs"; +import path from "node:path"; + +const cacheRoot = process.env.GIT_MIRROR_CACHE_ROOT || "/cache"; +const port = Number(process.env.PORT || 8080); + +createServer((req, res) => { + handle(req, res).catch((error) => { + if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: error.message })); + }); +}).listen(port, "0.0.0.0", () => { + console.log(JSON.stringify({ event: "git-mirror-smart-http-listening", port, cacheRoot })); +}); + +async function handle(req, res) { + const url = new URL(req.url || "/", "http://git-mirror-http"); + if (req.method === "GET" && url.pathname === "/") { + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-cache" }); + res.end(JSON.stringify({ ok: true, service: "git-mirror-smart-http" })); + return; + } + if (req.method === "GET" && url.pathname.endsWith("/info/refs") && url.searchParams.get("service") === "git-upload-pack") { + await runUploadPackAdvertisement(repoPathFromGitServicePath(url.pathname, "/info/refs"), res); + return; + } + if (req.method === "POST" && url.pathname.endsWith("/git-upload-pack")) { + await runUploadPackRpc(repoPathFromGitServicePath(url.pathname, "/git-upload-pack"), req, res); + return; + } + if (req.method === "GET" || req.method === "HEAD") { + serveStaticFile(url.pathname, req, res); + return; + } + res.writeHead(405, { "content-type": "text/plain" }); + res.end("method not allowed\n"); +} + +function repoPathFromGitServicePath(urlPath, suffix) { + const repoUrlPath = urlPath.slice(0, -suffix.length); + if (!repoUrlPath.endsWith(".git")) throw new Error(` + "`" + `unsupported git repo path: \${urlPath}` + "`" + `); + return safePath(repoUrlPath); +} + +function safePath(urlPath) { + const decoded = decodeURIComponent(urlPath.replace(/^\/+/, "")); + const fullPath = path.resolve(cacheRoot, decoded); + const normalizedRoot = path.resolve(cacheRoot); + if (fullPath !== normalizedRoot && !fullPath.startsWith(` + "`" + `\${normalizedRoot}\${path.sep}` + "`" + `)) throw new Error("path escapes cache root"); + return fullPath; +} + +function smartHeaders(contentType) { + return { + "content-type": contentType, + "cache-control": "no-cache, max-age=0, must-revalidate", + expires: "Fri, 01 Jan 1980 00:00:00 GMT", + pragma: "no-cache" + }; +} + +function pktLine(text) { + const length = Buffer.byteLength(text) + 4; + return ` + "`" + `\${length.toString(16).padStart(4, "0")}\${text}` + "`" + `; +} + +function runUploadPackAdvertisement(repoPath, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-upload-pack", ["--stateless-rpc", "--advertise-refs", repoPath], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-upload-pack advertise failed: \${stderr.trim()}` + "`" + `)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-upload-pack-advertisement")); + res.write(pktLine("# service=git-upload-pack\n")); + res.write("0000"); + child.stdout.pipe(res, { end: false }); + child.stdout.on("end", () => res.end()); + }); +} + +function runUploadPackRpc(repoPath, req, res) { + return new Promise((resolve, reject) => { + const child = spawn("git-upload-pack", ["--stateless-rpc", repoPath], { stdio: ["pipe", "pipe", "pipe"] }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; process.stderr.write(chunk); }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0 && !res.headersSent) reject(new Error(` + "`" + `git-upload-pack rpc failed: \${stderr.trim()}` + "`" + `)); + else resolve(); + }); + res.writeHead(200, smartHeaders("application/x-git-upload-pack-result")); + req.pipe(child.stdin); + child.stdout.pipe(res); + }); +} + +function serveStaticFile(urlPath, req, res) { + const fullPath = safePath(urlPath); + let stat; + try { stat = statSync(fullPath); } catch { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found\n"); + return; + } + if (!stat.isFile()) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found\n"); + return; + } + res.writeHead(200, { "content-length": stat.size, "content-type": contentType(fullPath), "cache-control": "no-cache" }); + if (req.method === "HEAD") { + res.end(); + return; + } + createReadStream(fullPath).pipe(res); +} + +function contentType(filePath) { + if (filePath.endsWith(".pack")) return "application/x-git-packed-objects"; + if (filePath.endsWith(".idx")) return "application/x-git-packed-objects-toc"; + return "text/plain"; +} ` } }, @@ -2837,15 +2966,20 @@ date -u +%Y-%m-%dT%H:%M:%SZ > /cache/HWLAB.last-sync name: "http", image: ciToolsRunnerImage, imagePullPolicy: "IfNotPresent", - workingDir: "/cache", - command: ["python3"], - args: ["-m", "http.server", "8080", "--bind", "0.0.0.0"], + command: ["node"], + args: ["/script/http.mjs"], ports: [{ name: "http", containerPort: 8080 }], readinessProbe: { httpGet: { path: "/pikasTech/HWLAB.git/HEAD", port: "http" }, initialDelaySeconds: 5, periodSeconds: 10 }, livenessProbe: { httpGet: { path: "/", port: "http" }, initialDelaySeconds: 10, periodSeconds: 30 }, - volumeMounts: [{ name: "cache", mountPath: "/cache", readOnly: true }] + volumeMounts: [ + { name: "cache", mountPath: "/cache", readOnly: true }, + { name: "script", mountPath: "/script", readOnly: true } + ] }], - volumes: [{ name: "cache", persistentVolumeClaim: { claimName: "git-mirror-cache", readOnly: true } }] + volumes: [ + { name: "cache", persistentVolumeClaim: { claimName: "git-mirror-cache", readOnly: true } }, + { name: "script", configMap: { name: "git-mirror-sync-script", defaultMode: 0o555 } } + ] } } }