Pin Minecraft 26.3 and add safe Orion managed deployment support
This commit is contained in:
1 parent
dff2b5a9c4
commit
008120dbad
40 files changed
+1877
-490
No files matched your search
@@ -1,25 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:26-slim AS deps
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:26-slim AS build
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM node:26-slim AS runtime
|
||||
FROM node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime
|
||||
ARG TARGETARCH=amd64
|
||||
ARG DOCKER_CLI_VERSION=27.5.1
|
||||
ARG COMPOSE_VERSION=v2.32.4
|
||||
|
||||
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Docker CLI + compose plugin so the panel can recreate containers after
|
||||
@@ -46,6 +46,7 @@ ENV NODE_ENV=production \
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
COPY scripts/restore_data.py /app/restore_data.py
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
|
||||
Generated
+1279
-201
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
@@ -14,7 +16,7 @@
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@uiw/react-codemirror": "4.25.11",
|
||||
"lucide-react": "1.24.0",
|
||||
"next": "16.2.10",
|
||||
"next": "16.3.7",
|
||||
"node-cron": "4.6.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
@@ -24,8 +26,9 @@
|
||||
"@types/node": "26.1.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"postcss": "8.5.16",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.2",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "5.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Minecraft archive and replace data contents while preserving the bind root.
|
||||
|
||||
The caller MUST stop Minecraft and its backup writer before `restore`.
|
||||
Only regular files/directories are accepted. No existing data is deleted.
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
def members(archive):
|
||||
result = []
|
||||
seen = set()
|
||||
for member in archive.getmembers():
|
||||
name = PurePosixPath(member.name)
|
||||
if name.is_absolute() or ".." in name.parts or "\\" in member.name:
|
||||
raise ValueError("Archive contains an unsafe path")
|
||||
if not (member.isdir() or member.isfile()):
|
||||
raise ValueError("Archive links and special files are not supported")
|
||||
key = str(name)
|
||||
if key in seen:
|
||||
raise ValueError("Archive contains duplicate paths")
|
||||
seen.add(key)
|
||||
result.append(member)
|
||||
if not any(m.isfile() and PurePosixPath(m.name).name == "level.dat" for m in result):
|
||||
raise ValueError("Archive does not contain a Minecraft world (level.dat)")
|
||||
return result
|
||||
|
||||
|
||||
def verify(archive_path):
|
||||
# Reading each member also verifies the gzip footer, rather than only headers.
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
entries = members(archive)
|
||||
for member in entries:
|
||||
if member.isfile():
|
||||
with archive.extractfile(member) as source:
|
||||
while source.read(1024 * 1024):
|
||||
pass
|
||||
import gzip
|
||||
with gzip.open(archive_path, "rb") as source:
|
||||
while source.read(1024 * 1024):
|
||||
pass
|
||||
return entries
|
||||
|
||||
|
||||
def restore(archive_path, data_path, work_path, lock_file=None):
|
||||
data = Path(data_path).resolve(strict=True)
|
||||
work = Path(work_path).resolve()
|
||||
if data == work or data in work.parents or work in data.parents:
|
||||
raise ValueError("Restore workspace must be separate from the data tree")
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
if data.stat().st_dev != work.stat().st_dev:
|
||||
raise ValueError("Data and restore workspace must be on the same filesystem")
|
||||
with (work / ".restore.lock").open("a") as operation_lock:
|
||||
fcntl.flock(operation_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
entries = verify(archive_path)
|
||||
required = sum(m.size for m in entries if m.isfile())
|
||||
if shutil.disk_usage(work).free < required + 64 * 1024 * 1024:
|
||||
raise ValueError("Insufficient free space for a staged restore")
|
||||
run = Path(tempfile.mkdtemp(prefix="restore-", dir=work))
|
||||
staged, previous = run / "staged", run / "previous"
|
||||
staged.mkdir()
|
||||
previous.mkdir()
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
archive.extractall(staged, members=members(archive), filter="data")
|
||||
# tarfile's data filter intentionally discards archive ownership.
|
||||
# Give the staged tree to the actual server data owner before activation.
|
||||
owner = data.stat()
|
||||
if os.geteuid() == 0:
|
||||
for child in staged.rglob("*"):
|
||||
os.chown(child, owner.st_uid, owner.st_gid)
|
||||
elif os.geteuid() != owner.st_uid:
|
||||
raise ValueError("Restore must run as root or the server data owner")
|
||||
if lock_file:
|
||||
marker = staged / ".deployment-lock.json"
|
||||
if not marker.is_file() or json.loads(marker.read_text()) != json.loads(Path(lock_file).read_text()):
|
||||
raise ValueError("Backup deployment lock differs. Restore the matching release via the host runbook.")
|
||||
# Moving children keeps the /data bind mount inode alive in existing containers.
|
||||
moved_old, moved_new = [], []
|
||||
try:
|
||||
for child in data.iterdir():
|
||||
child.rename(previous / child.name)
|
||||
moved_old.append(child.name)
|
||||
for child in staged.iterdir():
|
||||
child.rename(data / child.name)
|
||||
moved_new.append(child.name)
|
||||
except BaseException:
|
||||
for name in reversed(moved_new):
|
||||
(data / name).rename(staged / name)
|
||||
for name in reversed(moved_old):
|
||||
(previous / name).rename(data / name)
|
||||
raise
|
||||
return {"previous_data": str(previous), "restored_from": str(Path(archive_path).resolve())}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["verify", "restore"])
|
||||
parser.add_argument("--archive", required=True)
|
||||
parser.add_argument("--data")
|
||||
parser.add_argument("--work")
|
||||
parser.add_argument("--lock-file")
|
||||
args = parser.parse_args()
|
||||
if args.action == "verify":
|
||||
verify(args.archive)
|
||||
print(json.dumps({"valid": True}))
|
||||
else:
|
||||
if not args.data or not args.work:
|
||||
parser.error("restore requires --data and --work")
|
||||
print(json.dumps(restore(args.archive, args.data, args.work, args.lock_file)))
|
||||
@@ -39,6 +39,8 @@ const SECTIONS = [
|
||||
] as const;
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment", 0);
|
||||
const managed = deployment?.managed ?? true;
|
||||
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
|
||||
const [values, setValues] = useState<ConfigValues>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -103,10 +105,10 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} busy={saving} disabled={!dirty}>
|
||||
<Button onClick={save} busy={saving} disabled={managed || !dirty}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
|
||||
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={managed || dirty}>
|
||||
<Rocket size={14} /> Apply (restarts server)
|
||||
</Button>
|
||||
</div>
|
||||
@@ -128,6 +130,7 @@ export default function ConfigPage() {
|
||||
<div key={f.key}>
|
||||
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
|
||||
<Input
|
||||
disabled={managed}
|
||||
value={values[f.key] ?? ""}
|
||||
onChange={(e) => set(f.key, e.target.value)}
|
||||
className="w-full font-mono"
|
||||
|
||||
@@ -17,7 +17,7 @@ import CodeMirror from "@uiw/react-codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { api, toast } from "@/lib/api";
|
||||
import { api, toast, useApi } from "@/lib/api";
|
||||
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
|
||||
import { fmtBytes, fmtDateTime } from "@/lib/format";
|
||||
|
||||
@@ -32,6 +32,8 @@ function langFor(name: string) {
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
||||
const managed = deployment?.managed ?? true;
|
||||
const [path, setPath] = useState("");
|
||||
const [entries, setEntries] = useState<Entry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,10 +126,10 @@ export default function FilesPage() {
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Button disabled={managed} size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<Upload size={13} /> Upload here
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
<Button disabled={managed} size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
<FolderPlus size={13} /> New folder
|
||||
</Button>
|
||||
</div>
|
||||
@@ -187,6 +189,7 @@ export default function FilesPage() {
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
disabled={managed}
|
||||
onClick={() => setConfirmDelete(e)}
|
||||
className="rounded p-1 text-muted hover:text-crit"
|
||||
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
|
||||
@@ -213,7 +216,7 @@ export default function FilesPage() {
|
||||
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
|
||||
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={managed || !editing.dirty || editing.truncated}>
|
||||
<Save size={13} /> Save
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing(null)}>
|
||||
@@ -223,6 +226,8 @@ export default function FilesPage() {
|
||||
</header>
|
||||
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
|
||||
<CodeMirror
|
||||
editable={!managed}
|
||||
readOnly={managed}
|
||||
value={editing.content}
|
||||
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
|
||||
extensions={langFor(editing.path)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Shell } from "@/components/shell";
|
||||
import { ManagedNotice } from "@/components/managed-notice";
|
||||
|
||||
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Shell>{children}</Shell>;
|
||||
return <Shell><ManagedNotice />{children}</Shell>;
|
||||
}
|
||||
@@ -60,15 +60,17 @@ const FEATURED = [
|
||||
"Pre-generates the world around spawn so nobody lags exploring new chunks. Run `chunky radius 2000` then `chunky start` in Console.",
|
||||
},
|
||||
{
|
||||
slug: "proxy-protocol-support",
|
||||
slug: "fabricproxy-lite",
|
||||
emoji: "🛡",
|
||||
title: "Proxy Protocol Support",
|
||||
title: "FabricProxy-Lite",
|
||||
description:
|
||||
"Using TCPShield (or another proxy)? This restores players' real IPs so the Security page and IP bans work. Config is auto-enforced — see docs/tcpshield.md.",
|
||||
"Velocity modern forwarding requires coordinated proxy and backend configuration. Follow docs/tcpshield.md before enabling.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ModsPage() {
|
||||
const { data: deployment } = useApi<{ managed: boolean }>("/api/deployment");
|
||||
const managed = deployment?.managed ?? true;
|
||||
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
@@ -127,10 +129,10 @@ export default function ModsPage() {
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Mods</h1>
|
||||
<p className="mt-0.5 text-xs text-ink2">
|
||||
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
|
||||
{managed ? "Mod versions are locked by the reviewed deployment." : "Pinned Modrinth version IDs remain fixed when the server restarts."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
||||
<Button disabled={managed} variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
||||
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -150,7 +152,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "primary"}
|
||||
disabled={already}
|
||||
disabled={managed || already}
|
||||
busy={busy === `add-${f.slug}`}
|
||||
onClick={() => void manage(`add-${f.slug}`, { op: "add", slug: f.slug }, `${f.title} added — hit Apply to install`)}
|
||||
>
|
||||
@@ -170,7 +172,7 @@ export default function ModsPage() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void search()}
|
||||
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
|
||||
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.3"}…`}
|
||||
className="w-full"
|
||||
/>
|
||||
<Button onClick={() => void search()} busy={searching}>
|
||||
@@ -208,7 +210,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "primary"}
|
||||
disabled={already}
|
||||
disabled={managed || already}
|
||||
busy={busy === `add-${h.slug}`}
|
||||
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
||||
>
|
||||
@@ -223,8 +225,7 @@ export default function ModsPage() {
|
||||
{/* managed list */}
|
||||
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
||||
<p className="mb-3 text-[11px] leading-relaxed text-muted">
|
||||
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
|
||||
newest compatible build on every start, so updating Minecraft also updates these automatically.
|
||||
Entries with version IDs stay fixed. Review Minecraft, loader and mod compatibility together before updating.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{(data?.managed ?? []).map((slug) => (
|
||||
@@ -240,6 +241,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={managed}
|
||||
busy={busy === `remove-${slug}`}
|
||||
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
|
||||
>
|
||||
@@ -285,6 +287,7 @@ export default function ModsPage() {
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={managed}
|
||||
busy={busy === `toggle-${m.file}`}
|
||||
onClick={() =>
|
||||
void manage(
|
||||
@@ -300,6 +303,7 @@ export default function ModsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={managed}
|
||||
busy={busy === `delete-${m.file}`}
|
||||
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function DELETE(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name) return fail("Missing backup name");
|
||||
deleteBackup(name);
|
||||
await deleteBackup(name);
|
||||
return ok({ deleted: name });
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertDeploymentEditable } from "@/server/managed";
|
||||
import { withServerOperation } from "@/server/operation";
|
||||
import { NextRequest } from "next/server";
|
||||
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
||||
import { safetyBackup } from "@/server/backups";
|
||||
@@ -5,9 +7,10 @@ import { notify } from "@/server/notify";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
|
||||
if (backupFirst) await safetyBackup("config apply");
|
||||
if (service !== "backup" || backupFirst) await safetyBackup("config apply");
|
||||
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
|
||||
if (!res.ok) {
|
||||
notify("error", "Config apply failed", res.output.slice(-800));
|
||||
@@ -15,5 +18,5 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
|
||||
return ok({ output: res.output.slice(-1000) });
|
||||
});
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { env } from "@/server/env";
|
||||
import { ok } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export function GET() { return ok({ managed: env.managed }); }
|
||||
@@ -1,10 +1,15 @@
|
||||
import { safetyBackup } from "@/server/backups";
|
||||
import { assertDeploymentEditable } from "@/server/managed";
|
||||
import { withServerOperation } from "@/server/operation";
|
||||
import { applyMcConfig } from "@/server/compose";
|
||||
import { notify } from "@/server/notify";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
await safetyBackup("mod apply");
|
||||
const res = await applyMcConfig();
|
||||
if (!res.ok) {
|
||||
notify("error", "Mod apply failed", res.output.slice(-800));
|
||||
@@ -12,5 +17,5 @@ export async function POST() {
|
||||
}
|
||||
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
|
||||
return ok({ output: res.output.slice(-1000) });
|
||||
});
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertDeploymentEditable } from "@/server/managed";
|
||||
import { withServerOperation } from "@/server/operation";
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -18,7 +20,8 @@ export async function GET() {
|
||||
|
||||
/** Danger zone: archive the current world and generate a fresh one. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
return handle(() => withServerOperation(async () => {
|
||||
assertDeploymentEditable();
|
||||
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
||||
if (op !== "reset") return fail("Unknown operation");
|
||||
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
|
||||
@@ -27,6 +30,8 @@ export async function POST(req: NextRequest) {
|
||||
if (!fs.existsSync(worldDir)) return fail("No world folder found");
|
||||
|
||||
await safetyBackup("world reset");
|
||||
const backup = await inspectContainer(env.backupContainer);
|
||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||
|
||||
@@ -34,8 +39,9 @@ export async function POST(req: NextRequest) {
|
||||
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
|
||||
|
||||
await powerContainer(env.mcContainer, "start");
|
||||
if (backup.running) await powerContainer(env.backupContainer, "start");
|
||||
logActivity("world_reset", null, `old world kept as ${archived}`);
|
||||
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
|
||||
return ok({ archived });
|
||||
});
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
import { useApi } from "@/lib/api";
|
||||
|
||||
export function ManagedNotice() {
|
||||
const { data } = useApi<{ managed: boolean }>("/api/deployment", 0);
|
||||
if (!data?.managed) return null;
|
||||
return <div className="mb-4 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm">
|
||||
Managed deployment: versions, mods and files are read-only here. Update the reviewed
|
||||
deployment through Komodo. Console, player controls and backups remain available.
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DockerError } from "./docker";
|
||||
import { RconError } from "./rcon";
|
||||
import { ManagedDeploymentError } from "./managed";
|
||||
|
||||
/**
|
||||
* Small helpers for route handlers. Authentication is enforced globally in
|
||||
@@ -21,6 +22,7 @@ export function handle(fn: () => Promise<NextResponse> | NextResponse) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof ManagedDeploymentError) return fail(err.message, 409);
|
||||
if (err instanceof DockerError && err.kind === "unavailable") {
|
||||
return fail("Docker is unreachable from the panel", 503);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
||||
import { db, logActivity } from "./db";
|
||||
import { logActivity } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { env } from "./env";
|
||||
import { withServerOperation } from "./operation";
|
||||
import { promisify } from "node:util";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
|
||||
@@ -38,43 +41,63 @@ export function listBackups(): BackupInfo[] {
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
export async function triggerBackup(): Promise<string> {
|
||||
export function triggerBackup(): Promise<string> {
|
||||
return withServerOperation(triggerBackupUnlocked);
|
||||
}
|
||||
|
||||
async function triggerBackupUnlocked(): Promise<string> {
|
||||
const before = new Set(listBackups().map((b) => b.name));
|
||||
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
||||
if (res.exitCode !== 0) {
|
||||
notify("error", "Backup failed", res.output.slice(-800));
|
||||
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
||||
}
|
||||
const created = listBackups().find((b) => !before.has(b.name));
|
||||
logActivity("backup", null, created?.name ?? "manual backup");
|
||||
notify("success", "Backup completed", created ? created.name : "", { discord: false });
|
||||
return created?.name ?? "backup completed";
|
||||
// Some sidecar versions acknowledge the request before writing the archive.
|
||||
// Wait for a new complete gzip/tar, never treat the request as a finished backup.
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const created of listBackups().filter((b) => !before.has(b.name) && b.size > 0)) {
|
||||
try {
|
||||
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", backupPath(created.name)],
|
||||
{ timeout: 10 * 60 * 1000, maxBuffer: 1024 * 1024 });
|
||||
logActivity("backup", null, created.name);
|
||||
notify("success", "Backup completed", created.name, { discord: false });
|
||||
return created.name;
|
||||
} catch { /* Still being written, or invalid: do not report success. */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
throw new Error("No new complete Minecraft backup appeared within 30 minutes");
|
||||
}
|
||||
|
||||
export async function restoreBackup(name: string): Promise<void> {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
"tar",
|
||||
["-xpzf", file, "-C", env.mcDataDir],
|
||||
{ timeout: 30 * 60 * 1000 },
|
||||
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
||||
);
|
||||
export function restoreBackup(name: string): Promise<void> {
|
||||
return withServerOperation(async () => {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||
await execFileAsync("python3", [env.restoreHelper, "verify", "--archive", file],
|
||||
{ timeout: 30 * 60 * 1000 });
|
||||
const [mc, backup] = await Promise.all([
|
||||
inspectContainer(env.mcContainer), inspectContainer(env.backupContainer),
|
||||
]);
|
||||
if (backup.running) await powerContainer(env.backupContainer, "stop");
|
||||
if (mc.running) await powerContainer(env.mcContainer, "stop");
|
||||
// Failure deliberately leaves the writers stopped for inspection.
|
||||
const args = [env.restoreHelper, "restore", "--archive", file,
|
||||
"--data", env.mcDataDir, "--work", env.restoreWorkDir];
|
||||
if (env.managed) args.push("--lock-file", path.join(env.projectDir, "release-lock.json"));
|
||||
const result = await execFileAsync("python3", args, { timeout: 30 * 60 * 1000 });
|
||||
if (mc.running) await powerContainer(env.mcContainer, "start");
|
||||
if (backup.running) await powerContainer(env.backupContainer, "start");
|
||||
logActivity("restore", null, `${name}: ${result.stdout.trim()}`);
|
||||
notify("success", "Backup restored", "Previous data retained in the restore workspace.");
|
||||
});
|
||||
|
||||
await powerContainer(env.mcContainer, "start");
|
||||
logActivity("restore", null, name);
|
||||
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
|
||||
}
|
||||
|
||||
export function deleteBackup(name: string): void {
|
||||
fs.unlinkSync(backupPath(name));
|
||||
logActivity("backup_delete", null, name);
|
||||
export function deleteBackup(name: string): Promise<void> {
|
||||
return withServerOperation(async () => {
|
||||
fs.unlinkSync(backupPath(name));
|
||||
logActivity("backup_delete", null, name);
|
||||
});
|
||||
}
|
||||
|
||||
export function backupsDirSize(): number {
|
||||
@@ -92,7 +115,7 @@ export async function safetyBackup(reason: string): Promise<void> {
|
||||
try {
|
||||
await triggerBackup();
|
||||
} catch (err) {
|
||||
db();
|
||||
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
|
||||
notify("error", "Safety backup failed", `${reason} cancelled: ${(err as Error).message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
import { withServerOperation } from "./operation";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Apply configuration changes by re-running docker compose for a service.
|
||||
@@ -9,21 +12,22 @@ import { env } from "./env";
|
||||
*/
|
||||
|
||||
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
assertDeploymentEditable();
|
||||
return withServerOperation(() => new Promise((resolve) => {
|
||||
execFile(
|
||||
"docker",
|
||||
["compose", "--project-directory", env.projectDir, ...args],
|
||||
["compose", "--project-directory", env.projectDir, "-f", path.join(env.projectDir, env.composeFile), ...args],
|
||||
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
||||
}
|
||||
);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/** Recreate the Minecraft container so new .env values take effect. */
|
||||
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
|
||||
return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
|
||||
return runCompose(["up", "-d", "--no-deps", env.mcService]);
|
||||
}
|
||||
|
||||
/** Recreate the backup sidecar (after backup schedule changes). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "node:http";
|
||||
import { env } from "./env";
|
||||
import { withServerOperation } from "./operation";
|
||||
|
||||
/**
|
||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||
@@ -130,6 +131,10 @@ export async function containerEnv(name: string): Promise<Record<string, string>
|
||||
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||
|
||||
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||
return withServerOperation(() => powerContainerUnlocked(name, action));
|
||||
}
|
||||
|
||||
async function powerContainerUnlocked(name: string, action: PowerAction): Promise<void> {
|
||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||
method: "POST",
|
||||
@@ -292,5 +297,6 @@ export async function execInContainer(
|
||||
});
|
||||
|
||||
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
|
||||
return { exitCode: info.ExitCode ?? 0, output };
|
||||
if (info.ExitCode === null) throw new DockerError("Container command is still running; completion is unverified");
|
||||
return { exitCode: info.ExitCode, output };
|
||||
}
|
||||
@@ -14,6 +14,11 @@ function str(key: string, fallback: string): string {
|
||||
|
||||
export const env = {
|
||||
dev,
|
||||
managed: process.env.PANEL_MANAGED === "true",
|
||||
mcService: str("MC_SERVICE", "mc"),
|
||||
composeFile: str("COMPOSE_FILE_NAME", "docker-compose.yml"),
|
||||
restoreWorkDir: path.resolve(str("RESTORE_WORK_DIR", dev ? "./dev-data/restore" : "/restore-work")),
|
||||
restoreHelper: str("RESTORE_HELPER", "/app/restore_data.py"),
|
||||
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* Read/write the project .env file while preserving comments and ordering.
|
||||
@@ -18,16 +19,20 @@ export function readEnvFile(): Record<string, string> {
|
||||
try {
|
||||
text = fs.readFileSync(envFilePath(), "utf8");
|
||||
} catch {
|
||||
return out;
|
||||
return env.managed ? Object.fromEntries(EDITABLE_ENV_KEYS.map((key) => [key, process.env[key] ?? ""])) : out;
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||
if (m) out[m[1]] = m[2].trim();
|
||||
}
|
||||
if (env.managed) {
|
||||
for (const key of EDITABLE_ENV_KEYS) out[key] = process.env[key] ?? out[key] ?? "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEnvFile(updates: Record<string, string>): void {
|
||||
assertDeploymentEditable();
|
||||
const file = envFilePath();
|
||||
let lines: string[];
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* Sandboxed file access for the file browser / editors. Every path is
|
||||
@@ -80,18 +81,21 @@ export function readTextFile(rel: string): { content: string; truncated: boolean
|
||||
}
|
||||
|
||||
export function writeTextFile(rel: string, content: string): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, content);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function writeBinaryFile(rel: string, data: Buffer): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, data);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function deletePath(rel: string): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
||||
const st = fs.statSync(abs);
|
||||
@@ -103,6 +107,7 @@ export function deletePath(rel: string): void {
|
||||
}
|
||||
|
||||
export function makeDir(rel: string): void {
|
||||
assertDeploymentEditable();
|
||||
const abs = resolveSafe(rel);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
fixOwnership(abs);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { env } from "./env";
|
||||
|
||||
export class ManagedDeploymentError extends Error {
|
||||
constructor() {
|
||||
super("This deployment is managed by Komodo. Change its reviewed configuration in homelab-infra.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDeploymentEditable(): void {
|
||||
if (env.managed) throw new ManagedDeploymentError();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readEnvFile, writeEnvFile } from "./envfile";
|
||||
import { env } from "./env";
|
||||
import { assertDeploymentEditable } from "./managed";
|
||||
|
||||
/**
|
||||
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
||||
@@ -24,7 +25,7 @@ export type ModSearchResult = {
|
||||
};
|
||||
|
||||
export function currentMcVersion(): string {
|
||||
return readEnvFile()["MC_VERSION"] || "26.2";
|
||||
return readEnvFile()["MC_VERSION"] || "26.3";
|
||||
}
|
||||
|
||||
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
||||
@@ -143,11 +144,13 @@ function safeModFile(file: string): string {
|
||||
}
|
||||
|
||||
export function toggleMod(file: string): void {
|
||||
assertDeploymentEditable();
|
||||
const full = safeModFile(file);
|
||||
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
||||
fs.renameSync(full, target);
|
||||
}
|
||||
|
||||
export function deleteModFile(file: string): void {
|
||||
assertDeploymentEditable();
|
||||
fs.unlinkSync(safeModFile(file));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
// One reentrant queue for power, backup, restore and Compose operations.
|
||||
const state = globalThis as unknown as {
|
||||
__craftdeckOperation?: { tail: Promise<unknown>; context: AsyncLocalStorage<boolean> };
|
||||
};
|
||||
const queue = state.__craftdeckOperation ??= {
|
||||
tail: Promise.resolve(), context: new AsyncLocalStorage<boolean>(),
|
||||
};
|
||||
|
||||
export function withServerOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (queue.context.getStore()) return operation();
|
||||
const result = queue.tail.then(() => queue.context.run(true, operation));
|
||||
queue.tail = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import cron, { type ScheduledTask } from "node-cron";
|
||||
import { db } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { powerContainer, execInContainer } from "./docker";
|
||||
import { powerContainer } from "./docker";
|
||||
import { rcon } from "./rcon";
|
||||
import { say } from "./mc";
|
||||
import { env } from "./env";
|
||||
import { triggerBackup } from "./backups";
|
||||
|
||||
/**
|
||||
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
||||
@@ -47,9 +48,7 @@ export async function runScheduleAction(action: Schedule["action"], payload: str
|
||||
return "Restarted";
|
||||
}
|
||||
case "backup": {
|
||||
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
|
||||
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
|
||||
return "Backup completed";
|
||||
return await triggerBackup();
|
||||
}
|
||||
case "command": {
|
||||
if (!payload) throw new Error("No command configured");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { withServerOperation } from "../src/server/operation";
|
||||
|
||||
describe("server operation queue", () => {
|
||||
it("serializes destructive operations while allowing nested backups", async () => {
|
||||
const events: string[] = [];
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const first = withServerOperation(async () => {
|
||||
events.push("restore-start");
|
||||
await withServerOperation(async () => { events.push("nested-backup"); });
|
||||
await gate;
|
||||
events.push("restore-finish");
|
||||
});
|
||||
const second = withServerOperation(async () => { events.push("power"); });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(events).toEqual(["restore-start", "nested-backup"]);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
expect(events).toEqual(["restore-start", "nested-backup", "restore-finish", "power"]);
|
||||
});
|
||||
it("a failure does not poison later operations", async () => {
|
||||
await expect(withServerOperation(async () => { throw new Error("failed"); })).rejects.toThrow("failed");
|
||||
await expect(withServerOperation(async () => "next")).resolves.toBe("next");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
vi.mock("../src/server/env", () => ({ env: { managed: false, backupsDir: "/missing-test-backups", backupContainer: "backup", projectDir: "/missing-project" } }));
|
||||
vi.mock("../src/server/docker", () => ({
|
||||
execInContainer: vi.fn(async () => ({ exitCode: 1, output: "backup disk full" })),
|
||||
powerContainer: vi.fn(), inspectContainer: vi.fn(), DockerError: class extends Error {},
|
||||
}));
|
||||
vi.mock("../src/server/db", () => ({ logActivity: vi.fn() }));
|
||||
vi.mock("../src/server/notify", () => ({ notify: vi.fn() }));
|
||||
vi.mock("../src/server/compose", () => ({ applyMcConfig: vi.fn(), applyBackupConfig: vi.fn() }));
|
||||
import { env } from "../src/server/env";
|
||||
import { safetyBackup } from "../src/server/backups";
|
||||
import { execInContainer } from "../src/server/docker";
|
||||
import { applyMcConfig } from "../src/server/compose";
|
||||
import { writeEnvFile } from "../src/server/envfile";
|
||||
import { writeTextFile, writeBinaryFile, deletePath, makeDir } from "../src/server/files";
|
||||
import { POST } from "../src/app/api/config/apply/route";
|
||||
import { POST as applyMods } from "../src/app/api/mods/apply/route";
|
||||
|
||||
beforeEach(() => { env.managed = false; vi.clearAllMocks(); });
|
||||
describe("required backups and managed deployment", () => {
|
||||
it("does not swallow a failed safety backup", async () => {
|
||||
await expect(safetyBackup("upgrade")).rejects.toThrow("disk full");
|
||||
});
|
||||
it("aborts a config apply even when a client asks to skip backup", async () => {
|
||||
const response = await POST(new NextRequest("http://localhost/api/config/apply", {
|
||||
method: "POST", body: JSON.stringify({ backupFirst: false }),
|
||||
}));
|
||||
expect(response.status).toBe(500);
|
||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
it("also aborts mod apply after a failed backup", async () => {
|
||||
expect((await applyMods()).status).toBe(500);
|
||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects managed config apply before executing anything", async () => {
|
||||
env.managed = true;
|
||||
const response = await POST(new NextRequest("http://localhost/api/config/apply", { method: "POST", body: "{}" }));
|
||||
expect(response.status).toBe(409);
|
||||
expect(execInContainer).not.toHaveBeenCalled();
|
||||
expect(applyMcConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
it("guards direct config and file mutation helpers", () => {
|
||||
env.managed = true;
|
||||
for (const change of [
|
||||
() => writeEnvFile({ MC_VERSION: "latest" }),
|
||||
() => writeTextFile("server.properties", "bad"),
|
||||
() => writeBinaryFile("mods/unreviewed.jar", Buffer.from("bad")),
|
||||
() => deletePath("world"), () => makeDir("mods"),
|
||||
]) expect(change).toThrow("managed by Komodo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("restore_data", Path(__file__).parents[1] / "scripts/restore_data.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class RestoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.data = self.root / "data"
|
||||
self.data.mkdir()
|
||||
(self.data / "newer-only.dat").write_text("preserve me")
|
||||
self.archive = self.root / "backup.tgz"
|
||||
|
||||
def make_archive(self, files, link=False):
|
||||
with tarfile.open(self.archive, "w:gz") as tar:
|
||||
for name, content in files.items():
|
||||
info = tarfile.TarInfo(name)
|
||||
payload = content.encode()
|
||||
info.size = len(payload)
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
if link:
|
||||
info = tarfile.TarInfo("world/escape")
|
||||
info.type, info.linkname = tarfile.SYMTYPE, "/etc"
|
||||
tar.addfile(info)
|
||||
|
||||
def test_clean_restore_keeps_previous_and_bind_root(self):
|
||||
self.make_archive({"./world/level.dat": "old world"})
|
||||
inode = self.data.stat().st_ino
|
||||
result = module.restore(self.archive, self.data, self.root / "restore")
|
||||
self.assertEqual(self.data.stat().st_ino, inode)
|
||||
self.assertFalse((self.data / "newer-only.dat").exists())
|
||||
self.assertEqual((self.data / "world/level.dat").read_text(), "old world")
|
||||
self.assertEqual((Path(result["previous_data"]) / "newer-only.dat").read_text(), "preserve me")
|
||||
|
||||
def test_traversal_rejected_before_mutation(self):
|
||||
self.make_archive({"world/level.dat": "old", "../outside": "bad"})
|
||||
with self.assertRaises(ValueError): module.restore(self.archive, self.data, self.root / "restore")
|
||||
self.assertTrue((self.data / "newer-only.dat").exists())
|
||||
|
||||
def test_link_rejected(self):
|
||||
self.make_archive({"world/level.dat": "old"}, link=True)
|
||||
with self.assertRaises(ValueError): module.verify(self.archive)
|
||||
|
||||
def test_release_mismatch_keeps_current_world(self):
|
||||
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": '{"minecraft":"26.2"}'})
|
||||
lock = self.root / "lock.json"
|
||||
lock.write_text('{"minecraft":"26.3"}')
|
||||
with self.assertRaisesRegex(ValueError, "lock differs"):
|
||||
module.restore(self.archive, self.data, self.root / "restore", lock)
|
||||
self.assertTrue((self.data / "newer-only.dat").exists())
|
||||
|
||||
def test_matching_release_can_restore(self):
|
||||
lock = self.root / "lock.json"
|
||||
lock.write_text('{"minecraft":"26.3"}')
|
||||
self.make_archive({"world/level.dat": "old", ".deployment-lock.json": lock.read_text()})
|
||||
module.restore(self.archive, self.data, self.root / "restore", lock)
|
||||
self.assertTrue((self.data / "world/level.dat").exists())
|
||||
|
||||
def test_truncated_archive_rejected(self):
|
||||
self.make_archive({"world/level.dat": "old"})
|
||||
self.archive.write_bytes(self.archive.read_bytes()[:-12])
|
||||
with self.assertRaises((EOFError, tarfile.TarError)):
|
||||
module.verify(self.archive)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
export default defineConfig({
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
test: { environment: "node", include: ["tests/**/*.test.ts"] },
|
||||
});
|
||||
Reference in new issue
Block a user