Phase4
This commit is contained in:
1
.turbo/cache/088dae477bba6b86-manifest.json
vendored
Normal file
1
.turbo/cache/088dae477bba6b86-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/088dae477bba6b86-meta.json
vendored
Normal file
1
.turbo/cache/088dae477bba6b86-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"088dae477bba6b86","duration":6291,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}
|
||||
BIN
.turbo/cache/088dae477bba6b86.tar.zst
vendored
Normal file
BIN
.turbo/cache/088dae477bba6b86.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/0b40f6f2704c48c7-manifest.json
vendored
Normal file
1
.turbo/cache/0b40f6f2704c48c7-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":739,"mtime_nanos":1782469606261313100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":546,"mtime_nanos":1782469606260191400,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782469606254548800,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782469631918619700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782469606255544300,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/0b40f6f2704c48c7-meta.json
vendored
Normal file
1
.turbo/cache/0b40f6f2704c48c7-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"0b40f6f2704c48c7","duration":6647,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/0b40f6f2704c48c7.tar.zst
vendored
Normal file
BIN
.turbo/cache/0b40f6f2704c48c7.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/3deec67040f3d455-manifest.json
vendored
Normal file
1
.turbo/cache/3deec67040f3d455-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/storage/.turbo/turbo-test.log":{"size":413,"mtime_nanos":1782469629407300600,"mode":420,"is_dir":false}},"order":["packages/storage/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/3deec67040f3d455-meta.json
vendored
Normal file
1
.turbo/cache/3deec67040f3d455-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"3deec67040f3d455","duration":4142,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/3deec67040f3d455.tar.zst
vendored
Normal file
BIN
.turbo/cache/3deec67040f3d455.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/43d7e0e4235fbc22-manifest.json
vendored
Normal file
1
.turbo/cache/43d7e0e4235fbc22-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/43d7e0e4235fbc22-meta.json
vendored
Normal file
1
.turbo/cache/43d7e0e4235fbc22-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"43d7e0e4235fbc22","duration":6609,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}
|
||||
BIN
.turbo/cache/43d7e0e4235fbc22.tar.zst
vendored
Normal file
BIN
.turbo/cache/43d7e0e4235fbc22.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/618e3985122b3059-manifest.json
vendored
Normal file
1
.turbo/cache/618e3985122b3059-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782469626542421200,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/618e3985122b3059-meta.json
vendored
Normal file
1
.turbo/cache/618e3985122b3059-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"618e3985122b3059","duration":1276,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/618e3985122b3059.tar.zst
vendored
Normal file
BIN
.turbo/cache/618e3985122b3059.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/68fd9b67d57b66b8-manifest.json
vendored
Normal file
1
.turbo/cache/68fd9b67d57b66b8-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/68fd9b67d57b66b8-meta.json
vendored
Normal file
1
.turbo/cache/68fd9b67d57b66b8-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"68fd9b67d57b66b8","duration":10715,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}
|
||||
BIN
.turbo/cache/68fd9b67d57b66b8.tar.zst
vendored
Normal file
BIN
.turbo/cache/68fd9b67d57b66b8.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/70b32a99fb5043c4-manifest.json
vendored
Normal file
1
.turbo/cache/70b32a99fb5043c4-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/storage/.turbo/turbo-build.log":{"size":107,"mtime_nanos":1782469564967043200,"mode":420,"is_dir":false},"packages/storage/dist/config.d.ts":{"size":922,"mtime_nanos":1782469564825687900,"mode":420,"is_dir":false},"packages/storage/dist/index.js":{"size":1654,"mtime_nanos":1782469564856008100,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.js.map":{"size":518,"mtime_nanos":1782469564862367100,"mode":420,"is_dir":false},"packages/storage/dist/index.d.ts.map":{"size":291,"mtime_nanos":1782469564858346600,"mode":420,"is_dir":false},"packages/storage/dist/client.js":{"size":2298,"mtime_nanos":1782469564839389300,"mode":420,"is_dir":false},"packages/storage/dist/config.d.ts.map":{"size":358,"mtime_nanos":1782469564822341100,"mode":420,"is_dir":false},"packages/storage/dist/index.d.ts":{"size":290,"mtime_nanos":1782469564859351000,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.d.ts.map":{"size":112,"mtime_nanos":1782469564865503900,"mode":420,"is_dir":false},"packages/storage/dist/index.js.map":{"size":339,"mtime_nanos":1782469564854781600,"mode":420,"is_dir":false},"packages/storage/dist/client.d.ts.map":{"size":960,"mtime_nanos":1782469564844386600,"mode":420,"is_dir":false},"packages/storage/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/storage/dist/config.js.map":{"size":967,"mtime_nanos":1782469564792460200,"mode":420,"is_dir":false},"packages/storage/dist/client.js.map":{"size":1685,"mtime_nanos":1782469564837692200,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.js":{"size":623,"mtime_nanos":1782469564863473800,"mode":420,"is_dir":false},"packages/storage/dist/config.js":{"size":1011,"mtime_nanos":1782469564793946800,"mode":420,"is_dir":false},"packages/storage/dist/client.d.ts":{"size":925,"mtime_nanos":1782469564845387400,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.d.ts":{"size":50,"mtime_nanos":1782469564866493700,"mode":420,"is_dir":false}},"order":["packages/storage/.turbo/turbo-build.log","packages/storage/dist","packages/storage/dist/client.d.ts","packages/storage/dist/client.d.ts.map","packages/storage/dist/client.js","packages/storage/dist/client.js.map","packages/storage/dist/config.d.ts","packages/storage/dist/config.d.ts.map","packages/storage/dist/config.js","packages/storage/dist/config.js.map","packages/storage/dist/index.d.ts","packages/storage/dist/index.d.ts.map","packages/storage/dist/index.js","packages/storage/dist/index.js.map","packages/storage/dist/keys.test.d.ts","packages/storage/dist/keys.test.d.ts.map","packages/storage/dist/keys.test.js","packages/storage/dist/keys.test.js.map"]}
|
||||
1
.turbo/cache/70b32a99fb5043c4-meta.json
vendored
Normal file
1
.turbo/cache/70b32a99fb5043c4-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"70b32a99fb5043c4","duration":4945,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}
|
||||
BIN
.turbo/cache/70b32a99fb5043c4.tar.zst
vendored
Normal file
BIN
.turbo/cache/70b32a99fb5043c4.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/715be903aa661ca3-manifest.json
vendored
Normal file
1
.turbo/cache/715be903aa661ca3-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":833,"mtime_nanos":1782469633368857500,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/715be903aa661ca3-meta.json
vendored
Normal file
1
.turbo/cache/715be903aa661ca3-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"715be903aa661ca3","duration":1440,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/715be903aa661ca3.tar.zst
vendored
Normal file
BIN
.turbo/cache/715be903aa661ca3.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/8d2682a0fba961c5-manifest.json
vendored
Normal file
1
.turbo/cache/8d2682a0fba961c5-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782469629194402000,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/8d2682a0fba961c5-meta.json
vendored
Normal file
1
.turbo/cache/8d2682a0fba961c5-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"8d2682a0fba961c5","duration":3939,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/8d2682a0fba961c5.tar.zst
vendored
Normal file
BIN
.turbo/cache/8d2682a0fba961c5.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/93fd20f8ea67baeb-manifest.json
vendored
Normal file
1
.turbo/cache/93fd20f8ea67baeb-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/93fd20f8ea67baeb-meta.json
vendored
Normal file
1
.turbo/cache/93fd20f8ea67baeb-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"93fd20f8ea67baeb","duration":2910,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}
|
||||
BIN
.turbo/cache/93fd20f8ea67baeb.tar.zst
vendored
Normal file
BIN
.turbo/cache/93fd20f8ea67baeb.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/ca0b4945c3af0662-manifest.json
vendored
Normal file
1
.turbo/cache/ca0b4945c3af0662-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/ca0b4945c3af0662-meta.json
vendored
Normal file
1
.turbo/cache/ca0b4945c3af0662-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"ca0b4945c3af0662","duration":4668,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}
|
||||
BIN
.turbo/cache/ca0b4945c3af0662.tar.zst
vendored
Normal file
BIN
.turbo/cache/ca0b4945c3af0662.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/cb989f8003d05922-manifest.json
vendored
Normal file
1
.turbo/cache/cb989f8003d05922-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782469570117618900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":557,"mtime_nanos":1782469570242487900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/cb989f8003d05922-meta.json
vendored
Normal file
1
.turbo/cache/cb989f8003d05922-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"cb989f8003d05922","duration":10209,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}
|
||||
BIN
.turbo/cache/cb989f8003d05922.tar.zst
vendored
Normal file
BIN
.turbo/cache/cb989f8003d05922.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d1370c34f934edc9-manifest.json
vendored
Normal file
1
.turbo/cache/d1370c34f934edc9-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"apps/api/.turbo/turbo-test.log":{"size":2454,"mtime_nanos":1782469642397105100,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}
|
||||
1
.turbo/cache/d1370c34f934edc9-meta.json
vendored
Normal file
1
.turbo/cache/d1370c34f934edc9-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"d1370c34f934edc9","duration":10467,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}
|
||||
BIN
.turbo/cache/d1370c34f934edc9.tar.zst
vendored
Normal file
BIN
.turbo/cache/d1370c34f934edc9.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/e23c3ddbaecd7f7a-manifest.json
vendored
Normal file
1
.turbo/cache/e23c3ddbaecd7f7a-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/e23c3ddbaecd7f7a-meta.json
vendored
Normal file
1
.turbo/cache/e23c3ddbaecd7f7a-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"e23c3ddbaecd7f7a","duration":1884,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}
|
||||
BIN
.turbo/cache/e23c3ddbaecd7f7a.tar.zst
vendored
Normal file
BIN
.turbo/cache/e23c3ddbaecd7f7a.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/e6b183182d9648a1-manifest.json
vendored
Normal file
1
.turbo/cache/e6b183182d9648a1-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782469606254548800,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782469606374958300,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts.map":{"size":546,"mtime_nanos":1782469606260191400,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782469606255544300,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":739,"mtime_nanos":1782469606261313100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||
1
.turbo/cache/e6b183182d9648a1-meta.json
vendored
Normal file
1
.turbo/cache/e6b183182d9648a1-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"e6b183182d9648a1","duration":7621,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}
|
||||
BIN
.turbo/cache/e6b183182d9648a1.tar.zst
vendored
Normal file
BIN
.turbo/cache/e6b183182d9648a1.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/fc1229a0da7d5e9a-manifest.json
vendored
Normal file
1
.turbo/cache/fc1229a0da7d5e9a-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/fc1229a0da7d5e9a-meta.json
vendored
Normal file
1
.turbo/cache/fc1229a0da7d5e9a-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"fc1229a0da7d5e9a","duration":37281,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}
|
||||
BIN
.turbo/cache/fc1229a0da7d5e9a.tar.zst
vendored
Normal file
BIN
.turbo/cache/fc1229a0da7d5e9a.tar.zst
vendored
Normal file
Binary file not shown.
14
README.md
14
README.md
@@ -92,21 +92,21 @@ make build
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
**Phase 3** (Konsole, Dateien, Einstellungen) — abgeschlossen.
|
||||
**Phase 4** (Welten & Backups) — abgeschlossen.
|
||||
|
||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||
|
||||
### Server-Verwaltung im Panel
|
||||
|
||||
Nach dem Start eines Servers stehen unter `/servers/[id]` folgende Bereiche zur Verfügung:
|
||||
|
||||
| Tab | Funktion |
|
||||
|-----|----------|
|
||||
| Übersicht | Status, Start/Stop/Restart, Verbindungsadresse |
|
||||
| Konsole | Live-Logs (WebSocket), Befehle per RCON (nur `RUNNING`) |
|
||||
| Dateien | Verzeichnisliste, Dateien lesen/schreiben |
|
||||
| Eigenschaften | `server.properties` bearbeiten, optional Neustart |
|
||||
| Spieler | Online-Spieler und Whitelist (Lesen) |
|
||||
| Konsole | Live-Logs, RCON-Befehle |
|
||||
| Dateien | Verzeichnisliste, Dateien bearbeiten |
|
||||
| Eigenschaften | `server.properties` |
|
||||
| Spieler | Online-Spieler, Whitelist |
|
||||
| **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) |
|
||||
| **Backups** | Manuell/geplant, Restore, Retention |
|
||||
|
||||
### Minecraft-Server lokal testen
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
"@nestjs/core": "^11.0.7",
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
|
||||
@@ -320,7 +320,14 @@ export class NodesService {
|
||||
normalized === 'files.list' ||
|
||||
normalized === 'files.read' ||
|
||||
normalized === 'files.write' ||
|
||||
normalized === 'command'
|
||||
normalized === 'command' ||
|
||||
normalized === 'backup.prepare' ||
|
||||
normalized === 'backup.release' ||
|
||||
normalized === 'world.validate' ||
|
||||
normalized === 'world.archive' ||
|
||||
normalized === 'world.replace' ||
|
||||
normalized === 'storage.upload' ||
|
||||
normalized === 'storage.download'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
102
apps/api/src/servers/backups/backups.controller.ts
Normal file
102
apps/api/src/servers/backups/backups.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
createBackupRequestSchema,
|
||||
restoreBackupRequestSchema,
|
||||
updateBackupScheduleSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { BackupsService } from './backups.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/backups')
|
||||
@UseGuards(SessionGuard)
|
||||
export class BackupsController {
|
||||
constructor(private readonly backupsService: BackupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List server backups' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.backupsService.listBackups(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a manual backup' })
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = createBackupRequestSchema.parse(body ?? {});
|
||||
return this.backupsService.createBackup(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Get('schedule')
|
||||
@ApiOperation({ summary: 'Get backup schedule' })
|
||||
getSchedule(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.backupsService.getSchedule(user.id, serverId);
|
||||
}
|
||||
|
||||
@Patch('schedule')
|
||||
@ApiOperation({ summary: 'Update backup schedule' })
|
||||
updateSchedule(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = updateBackupScheduleSchema.parse(body);
|
||||
return this.backupsService.updateSchedule(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Get(':backupId/download-url')
|
||||
@ApiOperation({ summary: 'Get a presigned download URL for a backup' })
|
||||
downloadUrl(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
) {
|
||||
return this.backupsService.getDownloadUrl(user.id, serverId, backupId);
|
||||
}
|
||||
|
||||
@Post(':backupId/restore')
|
||||
@ApiOperation({ summary: 'Restore a backup' })
|
||||
restore(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
restoreBackupRequestSchema.parse(body);
|
||||
return this.backupsService.restoreBackup(user.id, serverId, backupId);
|
||||
}
|
||||
|
||||
@Delete(':backupId')
|
||||
@ApiOperation({ summary: 'Delete a backup' })
|
||||
deleteBackup(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
) {
|
||||
return this.backupsService.deleteBackup(user.id, serverId, backupId);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/servers/backups/backups.module.ts
Normal file
34
apps/api/src/servers/backups/backups.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { BackupsController } from './backups.controller';
|
||||
import { BackupsService, SERVER_BACKUPS_QUEUE } from './backups.service';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [BackupsController],
|
||||
providers: [
|
||||
BackupsService,
|
||||
ServerStateService,
|
||||
{
|
||||
provide: SERVER_BACKUPS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('server-backups', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [BackupsService, SERVER_BACKUPS_QUEUE],
|
||||
})
|
||||
export class BackupsModule {}
|
||||
341
apps/api/src/servers/backups/backups.service.ts
Normal file
341
apps/api/src/servers/backups/backups.service.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type { ServerBackup } from '@hexahost/database';
|
||||
import type {
|
||||
BackupActionResponse,
|
||||
BackupDownloadUrlResponse,
|
||||
BackupListResponse,
|
||||
BackupResponse,
|
||||
BackupRestoreResponse,
|
||||
BackupSchedule,
|
||||
CreateBackupRequest,
|
||||
UpdateBackupSchedule,
|
||||
} from '@hexahost/contracts';
|
||||
import {
|
||||
createPresignedGetUrl,
|
||||
createS3Client,
|
||||
deleteStoredObject,
|
||||
loadStorageConfig,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
export const SERVER_BACKUPS_QUEUE = Symbol('SERVER_BACKUPS_QUEUE');
|
||||
|
||||
@Injectable()
|
||||
export class BackupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async listBackups(userId: string, serverId: string): Promise<BackupListResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backups = await this.prisma.serverBackup.findMany({
|
||||
where: {
|
||||
serverId,
|
||||
status: { not: 'DELETED' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { backups: backups.map((backup) => this.toResponse(backup)) };
|
||||
}
|
||||
|
||||
async createBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: CreateBackupRequest,
|
||||
): Promise<BackupActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot create backup while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previousStatus = server.status;
|
||||
const backup = await this.prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'MANUAL',
|
||||
status: 'PENDING',
|
||||
label: input.label ?? 'Manual backup',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'BACKING_UP' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: previousStatus,
|
||||
toStatus: 'BACKING_UP',
|
||||
reason: 'Manual backup requested',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.backupsQueue.add('create-backup', {
|
||||
backupId: backup.id,
|
||||
serverId,
|
||||
previousStatus,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.create',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId: backup.id },
|
||||
});
|
||||
|
||||
return {
|
||||
backup: this.toResponse(backup),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupDownloadUrlResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId, status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
if (!backup.s3Key) {
|
||||
throw new BadRequestException('Backup is not stored yet');
|
||||
}
|
||||
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const downloadUrl = await createPresignedGetUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
backup.s3Key,
|
||||
15 * 60,
|
||||
);
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId },
|
||||
});
|
||||
|
||||
if (backup.s3Key) {
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
await deleteStoredObject(client, config.S3_BUCKET, backup.s3Key);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.delete',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId },
|
||||
});
|
||||
|
||||
return this.toResponse(updated);
|
||||
}
|
||||
|
||||
async restoreBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupRestoreResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (server.status === 'RUNNING') {
|
||||
throw new BadRequestException('Server must be stopped before restore');
|
||||
}
|
||||
|
||||
if (server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot restore while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId, status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
const restore = await this.prisma.backupRestore.create({
|
||||
data: {
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'RESTORING' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: 'STOPPED',
|
||||
toStatus: 'RESTORING',
|
||||
reason: 'Restore requested',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.backupsQueue.add('restore-backup', {
|
||||
restoreId: restore.id,
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.restore',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId, restoreId: restore.id },
|
||||
});
|
||||
|
||||
return {
|
||||
restore: {
|
||||
id: restore.id,
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
safetyBackupId: restore.safetyBackupId,
|
||||
status: restore.status,
|
||||
failureReason: restore.failureReason,
|
||||
createdAt: restore.createdAt.toISOString(),
|
||||
completedAt: restore.completedAt?.toISOString() ?? null,
|
||||
},
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getSchedule(userId: string, serverId: string): Promise<BackupSchedule> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const schedule = await this.prisma.backupSchedule.upsert({
|
||||
where: { serverId },
|
||||
create: {
|
||||
serverId,
|
||||
enabled: false,
|
||||
intervalHours: 24,
|
||||
retentionCount: 5,
|
||||
nextRunAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return this.toScheduleResponse(schedule);
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: UpdateBackupSchedule,
|
||||
): Promise<BackupSchedule> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const schedule = await this.prisma.backupSchedule.upsert({
|
||||
where: { serverId },
|
||||
create: {
|
||||
serverId,
|
||||
enabled: input.enabled ?? false,
|
||||
intervalHours: input.intervalHours ?? 24,
|
||||
retentionCount: input.retentionCount ?? 5,
|
||||
nextRunAt: new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000),
|
||||
},
|
||||
update: {
|
||||
enabled: input.enabled,
|
||||
intervalHours: input.intervalHours,
|
||||
retentionCount: input.retentionCount,
|
||||
nextRunAt:
|
||||
input.enabled === true
|
||||
? new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toScheduleResponse(schedule);
|
||||
}
|
||||
|
||||
private toResponse(backup: ServerBackup): BackupResponse {
|
||||
return {
|
||||
id: backup.id,
|
||||
serverId: backup.serverId,
|
||||
type: backup.type,
|
||||
status: backup.status,
|
||||
s3Key: backup.s3Key,
|
||||
sizeBytes: backup.sizeBytes?.toString() ?? null,
|
||||
sha256: backup.sha256,
|
||||
label: backup.label,
|
||||
failureReason: backup.failureReason,
|
||||
createdAt: backup.createdAt.toISOString(),
|
||||
completedAt: backup.completedAt?.toISOString() ?? null,
|
||||
expiresAt: backup.expiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private toScheduleResponse(schedule: {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
retentionCount: number;
|
||||
nextRunAt: Date | null;
|
||||
lastRunAt: Date | null;
|
||||
}): BackupSchedule {
|
||||
return {
|
||||
enabled: schedule.enabled,
|
||||
intervalHours: schedule.intervalHours,
|
||||
retentionCount: schedule.retentionCount,
|
||||
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
|
||||
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
|
||||
DRAFT: ['PROVISIONING', 'DELETING'],
|
||||
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
|
||||
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
|
||||
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
|
||||
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
|
||||
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'DELETING'],
|
||||
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
|
||||
RESTORING: ['STOPPED', 'ERROR'],
|
||||
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
|
||||
DELETING: ['DELETED'],
|
||||
DELETED: [],
|
||||
@@ -21,6 +23,8 @@ const IN_PROGRESS_STATUSES: GameServerStatus[] = [
|
||||
'INSTALLING',
|
||||
'STARTING',
|
||||
'STOPPING',
|
||||
'BACKING_UP',
|
||||
'RESTORING',
|
||||
'DELETING',
|
||||
];
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { PropertiesModule } from './properties/properties.module';
|
||||
import { ServerStateService } from './server-state.service';
|
||||
@@ -28,6 +30,8 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
forwardRef(() => WorldsModule),
|
||||
forwardRef(() => FilesModule),
|
||||
forwardRef(() => PropertiesModule),
|
||||
forwardRef(() => PlayersModule),
|
||||
|
||||
@@ -314,6 +314,7 @@ export class ServersService {
|
||||
hostPort: server.hostPort,
|
||||
containerId: server.containerId,
|
||||
dataPath: server.dataPath,
|
||||
activeWorldName: server.activeWorldName,
|
||||
ramMb: server.ramMb,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
|
||||
63
apps/api/src/servers/worlds/worlds.controller.ts
Normal file
63
apps/api/src/servers/worlds/worlds.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { initiateWorldUploadSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { WorldsService } from './worlds.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/worlds')
|
||||
@UseGuards(SessionGuard)
|
||||
export class WorldsController {
|
||||
constructor(private readonly worldsService: WorldsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get world overview' })
|
||||
info(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.worldsService.getWorldInfo(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post('uploads')
|
||||
@ApiOperation({ summary: 'Initiate a world upload' })
|
||||
initiateUpload(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = initiateWorldUploadSchema.parse(body);
|
||||
return this.worldsService.initiateUpload(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Post('uploads/:uploadId/complete')
|
||||
@ApiOperation({ summary: 'Complete a world upload and apply it' })
|
||||
completeUpload(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('uploadId', ParseUUIDPipe) uploadId: string,
|
||||
) {
|
||||
return this.worldsService.completeUpload(user.id, serverId, uploadId);
|
||||
}
|
||||
|
||||
@Post('download')
|
||||
@ApiOperation({ summary: 'Create a backup for world download' })
|
||||
download(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.worldsService.requestWorldDownload(user.id, serverId);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/servers/worlds/worlds.module.ts
Normal file
17
apps/api/src/servers/worlds/worlds.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { BackupsModule } from '../backups/backups.module';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
import { WorldsController } from './worlds.controller';
|
||||
import { WorldsService } from './worlds.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, NodeBridgeModule, BackupsModule],
|
||||
controllers: [WorldsController],
|
||||
providers: [WorldsService, ServerStateService],
|
||||
})
|
||||
export class WorldsModule {}
|
||||
250
apps/api/src/servers/worlds/worlds.service.ts
Normal file
250
apps/api/src/servers/worlds/worlds.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
InitiateWorldUpload,
|
||||
WorldDownloadUrlResponse,
|
||||
WorldInfoResponse,
|
||||
WorldUploadInitResponse,
|
||||
WorldUploadResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer, WorldUpload } from '@hexahost/database';
|
||||
import {
|
||||
createPresignedPutUrl,
|
||||
createS3Client,
|
||||
headStoredObject,
|
||||
loadStorageConfig,
|
||||
worldUploadObjectKey,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
import { SERVER_BACKUPS_QUEUE } from '../backups/backups.service';
|
||||
|
||||
const WORLD_MANAGE_STATUSES = new Set<GameServer['status']>(['STOPPED']);
|
||||
|
||||
@Injectable()
|
||||
export class WorldsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async getWorldInfo(userId: string, serverId: string): Promise<WorldInfoResponse> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.world.validate',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
worldName: server.activeWorldName,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to load world info',
|
||||
);
|
||||
}
|
||||
|
||||
const result = response.result as {
|
||||
worlds?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
hasLevelDat: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
activeWorldName: server.activeWorldName,
|
||||
worlds: (result.worlds ?? []).map((world) => ({
|
||||
name: world.name,
|
||||
path: world.path,
|
||||
sizeBytes: String(world.sizeBytes),
|
||||
hasLevelDat: world.hasLevelDat,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async initiateUpload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: InitiateWorldUpload,
|
||||
): Promise<WorldUploadInitResponse> {
|
||||
const server = await this.requireStoppedServer(userId, serverId);
|
||||
const uploadId = randomUUID();
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const s3Key = worldUploadObjectKey(serverId, uploadId);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const uploadUrl = await createPresignedPutUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
s3Key,
|
||||
60 * 60,
|
||||
'application/gzip',
|
||||
);
|
||||
|
||||
await this.prisma.worldUpload.create({
|
||||
data: {
|
||||
id: uploadId,
|
||||
serverId,
|
||||
status: 'UPLOADING',
|
||||
s3Key,
|
||||
worldName: input.worldName,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
uploadUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
s3Key,
|
||||
};
|
||||
}
|
||||
|
||||
async completeUpload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
uploadId: string,
|
||||
): Promise<WorldUploadResponse> {
|
||||
const server = await this.requireStoppedServer(userId, serverId);
|
||||
const upload = await this.prisma.worldUpload.findFirstOrThrow({
|
||||
where: { id: uploadId, serverId },
|
||||
});
|
||||
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const head = await headStoredObject(client, config.S3_BUCKET, upload.s3Key);
|
||||
|
||||
if (head.sizeBytes <= 0) {
|
||||
throw new BadRequestException('Uploaded world archive is empty');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.worldUpload.update({
|
||||
where: { id: uploadId },
|
||||
data: {
|
||||
status: 'READY',
|
||||
sizeBytes: BigInt(head.sizeBytes),
|
||||
},
|
||||
});
|
||||
|
||||
await this.backupsQueue.add('apply-world-upload', {
|
||||
uploadId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.world.upload',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { uploadId, worldName: upload.worldName },
|
||||
});
|
||||
|
||||
return this.toUploadResponse(updated);
|
||||
}
|
||||
|
||||
async requestWorldDownload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<WorldDownloadUrlResponse> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
|
||||
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot download world while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const backup = await this.prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'MANUAL',
|
||||
status: 'PENDING',
|
||||
label: 'World download',
|
||||
},
|
||||
});
|
||||
|
||||
const previousStatus = server.status as 'RUNNING' | 'STOPPED';
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'BACKING_UP' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: server.status,
|
||||
toStatus: 'BACKING_UP',
|
||||
reason: 'World download backup started',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.backupsQueue.add('create-backup', {
|
||||
backupId: backup.id,
|
||||
serverId,
|
||||
previousStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
backupId: backup.id,
|
||||
downloadUrl: '',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async requireNode(serverId: string, userId: string): Promise<GameServer> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
private async requireStoppedServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!WORLD_MANAGE_STATUSES.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
`World changes require server to be STOPPED (current: ${server.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private toUploadResponse(upload: WorldUpload): WorldUploadResponse {
|
||||
return {
|
||||
id: upload.id,
|
||||
serverId: upload.serverId,
|
||||
status: upload.status,
|
||||
worldName: upload.worldName,
|
||||
sizeBytes: upload.sizeBytes?.toString() ?? null,
|
||||
failureReason: upload.failureReason,
|
||||
createdAt: upload.createdAt.toISOString(),
|
||||
completedAt: upload.completedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
159
apps/node-agent/internal/archive/archive.go
Normal file
159
apps/node-agent/internal/archive/archive.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateTarGz archives sourceDir into outputPath and returns checksum and size.
|
||||
func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, err error) {
|
||||
sourceDir = filepath.Clean(sourceDir)
|
||||
|
||||
info, err := os.Stat(sourceDir)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", 0, fmt.Errorf("source is not a directory")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
writer := io.MultiWriter(file, hasher)
|
||||
gzipWriter := gzip.NewWriter(writer)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
|
||||
err = filepath.Walk(sourceDir, func(path string, entry os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel = filepath.ToSlash(rel)
|
||||
header, err := tar.FileInfoHeader(entry, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
_, err = io.Copy(tarWriter, sourceFile)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
|
||||
}
|
||||
|
||||
// ExtractTarGz extracts archivePath into destDir.
|
||||
func ExtractTarGz(archivePath, destDir string) error {
|
||||
destDir = filepath.Clean(destDir)
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := filepath.Join(destDir, filepath.FromSlash(header.Name))
|
||||
if !strings.HasPrefix(filepath.Clean(target), destDir+string(os.PathSeparator)) && filepath.Clean(target) != destDir {
|
||||
return fmt.Errorf("archive entry escapes destination: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(out, tarReader); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,10 @@ const (
|
||||
TypeServerFilesWrite MessageType = "server.files.write"
|
||||
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
|
||||
TypeServerWorldValidate MessageType = "server.world.validate"
|
||||
TypeServerWorldArchive MessageType = "server.world.archive"
|
||||
TypeServerWorldReplace MessageType = "server.world.replace"
|
||||
TypeServerStorageUpload MessageType = "server.storage.upload"
|
||||
TypeServerStorageDownload MessageType = "server.storage.download"
|
||||
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
|
||||
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
|
||||
)
|
||||
@@ -72,6 +76,10 @@ var controlToAgentTypes = map[MessageType]struct{}{
|
||||
TypeServerFilesWrite: {},
|
||||
TypeServerFilesUploadDone: {},
|
||||
TypeServerWorldValidate: {},
|
||||
TypeServerWorldArchive: {},
|
||||
TypeServerWorldReplace: {},
|
||||
TypeServerStorageUpload: {},
|
||||
TypeServerStorageDownload: {},
|
||||
TypeServerMetricsSub: {},
|
||||
TypeServerLogsSubscribe: {},
|
||||
}
|
||||
@@ -178,6 +186,40 @@ type ServerFilesWritePayload struct {
|
||||
Create bool `json:"create"`
|
||||
}
|
||||
|
||||
// ServerWorldValidatePayload validates a Minecraft world directory.
|
||||
type ServerWorldValidatePayload struct {
|
||||
OperationMeta
|
||||
WorldName string `json:"worldName"`
|
||||
}
|
||||
|
||||
// ServerWorldArchivePayload archives a world directory.
|
||||
type ServerWorldArchivePayload struct {
|
||||
OperationMeta
|
||||
WorldName string `json:"worldName"`
|
||||
ArchiveID string `json:"archiveId"`
|
||||
}
|
||||
|
||||
// ServerStorageUploadPayload uploads a local file to a presigned URL.
|
||||
type ServerStorageUploadPayload struct {
|
||||
OperationMeta
|
||||
LocalPath string `json:"localPath"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
}
|
||||
|
||||
// ServerStorageDownloadPayload downloads a remote file to a local path.
|
||||
type ServerStorageDownloadPayload struct {
|
||||
OperationMeta
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
LocalPath string `json:"localPath"`
|
||||
}
|
||||
|
||||
// ServerWorldReplacePayload replaces a world from a local archive.
|
||||
type ServerWorldReplacePayload struct {
|
||||
OperationMeta
|
||||
WorldName string `json:"worldName"`
|
||||
ArchivePath string `json:"archivePath"`
|
||||
}
|
||||
|
||||
// FileEntry describes a file or directory in a list response.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -5,11 +5,16 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/archive"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/files"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/transfer"
|
||||
worldpkg "github.com/hexahost/gamecloud/node-agent/internal/world"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -352,6 +357,219 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
|
||||
})
|
||||
}
|
||||
|
||||
// HandleBackupPrepare runs save-off and save-all flush via RCON.
|
||||
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "backup.prepare", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if rec.State != ServerStateRunning {
|
||||
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"skipped": "not_running"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, command := range []string{"save-off", "save-all flush"} {
|
||||
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, command); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "backup.prepare", "BACKUP_PREPARE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// HandleBackupRelease runs save-on via RCON.
|
||||
func (m *Manager) HandleBackupRelease(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "backup.release", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if rec.State != ServerStateRunning {
|
||||
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"skipped": "not_running"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, "save-on"); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "backup.release", "BACKUP_RELEASE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// HandleWorldValidate validates a world directory and lists worlds.
|
||||
func (m *Manager) HandleWorldValidate(ctx context.Context, correlationID string, payload protocol.ServerWorldValidatePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "world.validate", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
worldName := payload.WorldName
|
||||
if worldName == "" {
|
||||
worldName = "world"
|
||||
}
|
||||
|
||||
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
worlds, err := worldpkg.ListWorlds(rec.DataPath, worldName)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_LIST_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "world.validate", map[string]any{
|
||||
"valid": true,
|
||||
"worldName": worldName,
|
||||
"worlds": worlds,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWorldArchive creates a tar.gz archive of a world directory.
|
||||
func (m *Manager) HandleWorldArchive(ctx context.Context, correlationID string, payload protocol.ServerWorldArchivePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "world.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
worldName := payload.WorldName
|
||||
if worldName == "" {
|
||||
worldName = "world"
|
||||
}
|
||||
|
||||
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.archive", "WORLD_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
archiveDir := filepath.Join(rec.DataPath, ".hgc", "archives")
|
||||
archiveID := payload.ArchiveID
|
||||
if archiveID == "" {
|
||||
archiveID = meta.ServerID
|
||||
}
|
||||
outputPath := filepath.Join(archiveDir, archiveID+".tar.gz")
|
||||
sourceDir := filepath.Join(rec.DataPath, worldName)
|
||||
|
||||
checksum, size, err := archive.CreateTarGz(sourceDir, outputPath)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.archive", "ARCHIVE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "world.archive", map[string]any{
|
||||
"localPath": outputPath,
|
||||
"sha256": checksum,
|
||||
"sizeBytes": size,
|
||||
"worldName": worldName,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStorageUpload uploads a local archive to object storage.
|
||||
func (m *Manager) HandleStorageUpload(ctx context.Context, correlationID string, payload protocol.ServerStorageUploadPayload) {
|
||||
meta := payload.OperationMeta
|
||||
|
||||
if err := transfer.UploadFile(payload.LocalPath, payload.UploadURL); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "storage.upload", "UPLOAD_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "storage.upload", map[string]string{
|
||||
"localPath": payload.LocalPath,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStorageDownload downloads an archive from object storage.
|
||||
func (m *Manager) HandleStorageDownload(ctx context.Context, correlationID string, payload protocol.ServerStorageDownloadPayload) {
|
||||
meta := payload.OperationMeta
|
||||
|
||||
if err := transfer.DownloadFile(payload.DownloadURL, payload.LocalPath); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "storage.download", "DOWNLOAD_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "storage.download", map[string]string{
|
||||
"localPath": payload.LocalPath,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWorldReplace replaces a world directory from a local archive.
|
||||
func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string, payload protocol.ServerWorldReplacePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if rec.State == ServerStateRunning {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_RUNNING", "server must be stopped before world replace")
|
||||
return
|
||||
}
|
||||
|
||||
worldName := payload.WorldName
|
||||
if worldName == "" {
|
||||
worldName = "world"
|
||||
}
|
||||
|
||||
worldDir := filepath.Join(rec.DataPath, worldName)
|
||||
tempDir := filepath.Join(rec.DataPath, ".hgc", "restore", meta.ServerID)
|
||||
if err := os.RemoveAll(tempDir); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(tempDir, 0o755); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := archive.ExtractTarGz(payload.ArchivePath, tempDir); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "EXTRACT_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
extractedWorld := filepath.Join(tempDir, worldName)
|
||||
if info, err := os.Stat(extractedWorld); err != nil || !info.IsDir() {
|
||||
extractedWorld = tempDir
|
||||
}
|
||||
|
||||
if err := worldpkg.ValidateWorld(filepath.Dir(extractedWorld), filepath.Base(extractedWorld)); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "WORLD_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
backupDir := worldDir + ".bak"
|
||||
_ = os.RemoveAll(backupDir)
|
||||
if _, err := os.Stat(worldDir); err == nil {
|
||||
if err := os.Rename(worldDir, backupDir); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(extractedWorld, worldDir); err != nil {
|
||||
_ = os.Rename(backupDir, worldDir)
|
||||
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.RemoveAll(tempDir)
|
||||
_ = os.RemoveAll(backupDir)
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "world.replace", map[string]string{
|
||||
"worldName": worldName,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
73
apps/node-agent/internal/transfer/transfer.go
Normal file
73
apps/node-agent/internal/transfer/transfer.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadFile PUTs a local file to a presigned URL.
|
||||
func UploadFile(localPath, uploadURL string) error {
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPut, uploadURL, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.ContentLength = stat.Size()
|
||||
request.Header.Set("Content-Type", "application/gzip")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Minute}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("upload failed with status %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFile GETs a remote file to a local path.
|
||||
func DownloadFile(downloadURL, localPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Minute}
|
||||
response, err := client.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("download failed with status %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
|
||||
file, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, response.Body)
|
||||
return err
|
||||
}
|
||||
114
apps/node-agent/internal/world/world.go
Normal file
114
apps/node-agent/internal/world/world.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Summary describes a discovered Minecraft world directory.
|
||||
type Summary struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
HasLevelDat bool `json:"hasLevelDat"`
|
||||
}
|
||||
|
||||
// ValidateWorld checks that a world directory contains level.dat.
|
||||
func ValidateWorld(dataPath, worldName string) error {
|
||||
worldName = strings.TrimSpace(worldName)
|
||||
if worldName == "" || strings.Contains(worldName, "..") || strings.ContainsAny(worldName, `/\`) {
|
||||
return fmt.Errorf("invalid world name")
|
||||
}
|
||||
|
||||
worldDir := filepath.Join(dataPath, worldName)
|
||||
levelDat := filepath.Join(worldDir, "level.dat")
|
||||
|
||||
info, err := os.Stat(levelDat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("level.dat not found in world directory")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("level.dat is not a file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWorlds scans the server data directory for world folders.
|
||||
func ListWorlds(dataPath, activeWorldName string) ([]Summary, error) {
|
||||
entries, err := os.ReadDir(dataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
worlds := make([]Summary, 0)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
worldPath := filepath.Join(dataPath, entry.Name())
|
||||
levelDat := filepath.Join(worldPath, "level.dat")
|
||||
hasLevelDat := false
|
||||
if info, err := os.Stat(levelDat); err == nil && !info.IsDir() {
|
||||
hasLevelDat = true
|
||||
}
|
||||
|
||||
size, err := dirSize(worldPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
worlds = append(worlds, Summary{
|
||||
Name: entry.Name(),
|
||||
Path: entry.Name(),
|
||||
SizeBytes: size,
|
||||
HasLevelDat: hasLevelDat,
|
||||
})
|
||||
}
|
||||
|
||||
if activeWorldName != "" {
|
||||
found := false
|
||||
for _, world := range worlds {
|
||||
if world.Name == activeWorldName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if err := ValidateWorld(dataPath, activeWorldName); err == nil {
|
||||
size, sizeErr := dirSize(filepath.Join(dataPath, activeWorldName))
|
||||
if sizeErr != nil {
|
||||
return nil, sizeErr
|
||||
}
|
||||
worlds = append(worlds, Summary{
|
||||
Name: activeWorldName,
|
||||
Path: activeWorldName,
|
||||
SizeBytes: size,
|
||||
HasLevelDat: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return worlds, nil
|
||||
}
|
||||
|
||||
func dirSize(root string) (int64, error) {
|
||||
var total int64
|
||||
|
||||
err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return total, err
|
||||
}
|
||||
@@ -314,6 +314,62 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
}
|
||||
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerBackupPrepare:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
c.log.Warn("decode server.backup.prepare", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleBackupPrepare(ctx, env.MessageID, meta)
|
||||
|
||||
case protocol.TypeServerBackupRelease:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
c.log.Warn("decode server.backup.release", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleBackupRelease(ctx, env.MessageID, meta)
|
||||
|
||||
case protocol.TypeServerWorldValidate:
|
||||
var payload protocol.ServerWorldValidatePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.world.validate", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleWorldValidate(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerWorldArchive:
|
||||
var payload protocol.ServerWorldArchivePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.world.archive", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleWorldArchive(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerStorageUpload:
|
||||
var payload protocol.ServerStorageUploadPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.storage.upload", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStorageUpload(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerStorageDownload:
|
||||
var payload protocol.ServerStorageDownloadPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.storage.download", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStorageDownload(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerWorldReplace:
|
||||
var payload protocol.ServerWorldReplacePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.world.replace", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,9 @@
|
||||
"console": "Konsole",
|
||||
"files": "Dateien",
|
||||
"properties": "Eigenschaften",
|
||||
"players": "Spieler"
|
||||
"players": "Spieler",
|
||||
"worlds": "Welten",
|
||||
"backups": "Backups"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Wird geladen…",
|
||||
@@ -274,6 +276,47 @@
|
||||
"errors": {
|
||||
"loadFailed": "Spielerliste konnte nicht geladen werden."
|
||||
}
|
||||
},
|
||||
"worlds": {
|
||||
"loading": "Wird geladen…",
|
||||
"loadError": "Weltinformationen konnten nicht geladen werden.",
|
||||
"activeWorld": "Aktive Welt: {name}",
|
||||
"stoppedHint": "Upload und Ersetzen erfordern einen gestoppten Server.",
|
||||
"worldName": "Weltname",
|
||||
"uploadArchive": "Welt-Archiv (.tar.gz)",
|
||||
"uploadError": "Welt-Upload fehlgeschlagen.",
|
||||
"download": "Welt herunterladen",
|
||||
"downloadError": "Download konnte nicht gestartet werden.",
|
||||
"downloadPending": "Backup wird erstellt. Versuchen Sie den Download in Kürze erneut.",
|
||||
"empty": "Keine Welten gefunden.",
|
||||
"validWorld": "Gültige Welt",
|
||||
"invalidWorld": "Ungültig",
|
||||
"active": "Aktiv"
|
||||
},
|
||||
"backups": {
|
||||
"loading": "Wird geladen…",
|
||||
"loadError": "Backups konnten nicht geladen werden.",
|
||||
"create": "Backup erstellen",
|
||||
"manualLabel": "Manuelles Backup",
|
||||
"createError": "Backup konnte nicht erstellt werden.",
|
||||
"download": "Download",
|
||||
"downloadError": "Download-URL konnte nicht erstellt werden.",
|
||||
"restore": "Wiederherstellen",
|
||||
"restoreConfirm": "Welt wirklich aus diesem Backup wiederherstellen? Der Server muss gestoppt sein.",
|
||||
"restoreError": "Wiederherstellung fehlgeschlagen.",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Backup wirklich löschen?",
|
||||
"deleteError": "Backup konnte nicht gelöscht werden.",
|
||||
"enableSchedule": "Geplante Backups aktivieren",
|
||||
"disableSchedule": "Geplante Backups deaktivieren",
|
||||
"scheduleError": "Zeitplan konnte nicht aktualisiert werden.",
|
||||
"scheduleInfo": "Intervall: alle {hours}h · Aufbewahrung: {retention} Backups",
|
||||
"label": "Bezeichnung",
|
||||
"status": "Status",
|
||||
"size": "Größe",
|
||||
"created": "Erstellt",
|
||||
"actions": "Aktionen",
|
||||
"empty": "Noch keine Backups vorhanden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,9 @@
|
||||
"console": "Console",
|
||||
"files": "Files",
|
||||
"properties": "Properties",
|
||||
"players": "Players"
|
||||
"players": "Players",
|
||||
"worlds": "Worlds",
|
||||
"backups": "Backups"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Loading…",
|
||||
@@ -274,6 +276,47 @@
|
||||
"errors": {
|
||||
"loadFailed": "Could not load player list."
|
||||
}
|
||||
},
|
||||
"worlds": {
|
||||
"loading": "Loading…",
|
||||
"loadError": "Could not load world information.",
|
||||
"activeWorld": "Active world: {name}",
|
||||
"stoppedHint": "Upload and replace require a stopped server.",
|
||||
"worldName": "World name",
|
||||
"uploadArchive": "World archive (.tar.gz)",
|
||||
"uploadError": "World upload failed.",
|
||||
"download": "Download world",
|
||||
"downloadError": "Could not start download.",
|
||||
"downloadPending": "Backup is being created. Try the download again shortly.",
|
||||
"empty": "No worlds found.",
|
||||
"validWorld": "Valid world",
|
||||
"invalidWorld": "Invalid",
|
||||
"active": "Active"
|
||||
},
|
||||
"backups": {
|
||||
"loading": "Loading…",
|
||||
"loadError": "Could not load backups.",
|
||||
"create": "Create backup",
|
||||
"manualLabel": "Manual backup",
|
||||
"createError": "Could not create backup.",
|
||||
"download": "Download",
|
||||
"downloadError": "Could not create download URL.",
|
||||
"restore": "Restore",
|
||||
"restoreConfirm": "Restore the world from this backup? The server must be stopped.",
|
||||
"restoreError": "Restore failed.",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this backup?",
|
||||
"deleteError": "Could not delete backup.",
|
||||
"enableSchedule": "Enable scheduled backups",
|
||||
"disableSchedule": "Disable scheduled backups",
|
||||
"scheduleError": "Could not update schedule.",
|
||||
"scheduleInfo": "Interval: every {hours}h · Retention: {retention} backups",
|
||||
"label": "Label",
|
||||
"status": "Status",
|
||||
"size": "Size",
|
||||
"created": "Created",
|
||||
"actions": "Actions",
|
||||
"empty": "No backups yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/backups/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/backups/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerBackups } from "@/components/servers/server-backups";
|
||||
|
||||
interface ServerBackupsPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerBackupsPage({ params }: ServerBackupsPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerBackups serverId={id} />;
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/[id]/worlds/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/worlds/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerWorlds } from "@/components/servers/server-worlds";
|
||||
|
||||
interface ServerWorldsPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerWorldsPage({ params }: ServerWorldsPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerWorlds serverId={id} />;
|
||||
}
|
||||
237
apps/web/src/components/servers/server-backups.tsx
Normal file
237
apps/web/src/components/servers/server-backups.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
|
||||
import {
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
getBackupDownloadUrl,
|
||||
getBackupSchedule,
|
||||
listBackups,
|
||||
restoreBackup,
|
||||
updateBackupSchedule,
|
||||
type BackupItem,
|
||||
type BackupSchedule,
|
||||
} from "@/lib/api/backups";
|
||||
|
||||
interface ServerBackupsProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function formatBytes(value: string | null): string {
|
||||
if (!value) {
|
||||
return "—";
|
||||
}
|
||||
const bytes = Number(value);
|
||||
if (Number.isNaN(bytes)) {
|
||||
return value;
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function ServerBackups({ serverId }: ServerBackupsProps) {
|
||||
const t = useTranslations("servers.backups");
|
||||
const [backups, setBackups] = useState<BackupItem[]>([]);
|
||||
const [schedule, setSchedule] = useState<BackupSchedule | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [backupList, scheduleData] = await Promise.all([
|
||||
listBackups(serverId),
|
||||
getBackupSchedule(serverId),
|
||||
]);
|
||||
setBackups(backupList.backups);
|
||||
setSchedule(scheduleData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleCreateBackup() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createBackup(serverId, t("manualLabel"));
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("createError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(backupId: string) {
|
||||
try {
|
||||
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
|
||||
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("downloadError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(backupId: string) {
|
||||
if (!window.confirm(t("restoreConfirm"))) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await restoreBackup(serverId, backupId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("restoreError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(backupId: string) {
|
||||
if (!window.confirm(t("deleteConfirm"))) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteBackup(serverId, backupId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("deleteError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(enabled: boolean) {
|
||||
if (!schedule) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await updateBackupSchedule(serverId, { enabled });
|
||||
setSchedule(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("scheduleError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => void handleCreateBackup()} disabled={busy}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
{schedule ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void toggleSchedule(!schedule.enabled)}
|
||||
disabled={busy}
|
||||
>
|
||||
{schedule.enabled ? t("disableSchedule") : t("enableSchedule")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{schedule ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("scheduleInfo", {
|
||||
hours: schedule.intervalHours,
|
||||
retention: schedule.retentionCount,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
|
||||
<thead className="bg-zinc-50 dark:bg-zinc-900">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">{t("label")}</th>
|
||||
<th className="px-4 py-2 text-left font-medium">{t("status")}</th>
|
||||
<th className="px-4 py-2 text-left font-medium">{t("size")}</th>
|
||||
<th className="px-4 py-2 text-left font-medium">{t("created")}</th>
|
||||
<th className="px-4 py-2 text-right font-medium">{t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{backups.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-6 text-center text-zinc-500">
|
||||
{t("empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups.map((backup) => (
|
||||
<tr key={backup.id}>
|
||||
<td className="px-4 py-2">{backup.label ?? backup.type}</td>
|
||||
<td className="px-4 py-2">{backup.status}</td>
|
||||
<td className="px-4 py-2">{formatBytes(backup.sizeBytes)}</td>
|
||||
<td className="px-4 py-2">
|
||||
{new Date(backup.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right space-x-2">
|
||||
{backup.status === "AVAILABLE" ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleDownload(backup.id)}
|
||||
>
|
||||
{t("download")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleRestore(backup.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("restore")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleDelete(backup.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const tabs = [
|
||||
{ key: "files", suffix: "/files" },
|
||||
{ key: "properties", suffix: "/properties" },
|
||||
{ key: "players", suffix: "/players" },
|
||||
{ key: "worlds", suffix: "/worlds" },
|
||||
{ key: "backups", suffix: "/backups" },
|
||||
] as const;
|
||||
|
||||
export function ServerTabs({ serverId }: ServerTabsProps) {
|
||||
|
||||
172
apps/web/src/components/servers/server-worlds.tsx
Normal file
172
apps/web/src/components/servers/server-worlds.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
|
||||
import {
|
||||
completeWorldUpload,
|
||||
getWorldInfo,
|
||||
initiateWorldUpload,
|
||||
requestWorldDownload,
|
||||
type WorldSummary,
|
||||
} from "@/lib/api/worlds";
|
||||
import { getBackupDownloadUrl } from "@/lib/api/backups";
|
||||
|
||||
interface ServerWorldsProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
function formatBytes(value: string): string {
|
||||
const bytes = Number(value);
|
||||
if (Number.isNaN(bytes)) {
|
||||
return value;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function ServerWorlds({ serverId }: ServerWorldsProps) {
|
||||
const t = useTranslations("servers.worlds");
|
||||
const [activeWorldName, setActiveWorldName] = useState("world");
|
||||
const [worlds, setWorlds] = useState<WorldSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [worldName, setWorldName] = useState("world");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await getWorldInfo(serverId);
|
||||
setActiveWorldName(info.activeWorldName);
|
||||
setWorlds(info.worlds);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const init = await initiateWorldUpload(serverId, worldName);
|
||||
const response = await fetch(init.uploadUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(t("uploadError"));
|
||||
}
|
||||
await completeWorldUpload(serverId, init.uploadId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("uploadError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { backupId } = await requestWorldDownload(serverId);
|
||||
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
setError(t("downloadPending"));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("downloadError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("activeWorld", { name: activeWorldName })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("stoppedHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
|
||||
{t("worldName")}
|
||||
</span>
|
||||
<input
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
|
||||
value={worldName}
|
||||
onChange={(event) => setWorldName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
|
||||
{t("uploadArchive")}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz,application/gzip"
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void handleUpload(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button variant="secondary" onClick={() => void handleDownload()} disabled={busy}>
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{worlds.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
|
||||
) : (
|
||||
worlds.map((world) => (
|
||||
<li key={world.name} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{world.name}</p>
|
||||
<p className="text-zinc-500">
|
||||
{world.hasLevelDat ? t("validWorld") : t("invalidWorld")} ·{" "}
|
||||
{formatBytes(world.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
{world.name === activeWorldName ? (
|
||||
<span className="text-xs text-sky-600 dark:text-sky-400">{t("active")}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/lib/api/backups.ts
Normal file
80
apps/web/src/lib/api/backups.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface BackupItem {
|
||||
id: string;
|
||||
serverId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
sizeBytes: string | null;
|
||||
sha256: string | null;
|
||||
label: string | null;
|
||||
failureReason: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BackupListResponse {
|
||||
backups: BackupItem[];
|
||||
}
|
||||
|
||||
export interface BackupSchedule {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
retentionCount: number;
|
||||
nextRunAt: string | null;
|
||||
lastRunAt: string | null;
|
||||
}
|
||||
|
||||
export async function listBackups(serverId: string): Promise<BackupListResponse> {
|
||||
return apiFetch<BackupListResponse>(`/servers/${serverId}/backups`);
|
||||
}
|
||||
|
||||
export async function createBackup(
|
||||
serverId: string,
|
||||
label?: string,
|
||||
): Promise<{ backup: BackupItem; jobId?: string }> {
|
||||
return apiFetch(`/servers/${serverId}/backups`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBackup(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupItem> {
|
||||
return apiFetch(`/servers/${serverId}/backups/${backupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBackupDownloadUrl(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<{ downloadUrl: string; expiresAt: string }> {
|
||||
return apiFetch(`/servers/${serverId}/backups/${backupId}/download-url`);
|
||||
}
|
||||
|
||||
export async function restoreBackup(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/servers/${serverId}/backups/${backupId}/restore`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBackupSchedule(serverId: string): Promise<BackupSchedule> {
|
||||
return apiFetch(`/servers/${serverId}/backups/schedule`);
|
||||
}
|
||||
|
||||
export async function updateBackupSchedule(
|
||||
serverId: string,
|
||||
input: Partial<BackupSchedule>,
|
||||
): Promise<BackupSchedule> {
|
||||
return apiFetch(`/servers/${serverId}/backups/schedule`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
48
apps/web/src/lib/api/worlds.ts
Normal file
48
apps/web/src/lib/api/worlds.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export interface WorldSummary {
|
||||
name: string;
|
||||
path: string;
|
||||
sizeBytes: string;
|
||||
hasLevelDat: boolean;
|
||||
}
|
||||
|
||||
export interface WorldInfoResponse {
|
||||
activeWorldName: string;
|
||||
worlds: WorldSummary[];
|
||||
}
|
||||
|
||||
export async function getWorldInfo(serverId: string): Promise<WorldInfoResponse> {
|
||||
return apiFetch(`/servers/${serverId}/worlds`);
|
||||
}
|
||||
|
||||
export async function initiateWorldUpload(
|
||||
serverId: string,
|
||||
worldName: string,
|
||||
): Promise<{
|
||||
uploadId: string;
|
||||
uploadUrl: string;
|
||||
expiresAt: string;
|
||||
}> {
|
||||
return apiFetch(`/servers/${serverId}/worlds/uploads`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worldName }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeWorldUpload(
|
||||
serverId: string,
|
||||
uploadId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/servers/${serverId}/worlds/uploads/${uploadId}/complete`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWorldDownload(
|
||||
serverId: string,
|
||||
): Promise<{ backupId: string }> {
|
||||
return apiFetch(`/servers/${serverId}/worlds/download`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -11,9 +11,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dependencies": {
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"bullmq": "^5.34.10",
|
||||
"ioredis": "^5.4.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
|
||||
54
apps/worker/src/agent-bridge.ts
Normal file
54
apps/worker/src/agent-bridge.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
publishNodeCommand,
|
||||
waitForNodeResponse,
|
||||
type NodeResponseMessage,
|
||||
} from './redis';
|
||||
|
||||
export interface AgentRequestResult extends NodeResponseMessage {
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export async function sendAgentRequest(
|
||||
nodeId: string,
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<AgentRequestResult> {
|
||||
const messageId = randomUUID();
|
||||
|
||||
await publishNodeCommand({
|
||||
nodeId,
|
||||
envelope: {
|
||||
protocolVersion: 1,
|
||||
messageId,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
...payload,
|
||||
generation: payload['generation'] ?? 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await waitForNodeResponse(messageId, timeoutMs);
|
||||
|
||||
if (response === null) {
|
||||
logger.error({ nodeId, type, messageId }, 'Agent request timed out');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'AGENT_TIMEOUT',
|
||||
errorMessage: 'Agent request timed out',
|
||||
};
|
||||
}
|
||||
|
||||
return response as AgentRequestResult;
|
||||
}
|
||||
|
||||
export function loadStorageEnv(): void {
|
||||
validateConfig();
|
||||
}
|
||||
632
apps/worker/src/handlers/server-backups.ts
Normal file
632
apps/worker/src/handlers/server-backups.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
import {
|
||||
backupObjectKey,
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
createS3Client,
|
||||
deleteStoredObject,
|
||||
loadStorageConfig,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { sendAgentRequest } from '../agent-bridge';
|
||||
import { logger } from '../logger';
|
||||
import { transitionServerStatus } from '../server-state';
|
||||
import { getBackupsQueue } from '../queues';
|
||||
|
||||
const BACKUP_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
const createBackupJobSchema = z.object({
|
||||
backupId: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
previousStatus: z.enum(['RUNNING', 'STOPPED']),
|
||||
});
|
||||
|
||||
const restoreBackupJobSchema = z.object({
|
||||
restoreId: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
backupId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const applyWorldUploadJobSchema = z.object({
|
||||
uploadId: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const retentionJobSchema = z.object({
|
||||
serverId: z.string().uuid(),
|
||||
});
|
||||
|
||||
interface ArchiveResult {
|
||||
localPath: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
worldName: string;
|
||||
}
|
||||
|
||||
function getStorage() {
|
||||
const config = loadStorageConfig();
|
||||
return {
|
||||
config,
|
||||
client: createS3Client(config),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadServer(serverId: string) {
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
include: { backupSchedule: true },
|
||||
});
|
||||
|
||||
if (!server?.nodeId) {
|
||||
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function markBackupFailed(
|
||||
backupId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
failureReason: reason,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runBackupArchive(
|
||||
serverId: string,
|
||||
nodeId: string,
|
||||
backupId: string,
|
||||
worldName: string,
|
||||
generation: number,
|
||||
): Promise<ArchiveResult> {
|
||||
const response = await sendAgentRequest(
|
||||
nodeId,
|
||||
'server.world.archive',
|
||||
{
|
||||
serverId,
|
||||
generation,
|
||||
worldName,
|
||||
archiveId: backupId,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.errorMessage ?? 'World archive failed');
|
||||
}
|
||||
|
||||
return response.result as ArchiveResult;
|
||||
}
|
||||
|
||||
async function uploadArchiveToS3(
|
||||
serverId: string,
|
||||
nodeId: string,
|
||||
backupId: string,
|
||||
archive: ArchiveResult,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const { config, client } = getStorage();
|
||||
const s3Key = backupObjectKey(serverId, backupId);
|
||||
const uploadUrl = await createPresignedPutUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
s3Key,
|
||||
60 * 60,
|
||||
);
|
||||
|
||||
const response = await sendAgentRequest(
|
||||
nodeId,
|
||||
'server.storage.upload',
|
||||
{
|
||||
serverId,
|
||||
generation,
|
||||
localPath: archive.localPath,
|
||||
uploadUrl,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.errorMessage ?? 'Backup upload failed');
|
||||
}
|
||||
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: {
|
||||
s3Key,
|
||||
sha256: archive.sha256,
|
||||
sizeBytes: BigInt(archive.sizeBytes),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function enforceRetention(serverId: string, retentionCount: number): Promise<void> {
|
||||
const backups = await prisma.serverBackup.findMany({
|
||||
where: {
|
||||
serverId,
|
||||
status: 'AVAILABLE',
|
||||
type: { in: ['MANUAL', 'SCHEDULED'] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const extras = backups.slice(retentionCount);
|
||||
if (extras.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { config, client } = getStorage();
|
||||
|
||||
for (const backup of extras) {
|
||||
if (backup.s3Key) {
|
||||
try {
|
||||
await deleteStoredObject(client, config.S3_BUCKET, backup.s3Key);
|
||||
} catch (error) {
|
||||
logger.warn({ backupId: backup.id, error }, 'Failed to delete S3 backup object');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: backup.id },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function processCreateBackupJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'create-backup') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { backupId, serverId, previousStatus } = createBackupJobSchema.parse(job.data);
|
||||
const server = await loadServer(serverId);
|
||||
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: { status: 'RUNNING' },
|
||||
});
|
||||
|
||||
try {
|
||||
if (previousStatus === 'RUNNING') {
|
||||
const prepare = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.backup.prepare',
|
||||
{ serverId, generation: server.version },
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
if (!prepare.success) {
|
||||
throw new Error(prepare.errorMessage ?? 'Backup prepare failed');
|
||||
}
|
||||
}
|
||||
|
||||
const archive = await runBackupArchive(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
backupId,
|
||||
server.activeWorldName,
|
||||
server.version,
|
||||
);
|
||||
|
||||
await uploadArchiveToS3(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
backupId,
|
||||
archive,
|
||||
server.version,
|
||||
);
|
||||
|
||||
if (previousStatus === 'RUNNING') {
|
||||
const release = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.backup.release',
|
||||
{ serverId, generation: server.version },
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
if (!release.success) {
|
||||
throw new Error(release.errorMessage ?? 'Backup release failed');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: {
|
||||
status: 'AVAILABLE',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await transitionServerStatus(serverId, previousStatus, {
|
||||
expectedFrom: 'BACKING_UP',
|
||||
reason: 'Backup completed',
|
||||
});
|
||||
|
||||
const retentionCount = server.backupSchedule?.retentionCount ?? 5;
|
||||
await enforceRetention(serverId, retentionCount);
|
||||
|
||||
return { status: 'available' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Backup failed';
|
||||
await markBackupFailed(backupId, message);
|
||||
|
||||
try {
|
||||
if (previousStatus === 'RUNNING') {
|
||||
await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.backup.release',
|
||||
{ serverId, generation: server.version },
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
await transitionServerStatus(serverId, previousStatus, {
|
||||
expectedFrom: 'BACKING_UP',
|
||||
reason: 'Backup failed',
|
||||
errorMessage: message,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
logger.error({ restoreError, serverId }, 'Failed to restore server status after backup error');
|
||||
await transitionServerStatus(serverId, 'ERROR', {
|
||||
expectedFrom: 'BACKING_UP',
|
||||
reason: 'Backup failed and status recovery failed',
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processRestoreBackupJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'restore-backup') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { restoreId, serverId, backupId } = restoreBackupJobSchema.parse(job.data);
|
||||
const server = await loadServer(serverId);
|
||||
const backup = await prisma.serverBackup.findUniqueOrThrow({
|
||||
where: { id: backupId },
|
||||
});
|
||||
|
||||
if (backup.status !== 'AVAILABLE' || !backup.s3Key) {
|
||||
throw new Error('Backup is not available for restore');
|
||||
}
|
||||
|
||||
await prisma.backupRestore.update({
|
||||
where: { id: restoreId },
|
||||
data: { status: 'RUNNING' },
|
||||
});
|
||||
|
||||
const safetyBackup = await prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'PRE_RESTORE',
|
||||
status: 'RUNNING',
|
||||
label: 'Pre-restore safety backup',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.backupRestore.update({
|
||||
where: { id: restoreId },
|
||||
data: { safetyBackupId: safetyBackup.id },
|
||||
});
|
||||
|
||||
try {
|
||||
const safetyArchive = await runBackupArchive(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
safetyBackup.id,
|
||||
server.activeWorldName,
|
||||
server.version,
|
||||
);
|
||||
await uploadArchiveToS3(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
safetyBackup.id,
|
||||
safetyArchive,
|
||||
server.version,
|
||||
);
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: safetyBackup.id },
|
||||
data: {
|
||||
status: 'AVAILABLE',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Safety backup failed';
|
||||
await markBackupFailed(safetyBackup.id, message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { config, client } = getStorage();
|
||||
const downloadUrl = await createPresignedGetUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
backup.s3Key,
|
||||
60 * 60,
|
||||
);
|
||||
const localArchive = `/var/lib/hgc/servers/${serverId}/.hgc/restore/${restoreId}.tar.gz`;
|
||||
|
||||
try {
|
||||
const download = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.storage.download',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
downloadUrl,
|
||||
localPath: localArchive,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!download.success) {
|
||||
throw new Error(download.errorMessage ?? 'Backup download failed');
|
||||
}
|
||||
|
||||
const replace = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.world.replace',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
worldName: server.activeWorldName,
|
||||
archivePath: localArchive,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!replace.success) {
|
||||
throw new Error(replace.errorMessage ?? 'World replace failed');
|
||||
}
|
||||
|
||||
await prisma.backupRestore.update({
|
||||
where: { id: restoreId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await transitionServerStatus(serverId, 'STOPPED', {
|
||||
expectedFrom: 'RESTORING',
|
||||
reason: 'Restore completed',
|
||||
});
|
||||
|
||||
return { status: 'completed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Restore failed';
|
||||
await prisma.backupRestore.update({
|
||||
where: { id: restoreId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
failureReason: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await transitionServerStatus(serverId, 'STOPPED', {
|
||||
expectedFrom: 'RESTORING',
|
||||
reason: 'Restore failed',
|
||||
errorMessage: message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processApplyWorldUploadJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'apply-world-upload') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { uploadId, serverId } = applyWorldUploadJobSchema.parse(job.data);
|
||||
const server = await loadServer(serverId);
|
||||
const upload = await prisma.worldUpload.findUniqueOrThrow({
|
||||
where: { id: uploadId },
|
||||
});
|
||||
|
||||
await prisma.worldUpload.update({
|
||||
where: { id: uploadId },
|
||||
data: { status: 'VALIDATING' },
|
||||
});
|
||||
|
||||
const safetyBackup = await prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'PRE_WORLD_REPLACE',
|
||||
status: 'RUNNING',
|
||||
label: `Pre-replace backup for upload ${uploadId}`,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const safetyArchive = await runBackupArchive(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
safetyBackup.id,
|
||||
server.activeWorldName,
|
||||
server.version,
|
||||
);
|
||||
await uploadArchiveToS3(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
safetyBackup.id,
|
||||
safetyArchive,
|
||||
server.version,
|
||||
);
|
||||
await prisma.serverBackup.update({
|
||||
where: { id: safetyBackup.id },
|
||||
data: {
|
||||
status: 'AVAILABLE',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Safety backup failed';
|
||||
await markBackupFailed(safetyBackup.id, message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { config, client } = getStorage();
|
||||
const downloadUrl = await createPresignedGetUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
upload.s3Key,
|
||||
60 * 60,
|
||||
);
|
||||
const localArchive = `/var/lib/hgc/servers/${serverId}/.hgc/uploads/${uploadId}.tar.gz`;
|
||||
|
||||
try {
|
||||
const download = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.storage.download',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
downloadUrl,
|
||||
localPath: localArchive,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!download.success) {
|
||||
throw new Error(download.errorMessage ?? 'Upload download failed');
|
||||
}
|
||||
|
||||
const replace = await sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.world.replace',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
worldName: upload.worldName,
|
||||
archivePath: localArchive,
|
||||
},
|
||||
BACKUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!replace.success) {
|
||||
throw new Error(replace.errorMessage ?? 'World replace failed');
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.worldUpload.update({
|
||||
where: { id: uploadId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { activeWorldName: upload.worldName },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { status: 'completed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'World upload apply failed';
|
||||
await prisma.worldUpload.update({
|
||||
where: { id: uploadId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
failureReason: message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processRetentionJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'enforce-retention') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { serverId } = retentionJobSchema.parse(job.data);
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
include: { backupSchedule: true },
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
await enforceRetention(serverId, server.backupSchedule?.retentionCount ?? 5);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
export async function processScheduledBackupsTick(): Promise<void> {
|
||||
const now = new Date();
|
||||
const schedules = await prisma.backupSchedule.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
OR: [{ nextRunAt: null }, { nextRunAt: { lte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: schedule.serverId },
|
||||
});
|
||||
|
||||
if (!server?.nodeId || !['RUNNING', 'STOPPED'].includes(server.status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const backup = await prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId: schedule.serverId,
|
||||
type: 'SCHEDULED',
|
||||
status: 'PENDING',
|
||||
label: 'Scheduled backup',
|
||||
},
|
||||
});
|
||||
|
||||
await transitionServerStatus(schedule.serverId, 'BACKING_UP', {
|
||||
expectedFrom: server.status as 'RUNNING' | 'STOPPED',
|
||||
reason: 'Scheduled backup started',
|
||||
});
|
||||
|
||||
await getBackupsQueue().add('create-backup', {
|
||||
backupId: backup.id,
|
||||
serverId: schedule.serverId,
|
||||
previousStatus: server.status,
|
||||
});
|
||||
|
||||
const nextRunAt = new Date(now.getTime() + schedule.intervalHours * 60 * 60 * 1000);
|
||||
await prisma.backupSchedule.update({
|
||||
where: { id: schedule.id },
|
||||
data: {
|
||||
lastRunAt: now,
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function processBackupJob(job: Job): Promise<{ status: string }> {
|
||||
switch (job.name) {
|
||||
case 'create-backup':
|
||||
return processCreateBackupJob(job);
|
||||
case 'restore-backup':
|
||||
return processRestoreBackupJob(job);
|
||||
case 'apply-world-upload':
|
||||
return processApplyWorldUploadJob(job);
|
||||
case 'enforce-retention':
|
||||
return processRetentionJob(job);
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown backup job');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
|
||||
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||
import { startHealthServer } from './health';
|
||||
@@ -11,6 +12,7 @@ import { logger } from './logger';
|
||||
import { closeRedisConnections } from './redis';
|
||||
import {
|
||||
QUEUE_NOTIFICATIONS,
|
||||
QUEUE_SERVER_BACKUPS,
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
WORKER_QUEUES,
|
||||
@@ -37,6 +39,10 @@ function createQueueWorker(
|
||||
return processProvisionServerJob(job);
|
||||
}
|
||||
|
||||
if (queueName === QUEUE_SERVER_BACKUPS) {
|
||||
return processBackupJob(job);
|
||||
}
|
||||
|
||||
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
||||
return processLifecycleJob(job);
|
||||
}
|
||||
@@ -75,9 +81,17 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
logger.info({ queues: WORKER_QUEUES }, 'Worker started');
|
||||
|
||||
const scheduleTimer = setInterval(() => {
|
||||
void processScheduledBackupsTick().catch((error: Error) => {
|
||||
logger.error({ err: error }, 'Scheduled backup tick failed');
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, 'Shutting down worker');
|
||||
|
||||
clearInterval(scheduleTimer);
|
||||
|
||||
await Promise.all(workers.map((worker) => worker.close()));
|
||||
await closeRedisConnections();
|
||||
await prisma.$disconnect();
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
|
||||
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
|
||||
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
|
||||
export const QUEUE_NOTIFICATIONS = 'notifications' as const;
|
||||
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
|
||||
export const WORKER_QUEUES = [
|
||||
QUEUE_SERVER_BACKUPS,
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
QUEUE_NOTIFICATIONS,
|
||||
] as const;
|
||||
|
||||
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
|
||||
|
||||
let backupsQueue: Queue | null = null;
|
||||
|
||||
export function getBackupsQueue(): Queue {
|
||||
if (!backupsQueue) {
|
||||
const config = validateConfig();
|
||||
backupsQueue = new Queue(QUEUE_SERVER_BACKUPS, {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return backupsQueue;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface NodeCommandMessage {
|
||||
|
||||
export interface NodeResponseMessage {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,92 +2,76 @@
|
||||
|
||||
Last updated: 2026-06-26
|
||||
|
||||
## Current phase: Phase 3 — Console, files, settings ✅
|
||||
## Current phase: Phase 4 — Worlds & backups ✅
|
||||
|
||||
### Phase 3 completed
|
||||
### Phase 4 completed
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Go agent v0.3 | Done | Log stream, RCON commands, safe file I/O |
|
||||
| `@hexahost/contracts` phase 3 | Done | console, files, properties, players |
|
||||
| API NodeBridge | Done | Sync request/response over Redis |
|
||||
| API Console | Done | Token + WS `/api/v1/servers/:id/console/ws` |
|
||||
| API Files | Done | List, read, write with path validation |
|
||||
| API Properties | Done | GET/PATCH `server.properties` |
|
||||
| API Players | Done | Online list + whitelist |
|
||||
| API Restart | Done | `POST /servers/:id/restart?skip=true` |
|
||||
| Audit events | Done | console.command, file.write, properties.update |
|
||||
| Web server tabs | Done | Console, files, properties, players |
|
||||
| Unit tests | Done | Properties parser, path validation, route contracts |
|
||||
| Prisma backup models | Done | ServerBackup, BackupRestore, BackupSchedule, WorldUpload |
|
||||
| `@hexahost/storage` | Done | S3/MinIO presigned URLs, object keys |
|
||||
| `@hexahost/contracts` phase 4 | Done | backups, worlds schemas |
|
||||
| Go agent v0.4 | Done | Archive, validate, RCON prepare/release, S3 transfer |
|
||||
| Worker backup queue | Done | create/restore/upload, retention, schedule tick |
|
||||
| API BackupsModule | Done | CRUD, restore, schedule, download URL |
|
||||
| API WorldsModule | Done | World info, upload, download via backup |
|
||||
| Web worlds/backups tabs | Done | Upload, list, backup management |
|
||||
| State machine | Done | BACKING_UP, RESTORING |
|
||||
|
||||
### Abnahmekriterium Phase 3
|
||||
### Abnahmekriterium Phase 4
|
||||
|
||||
- [x] Live console WebSocket (logs + commands when RUNNING)
|
||||
- [x] File manager (list/read/write, path sandbox)
|
||||
- [x] `server.properties` editor with restart option
|
||||
- [x] Player list + whitelist view
|
||||
- [x] Server management fully via web panel (no direct host access required)
|
||||
- [ ] Manual E2E validation (requires Docker + agent running locally)
|
||||
- [x] World backup to S3 (MinIO in dev)
|
||||
- [x] World download (presigned URL)
|
||||
- [x] World upload and replace (server stopped)
|
||||
- [x] Backup restore with safety backup
|
||||
- [x] Retention enforcement
|
||||
- [x] Scheduled backups (interval-based)
|
||||
- [ ] Manual E2E validation (requires Docker + agent + MinIO)
|
||||
|
||||
### Local vertical slice
|
||||
|
||||
```bash
|
||||
pnpm dev:infra
|
||||
pnpm db:migrate # or db:push
|
||||
pnpm dev:infra # includes MinIO on :9000
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
pnpm dev
|
||||
|
||||
# Terminal 2 — Node Agent (requires Go + Docker)
|
||||
cd apps/node-agent
|
||||
set NODE_ID=00000000-0000-4000-8000-000000000001
|
||||
set NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
|
||||
set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
|
||||
make run
|
||||
# Node Agent (Go + Docker)
|
||||
cd apps/node-agent && make run
|
||||
```
|
||||
|
||||
1. Create and start a server under `/servers/new`
|
||||
2. Open **Console** tab — live logs, send commands via RCON
|
||||
3. Open **Files** — browse server directory, edit files
|
||||
4. Open **Properties** — change `server.properties`, optional restart
|
||||
5. Open **Players** — online players and whitelist
|
||||
1. Stop server → **Welten** → `.tar.gz` hochladen
|
||||
2. **Backups** → manuelles Backup erstellen → Download
|
||||
3. Backup wiederherstellen (Server gestoppt)
|
||||
4. Geplante Backups aktivieren
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Console only when server status is `RUNNING`; files readable when `RUNNING` or `STOPPED`.
|
||||
- RCON via `rcon-cli` inside Minecraft container (itzg image).
|
||||
- File writes limited to server data directory; no upload/download archives yet (Phase 4).
|
||||
- Player management read-only (whitelist view); add/remove deferred.
|
||||
- Single-node only; no scheduler fairness yet.
|
||||
- Upload expects gzip-compressed tar (`.tar.gz`) with world folder or `level.dat` at root.
|
||||
- Restore/download require agent on node; worker orchestrates S3 transfer.
|
||||
- Scheduled backups use simple `intervalHours` (no cron expressions yet).
|
||||
- No server-side encryption of backups beyond MinIO/S3 transport (deferred).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Console, files, settings ✅
|
||||
|
||||
Live console, file manager, `server.properties`, player lists, audit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Single-Node Vertical Slice ✅
|
||||
|
||||
| Area | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
|
||||
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
|
||||
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
|
||||
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
|
||||
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
|
||||
| Go node-agent v0.2 | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
|
||||
| Web server UI | Done | Create, list, detail, start/stop |
|
||||
| Migration | Done | `20260626140000_phase2_game_servers` |
|
||||
Provisioning, start/stop, node agent, web panel.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Authentication ✅
|
||||
|
||||
Session cookies, register/login/logout, email verify, password reset, TOTP 2FA, recovery codes, audit baseline. See `docs/adr/0002-session-authentication.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Monorepo foundation ✅
|
||||
|
||||
pnpm + Turborepo monorepo, PostgreSQL/Redis/MinIO/Mailpit compose, CI, ADRs.
|
||||
### Next phase: Phase 5 — Software & add-ons
|
||||
|
||||
### Next phase: Phase 4 — Worlds & backups
|
||||
|
||||
- S3-compatible storage (MinIO)
|
||||
- World upload/download
|
||||
- Backup jobs and restore
|
||||
- Software catalog, Modrinth, mods/plugins/modpacks
|
||||
|
||||
102
packages/contracts/src/backups.ts
Normal file
102
packages/contracts/src/backups.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const backupStatusSchema = z.enum([
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'AVAILABLE',
|
||||
'FAILED',
|
||||
'DELETED',
|
||||
]);
|
||||
|
||||
export const backupTypeSchema = z.enum([
|
||||
'MANUAL',
|
||||
'SCHEDULED',
|
||||
'PRE_RESTORE',
|
||||
'PRE_WORLD_REPLACE',
|
||||
]);
|
||||
|
||||
export const backupRestoreStatusSchema = z.enum([
|
||||
'PENDING',
|
||||
'RUNNING',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
]);
|
||||
|
||||
export const backupResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
type: backupTypeSchema,
|
||||
status: backupStatusSchema,
|
||||
s3Key: z.string().nullable(),
|
||||
sizeBytes: z.string().nullable(),
|
||||
sha256: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
expiresAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const backupListResponseSchema = z.object({
|
||||
backups: z.array(backupResponseSchema),
|
||||
});
|
||||
|
||||
export const createBackupRequestSchema = z.object({
|
||||
label: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export const backupActionResponseSchema = z.object({
|
||||
backup: backupResponseSchema,
|
||||
jobId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const backupDownloadUrlResponseSchema = z.object({
|
||||
downloadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const restoreBackupRequestSchema = z.object({
|
||||
confirm: z.literal(true, {
|
||||
errorMap: () => ({ message: 'Restore must be explicitly confirmed' }),
|
||||
}),
|
||||
});
|
||||
|
||||
export const backupRestoreResponseSchema = z.object({
|
||||
restore: z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
backupId: z.string().uuid(),
|
||||
safetyBackupId: z.string().uuid().nullable(),
|
||||
status: backupRestoreStatusSchema,
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
}),
|
||||
jobId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const backupScheduleSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
intervalHours: z.number().int().min(1).max(168),
|
||||
retentionCount: z.number().int().min(1).max(50),
|
||||
nextRunAt: z.string().datetime().nullable(),
|
||||
lastRunAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const updateBackupScheduleSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
intervalHours: z.number().int().min(1).max(168).optional(),
|
||||
retentionCount: z.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
export type BackupStatus = z.infer<typeof backupStatusSchema>;
|
||||
export type BackupType = z.infer<typeof backupTypeSchema>;
|
||||
export type BackupResponse = z.infer<typeof backupResponseSchema>;
|
||||
export type BackupListResponse = z.infer<typeof backupListResponseSchema>;
|
||||
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>;
|
||||
export type BackupActionResponse = z.infer<typeof backupActionResponseSchema>;
|
||||
export type BackupDownloadUrlResponse = z.infer<typeof backupDownloadUrlResponseSchema>;
|
||||
export type RestoreBackupRequest = z.infer<typeof restoreBackupRequestSchema>;
|
||||
export type BackupRestoreResponse = z.infer<typeof backupRestoreResponseSchema>;
|
||||
export type BackupSchedule = z.infer<typeof backupScheduleSchema>;
|
||||
export type UpdateBackupSchedule = z.infer<typeof updateBackupScheduleSchema>;
|
||||
@@ -113,3 +113,44 @@ export {
|
||||
type OperatorEntry,
|
||||
type PlayerListResponse,
|
||||
} from './players';
|
||||
|
||||
export {
|
||||
backupStatusSchema,
|
||||
backupTypeSchema,
|
||||
backupRestoreStatusSchema,
|
||||
backupResponseSchema,
|
||||
backupListResponseSchema,
|
||||
createBackupRequestSchema,
|
||||
backupActionResponseSchema,
|
||||
backupDownloadUrlResponseSchema,
|
||||
restoreBackupRequestSchema,
|
||||
backupRestoreResponseSchema,
|
||||
backupScheduleSchema,
|
||||
updateBackupScheduleSchema,
|
||||
type BackupStatus,
|
||||
type BackupType,
|
||||
type BackupResponse,
|
||||
type BackupListResponse,
|
||||
type CreateBackupRequest,
|
||||
type BackupActionResponse,
|
||||
type BackupDownloadUrlResponse,
|
||||
type RestoreBackupRequest,
|
||||
type BackupRestoreResponse,
|
||||
type BackupSchedule,
|
||||
type UpdateBackupSchedule,
|
||||
} from './backups';
|
||||
|
||||
export {
|
||||
worldSummarySchema,
|
||||
worldInfoResponseSchema,
|
||||
initiateWorldUploadSchema,
|
||||
worldUploadInitResponseSchema,
|
||||
worldUploadResponseSchema,
|
||||
worldDownloadUrlResponseSchema,
|
||||
type WorldSummary,
|
||||
type WorldInfoResponse,
|
||||
type InitiateWorldUpload,
|
||||
type WorldUploadInitResponse,
|
||||
type WorldUploadResponse,
|
||||
type WorldDownloadUrlResponse,
|
||||
} from './worlds';
|
||||
|
||||
@@ -8,6 +8,8 @@ export const gameServerStatusSchema = z.enum([
|
||||
'STARTING',
|
||||
'RUNNING',
|
||||
'STOPPING',
|
||||
'BACKING_UP',
|
||||
'RESTORING',
|
||||
'ERROR',
|
||||
'DELETING',
|
||||
'DELETED',
|
||||
@@ -53,6 +55,7 @@ export const serverResponseSchema = z.object({
|
||||
hostPort: z.number().int().nullable(),
|
||||
containerId: z.string().nullable(),
|
||||
dataPath: z.string().nullable(),
|
||||
activeWorldName: z.string(),
|
||||
ramMb: z.number().int(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
|
||||
59
packages/contracts/src/worlds.ts
Normal file
59
packages/contracts/src/worlds.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const worldSummarySchema = z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
sizeBytes: z.string(),
|
||||
hasLevelDat: z.boolean(),
|
||||
});
|
||||
|
||||
export const worldInfoResponseSchema = z.object({
|
||||
activeWorldName: z.string(),
|
||||
worlds: z.array(worldSummarySchema),
|
||||
});
|
||||
|
||||
export const initiateWorldUploadSchema = z.object({
|
||||
worldName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid world name'),
|
||||
});
|
||||
|
||||
export const worldUploadInitResponseSchema = z.object({
|
||||
uploadId: z.string().uuid(),
|
||||
uploadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
s3Key: z.string(),
|
||||
});
|
||||
|
||||
export const worldUploadResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
status: z.enum([
|
||||
'PENDING',
|
||||
'UPLOADING',
|
||||
'VALIDATING',
|
||||
'READY',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
]),
|
||||
worldName: z.string(),
|
||||
sizeBytes: z.string().nullable(),
|
||||
failureReason: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime().nullable(),
|
||||
});
|
||||
|
||||
export const worldDownloadUrlResponseSchema = z.object({
|
||||
downloadUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
backupId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export type WorldSummary = z.infer<typeof worldSummarySchema>;
|
||||
export type WorldInfoResponse = z.infer<typeof worldInfoResponseSchema>;
|
||||
export type InitiateWorldUpload = z.infer<typeof initiateWorldUploadSchema>;
|
||||
export type WorldUploadInitResponse = z.infer<typeof worldUploadInitResponseSchema>;
|
||||
export type WorldUploadResponse = z.infer<typeof worldUploadResponseSchema>;
|
||||
export type WorldDownloadUrlResponse = z.infer<typeof worldDownloadUrlResponseSchema>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,99 @@
|
||||
-- Phase 4: Worlds & Backups
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "GameServerStatus" ADD VALUE 'BACKING_UP';
|
||||
ALTER TYPE "GameServerStatus" ADD VALUE 'RESTORING';
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'RUNNING', 'AVAILABLE', 'FAILED', 'DELETED');
|
||||
CREATE TYPE "BackupType" AS ENUM ('MANUAL', 'SCHEDULED', 'PRE_RESTORE', 'PRE_WORLD_REPLACE');
|
||||
CREATE TYPE "BackupRestoreStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
|
||||
CREATE TYPE "WorldUploadStatus" AS ENUM ('PENDING', 'UPLOADING', 'VALIDATING', 'READY', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "game_servers" ADD COLUMN "activeWorldName" TEXT NOT NULL DEFAULT 'world';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "server_backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"type" "BackupType" NOT NULL DEFAULT 'MANUAL',
|
||||
"status" "BackupStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"s3Key" TEXT,
|
||||
"sizeBytes" BIGINT,
|
||||
"sha256" TEXT,
|
||||
"label" TEXT,
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
"expiresAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "server_backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "backup_restores" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"backupId" TEXT NOT NULL,
|
||||
"safetyBackupId" TEXT,
|
||||
"status" "BackupRestoreStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "backup_restores_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "backup_schedules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"intervalHours" INTEGER NOT NULL DEFAULT 24,
|
||||
"retentionCount" INTEGER NOT NULL DEFAULT 5,
|
||||
"nextRunAt" TIMESTAMPTZ(3),
|
||||
"lastRunAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "backup_schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "world_uploads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"status" "WorldUploadStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"s3Key" TEXT NOT NULL,
|
||||
"worldName" TEXT NOT NULL DEFAULT 'world',
|
||||
"sha256" TEXT,
|
||||
"sizeBytes" BIGINT,
|
||||
"failureReason" TEXT,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "world_uploads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "server_backups_serverId_idx" ON "server_backups"("serverId");
|
||||
CREATE INDEX "server_backups_status_idx" ON "server_backups"("status");
|
||||
CREATE INDEX "server_backups_createdAt_idx" ON "server_backups"("createdAt");
|
||||
|
||||
CREATE INDEX "backup_restores_serverId_idx" ON "backup_restores"("serverId");
|
||||
CREATE INDEX "backup_restores_backupId_idx" ON "backup_restores"("backupId");
|
||||
CREATE INDEX "backup_restores_status_idx" ON "backup_restores"("status");
|
||||
|
||||
CREATE UNIQUE INDEX "backup_schedules_serverId_key" ON "backup_schedules"("serverId");
|
||||
|
||||
CREATE INDEX "world_uploads_serverId_idx" ON "world_uploads"("serverId");
|
||||
CREATE INDEX "world_uploads_status_idx" ON "world_uploads"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "server_backups" ADD CONSTRAINT "server_backups_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_backupId_fkey" FOREIGN KEY ("backupId") REFERENCES "server_backups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_safetyBackupId_fkey" FOREIGN KEY ("safetyBackupId") REFERENCES "server_backups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "backup_schedules" ADD CONSTRAINT "backup_schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "world_uploads" ADD CONSTRAINT "world_uploads_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -37,6 +37,8 @@ enum GameServerStatus {
|
||||
STARTING
|
||||
RUNNING
|
||||
STOPPING
|
||||
BACKING_UP
|
||||
RESTORING
|
||||
ERROR
|
||||
DELETING
|
||||
DELETED
|
||||
@@ -63,6 +65,37 @@ enum JobStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum BackupStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
AVAILABLE
|
||||
FAILED
|
||||
DELETED
|
||||
}
|
||||
|
||||
enum BackupType {
|
||||
MANUAL
|
||||
SCHEDULED
|
||||
PRE_RESTORE
|
||||
PRE_WORLD_REPLACE
|
||||
}
|
||||
|
||||
enum BackupRestoreStatus {
|
||||
PENDING
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum WorldUploadStatus {
|
||||
PENDING
|
||||
UPLOADING
|
||||
VALIDATING
|
||||
READY
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
@@ -219,6 +252,7 @@ model GameServer {
|
||||
containerId String?
|
||||
dataPath String?
|
||||
ramMb Int @default(1024)
|
||||
activeWorldName String @default("world")
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -227,6 +261,10 @@ model GameServer {
|
||||
node GameNode? @relation(fields: [nodeId], references: [id])
|
||||
stateTransitions GameServerStateTransition[]
|
||||
allocation GameServerAllocation?
|
||||
backups ServerBackup[]
|
||||
backupRestores BackupRestore[]
|
||||
backupSchedule BackupSchedule?
|
||||
worldUploads WorldUpload[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([nodeId])
|
||||
@@ -385,3 +423,82 @@ model JobRecord {
|
||||
@@index([createdAt])
|
||||
@@map("job_records")
|
||||
}
|
||||
|
||||
model ServerBackup {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
type BackupType @default(MANUAL)
|
||||
status BackupStatus @default(PENDING)
|
||||
s3Key String?
|
||||
sizeBytes BigInt?
|
||||
sha256 String?
|
||||
label String?
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
expiresAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
restores BackupRestore[] @relation("RestoredFromBackup")
|
||||
safetyForRestores BackupRestore[] @relation("SafetyBackup")
|
||||
|
||||
@@index([serverId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@map("server_backups")
|
||||
}
|
||||
|
||||
model BackupRestore {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
backupId String
|
||||
safetyBackupId String?
|
||||
status BackupRestoreStatus @default(PENDING)
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
backup ServerBackup @relation("RestoredFromBackup", fields: [backupId], references: [id])
|
||||
safetyBackup ServerBackup? @relation("SafetyBackup", fields: [safetyBackupId], references: [id])
|
||||
|
||||
@@index([serverId])
|
||||
@@index([backupId])
|
||||
@@index([status])
|
||||
@@map("backup_restores")
|
||||
}
|
||||
|
||||
model BackupSchedule {
|
||||
id String @id @default(uuid())
|
||||
serverId String @unique
|
||||
enabled Boolean @default(true)
|
||||
intervalHours Int @default(24)
|
||||
retentionCount Int @default(5)
|
||||
nextRunAt DateTime? @db.Timestamptz(3)
|
||||
lastRunAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("backup_schedules")
|
||||
}
|
||||
|
||||
model WorldUpload {
|
||||
id String @id @default(uuid())
|
||||
serverId String
|
||||
status WorldUploadStatus @default(PENDING)
|
||||
s3Key String
|
||||
worldName String @default("world")
|
||||
sha256 String?
|
||||
sizeBytes BigInt?
|
||||
failureReason String?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
completedAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([serverId])
|
||||
@@index([status])
|
||||
@@map("world_uploads")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ export type {
|
||||
PlatformRole,
|
||||
UserPlatformRole,
|
||||
GameServer,
|
||||
GameServerAllocation,
|
||||
GameServerStateTransition,
|
||||
ServerBackup,
|
||||
BackupRestore,
|
||||
BackupSchedule,
|
||||
WorldUpload,
|
||||
GameNode,
|
||||
GameNodeHeartbeat,
|
||||
Plan,
|
||||
|
||||
File diff suppressed because one or more lines are too long
23
packages/storage/package.json
Normal file
23
packages/storage/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hexahost/storage",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.750.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
90
packages/storage/src/client.ts
Normal file
90
packages/storage/src/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
import type { StorageConfig } from './config';
|
||||
|
||||
export function createS3Client(config: StorageConfig): S3Client {
|
||||
return new S3Client({
|
||||
region: config.S3_REGION,
|
||||
endpoint: config.S3_ENDPOINT,
|
||||
forcePathStyle: config.S3_FORCE_PATH_STYLE,
|
||||
credentials: {
|
||||
accessKeyId: config.S3_ACCESS_KEY,
|
||||
secretAccessKey: config.S3_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function backupObjectKey(serverId: string, backupId: string): string {
|
||||
return `servers/${serverId}/backups/${backupId}.tar.gz`;
|
||||
}
|
||||
|
||||
export function worldUploadObjectKey(serverId: string, uploadId: string): string {
|
||||
return `servers/${serverId}/uploads/${uploadId}/world.tar.gz`;
|
||||
}
|
||||
|
||||
export async function createPresignedPutUrl(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresInSeconds: number,
|
||||
contentType = 'application/gzip',
|
||||
): Promise<string> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function createPresignedGetUrl(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
expiresInSeconds: number,
|
||||
): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
|
||||
}
|
||||
|
||||
export async function deleteStoredObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function headStoredObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<{ sizeBytes: number; etag?: string }> {
|
||||
const response = await client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sizeBytes: Number(response.ContentLength ?? 0),
|
||||
etag: response.ETag,
|
||||
};
|
||||
}
|
||||
28
packages/storage/src/config.ts
Normal file
28
packages/storage/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const storageConfigSchema = z.object({
|
||||
S3_ENDPOINT: z.string().url(),
|
||||
S3_REGION: z.string().min(1).default('us-east-1'),
|
||||
S3_BUCKET: z.string().min(1),
|
||||
S3_ACCESS_KEY: z.string().min(1),
|
||||
S3_SECRET_KEY: z.string().min(1),
|
||||
S3_FORCE_PATH_STYLE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === 'true' || value === '1'),
|
||||
});
|
||||
|
||||
export type StorageConfig = z.infer<typeof storageConfigSchema>;
|
||||
|
||||
export function loadStorageConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): StorageConfig {
|
||||
return storageConfigSchema.parse({
|
||||
S3_ENDPOINT: env['S3_ENDPOINT'],
|
||||
S3_REGION: env['S3_REGION'],
|
||||
S3_BUCKET: env['S3_BUCKET'],
|
||||
S3_ACCESS_KEY: env['S3_ACCESS_KEY'],
|
||||
S3_SECRET_KEY: env['S3_SECRET_KEY'],
|
||||
S3_FORCE_PATH_STYLE: env['S3_FORCE_PATH_STYLE'],
|
||||
});
|
||||
}
|
||||
15
packages/storage/src/index.ts
Normal file
15
packages/storage/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
storageConfigSchema,
|
||||
loadStorageConfig,
|
||||
type StorageConfig,
|
||||
} from './config';
|
||||
|
||||
export {
|
||||
createS3Client,
|
||||
backupObjectKey,
|
||||
worldUploadObjectKey,
|
||||
createPresignedPutUrl,
|
||||
createPresignedGetUrl,
|
||||
deleteStoredObject,
|
||||
headStoredObject,
|
||||
} from './client';
|
||||
17
packages/storage/src/keys.test.ts
Normal file
17
packages/storage/src/keys.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { backupObjectKey, worldUploadObjectKey } from './client';
|
||||
|
||||
describe('storage object keys', () => {
|
||||
it('builds backup keys', () => {
|
||||
expect(backupObjectKey('srv-1', 'bak-1')).toBe(
|
||||
'servers/srv-1/backups/bak-1.tar.gz',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds world upload keys', () => {
|
||||
expect(worldUploadObjectKey('srv-1', 'up-1')).toBe(
|
||||
'servers/srv-1/uploads/up-1/world.tar.gz',
|
||||
);
|
||||
});
|
||||
});
|
||||
9
packages/storage/tsconfig.json
Normal file
9
packages/storage/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user