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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user