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