first cycle lab release
This commit is contained in:
+23
@@ -1 +1,24 @@
|
|||||||
*Zone.Identifier
|
*Zone.Identifier
|
||||||
|
/.tools/
|
||||||
|
/dist/*
|
||||||
|
!/dist/.gitkeep
|
||||||
|
/runtime/*
|
||||||
|
!/runtime/.gitkeep
|
||||||
|
/backups/
|
||||||
|
/server/.env
|
||||||
|
/server/.env.*
|
||||||
|
!/server/.env.example
|
||||||
|
/server/compose.override.yaml
|
||||||
|
*.log
|
||||||
|
/node_modules/
|
||||||
|
/.env
|
||||||
|
/.mcpack-runtime.json
|
||||||
|
/.mcpack-runtime.lock/
|
||||||
|
/.mcpack-backup-operation.json
|
||||||
|
/.mcpack-backup-operation.json.tmp
|
||||||
|
/diagnostics/
|
||||||
|
/rollback-state.json
|
||||||
|
/deployment-state.json
|
||||||
|
/operation-history/
|
||||||
|
/retired-runtimes/
|
||||||
|
/.rollback-restore-*/
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# minecraft-lab
|
||||||
|
|
||||||
|
Платформа для пользовательских сборок **NeoForge/Forge без обязательных
|
||||||
|
аккаунтов**. Подтверждённая основа: Minecraft 1.21.1 + NeoForge 21.1.252,
|
||||||
|
Sophisticated Backpacks/Core и client-only Mouse Tweaks. Forge пока требует
|
||||||
|
отдельного spike. Текущий source release — 0.0.5.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Нужны Linux x86_64, Bash, Node.js 22+, npm, Git, curl, bsdtar и sha256sum.
|
||||||
|
Docker daemon для build не нужен. Bootstrap загружает закреплённые packwiz
|
||||||
|
и Compose в `.tools/`; npm parsers закреплены lockfile.
|
||||||
|
|
||||||
|
После clone из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
scripts/spike-tools.sh
|
||||||
|
./mcpack build
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/minecraft-lab-neoforge-spike-0.0.5/
|
||||||
|
├── minecraft-lab-neoforge-spike-0.0.5.mrpack
|
||||||
|
├── server/
|
||||||
|
├── manifest.json
|
||||||
|
└── SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
Build читает `platform/config.yaml`, работает в копии source и не меняет
|
||||||
|
исходники. Готовый релиз не перезаписывается. Для изменения source выбрать
|
||||||
|
новую pack version и явно выполнить refresh перед сборкой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pack
|
||||||
|
../.tools/packwiz/packwiz refresh
|
||||||
|
cd ..
|
||||||
|
./mcpack build
|
||||||
|
```
|
||||||
|
|
||||||
|
Development build допускает dirty worktree и отмечает его в manifest.
|
||||||
|
`./mcpack build --release` требует чистого Git state.
|
||||||
|
Подробности: [docs/build.md](docs/build.md). Тесты: `npm test`.
|
||||||
|
|
||||||
|
## Моды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack add <modrinth-slug|ID|URL>
|
||||||
|
./mcpack remove <mod>
|
||||||
|
./mcpack update <mod>
|
||||||
|
./mcpack update --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Команды работают с source через packwiz, сохраняют prompts и сообщения.
|
||||||
|
Для автоматического ответа добавить `--yes`. Перед следующим build выбрать
|
||||||
|
новую pack version. Подробности: [docs/mods.md](docs/mods.md).
|
||||||
|
|
||||||
|
## Клиент
|
||||||
|
|
||||||
|
Prism → Add Instance → Import: выбрать готовый `.mrpack`. В текущем WSL путь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
\\wsl.localhost\archlinux\root\Projects\minecraft-lab\dist\minecraft-lab-neoforge-spike-0.0.5\minecraft-lab-neoforge-spike-0.0.5.mrpack
|
||||||
|
```
|
||||||
|
|
||||||
|
Можно запускать с offline-аккаунтом/Launch Offline. Адрес локального сервера
|
||||||
|
**127.0.0.1:25565**: `localhost` может разрешиться в IPv6 `::1`, тогда как
|
||||||
|
локальный Phase 0 Compose публикует только IPv4 loopback.
|
||||||
|
Удалённый сервер доступен напрямую по **192.168.1.61:25565**.
|
||||||
|
|
||||||
|
## Сервер
|
||||||
|
|
||||||
|
Server Compose внутри полного релиза использует pinned image, offline
|
||||||
|
authentication и абсолютные `MC_ROOT`/`MC_RELEASE_DIR`. Настройки из
|
||||||
|
`server/config/` релиза имеют приоритет над pack defaults. Локальные secrets
|
||||||
|
и EULA acceptance в артефакт не попадают; `.env.example` содержит EULA=FALSE.
|
||||||
|
Команда build сервер не запускает и runtime не меняет.
|
||||||
|
|
||||||
|
Для lifecycle готового релиза добавлен `scripts/server.sh` с проверкой
|
||||||
|
checksums, локальной EULA/UID/GID и привязкой runtime к релизу. Первоначальная
|
||||||
|
настройка и start/restart/recreate — [docs/runtime.md](docs/runtime.md).
|
||||||
|
|
||||||
|
`scripts/server.sh up --wait --wait-timeout 120` ожидает Minecraft status
|
||||||
|
query и стабильную работу. `scripts/server.sh health` проверяет уже созданный
|
||||||
|
контейнер. Crash, restart loop и timeout возвращают non-zero с report/logs:
|
||||||
|
[docs/health.md](docs/health.md).
|
||||||
|
|
||||||
|
Для проверки Compose нового релиза:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MC_ROOT="$PWD" MC_RELEASE_DIR="$PWD/dist/minecraft-lab-neoforge-spike-0.0.5" \
|
||||||
|
.tools/docker-compose --project-name minecraft-lab-spike \
|
||||||
|
--env-file dist/minecraft-lab-neoforge-spike-0.0.5/server/.env.example \
|
||||||
|
-f dist/minecraft-lab-neoforge-spike-0.0.5/server/compose.yaml config --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Текущий Phase 0 сервер остаётся на 0.0.1 и управляется прежним wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/spike-server.sh ps
|
||||||
|
scripts/spike-server.sh logs --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Пользователь принял EULA для этого эксперимента. На новом host принятие
|
||||||
|
требуется отдельно. Готовый релиз хранится целиком; SSH deployment и
|
||||||
|
[rollback](docs/rollback.md) используют его без повторной сборки.
|
||||||
|
|
||||||
|
## Backup и восстановление
|
||||||
|
|
||||||
|
Для runtime с full-release binding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MC_ROOT=/absolute/server-root MC_RELEASE_DIR=/absolute/full-release ./mcpack backup
|
||||||
|
./mcpack restore --backup /absolute/snapshot --destination /absolute/new-server-root
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup останавливает сервер, проверяет архив всего runtime и сохраняет копию
|
||||||
|
исходного релиза. После успеха ранее запущенный сервер возвращается в работу.
|
||||||
|
Restore разрешён только в новый каталог; запуск выполняется отдельно.
|
||||||
|
Нужны GNU tar, zstd и права на чтение/сохранение владельцев данных.
|
||||||
|
Подробности и recovery procedure — [docs/backups.md](docs/backups.md).
|
||||||
|
|
||||||
|
## SSH deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack deploy --artifact /absolute/full-release
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH target задаётся в `platform/config.yaml`, host `.env` готовится отдельно.
|
||||||
|
Deployment принимает clean releases, передаёт payload через rsync, проверяет
|
||||||
|
конфликты, создаёт backup и обновляет active/previous только после healthcheck.
|
||||||
|
Тот же healthy release повторно не перезапускается.
|
||||||
|
Порт и Node из nvm можно задать через `--ssh-config` и `--remote-node`.
|
||||||
|
Bind interface задаётся host `.env`: `MC_BIND_ADDRESS=0.0.0.0` для remote LAN,
|
||||||
|
`127.0.0.1` для локального запуска. Настроенный host и команды подключения — [docs/deploy.md](docs/deploy.md).
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
`platform/` — config, compatibility record и build orchestration; `pack/` —
|
||||||
|
источник модпака; `server/` — Compose и settings; `scripts/` — CLI entry и
|
||||||
|
spike-утилиты. `docs/`, `examples/`, `testdata/`, `tests/` содержат результаты
|
||||||
|
и материалы проверок. Runtime, dist, tools, node_modules, backups и local
|
||||||
|
secrets исключены из Git, кроме placeholders.
|
||||||
|
|
||||||
|
Результаты: [Phase 0](docs/spike-results.md), [Phase 1](docs/phase1-results.md),
|
||||||
|
[Phase 2](docs/phase2-results.md), [Phase 3](docs/phase3-results.md),
|
||||||
|
[Phase 4](docs/phase4-results.md), [Phase 5](docs/phase5-results.md),
|
||||||
|
[Phase 6](docs/phase6-results.md), [Phase 7](docs/phase7-results.md),
|
||||||
|
[Phase 8](docs/phase8-results.md), [Phase 9](docs/phase9-results.md).
|
||||||
|
|
||||||
|
Выпуск из чистого Git worktree: `./mcpack release 0.0.6`.
|
||||||
|
Команда обновляет версию, проверяет сборку, коммитит `pack.toml`, собирает
|
||||||
|
финальный release и создаёт annotated tag. [Workflow и recovery](docs/releases.md).
|
||||||
|
Fabric-эксперимент архивирован в `examples/fabric-spike/` и
|
||||||
|
[fabric-spike-results.md](docs/fabric-spike-results.md).
|
||||||
Vendored
+118
@@ -0,0 +1,118 @@
|
|||||||
|
# Backup и восстановление — Phase 5
|
||||||
|
|
||||||
|
`./mcpack backup` работает с полным релизом и runtime, который уже связан
|
||||||
|
с ним через `scripts/server.sh`. Нужны Node/npm dependencies, закреплённый
|
||||||
|
Compose, Docker daemon, GNU tar и zstd. Оператор должен читать весь runtime
|
||||||
|
и сохранять владельцев при распаковке: запускать от владельца данных или
|
||||||
|
с необходимыми правами. Проверка UID/GID не пропускается.
|
||||||
|
|
||||||
|
## Создание snapshot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MC_ROOT=/absolute/server-root
|
||||||
|
export MC_RELEASE_DIR=/absolute/full-release
|
||||||
|
# MC_ENV_FILE — необязательный абсолютный путь, по умолчанию MC_ROOT/.env.
|
||||||
|
./mcpack backup --wait-timeout 120
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда удерживает общую `.mcpack-runtime.lock`, проверяет релиз/settings,
|
||||||
|
останавливает сервер через Compose и подтверждает остановку по Docker state.
|
||||||
|
После этого архивирует **весь `MC_ROOT/runtime` без исключений**: world,
|
||||||
|
данные модов вне world, конфиги, server.properties, whitelist/operators,
|
||||||
|
логи, cache, downloads и установленные server/mod JAR. Для NeoForge безопасные
|
||||||
|
исключения пока не подтверждены. Releases, snapshots и host `.env` находятся
|
||||||
|
вне runtime и не входят в архив; внутри snapshot хранится отдельная полная
|
||||||
|
копия исходного релиза, включая его `.env.example`.
|
||||||
|
|
||||||
|
Результат находится в `MC_ROOT/backups/<UTC timestamp>-<UUID>/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime.tar.zst
|
||||||
|
metadata.json
|
||||||
|
SHA256SUMS
|
||||||
|
release/ # исходный полный релиз с собственными checksums
|
||||||
|
```
|
||||||
|
|
||||||
|
Metadata фиксирует исходный релиз, SHA256 manifest/checksum list, закреплённый
|
||||||
|
image, целевой релиз (если передан `--target-release /absolute/release`),
|
||||||
|
прежнее состояние сервера, подтверждение остановки, время, исключения,
|
||||||
|
архив SHA256/размер и inventory путей/хешей/размеров/mode/UID/GID runtime.
|
||||||
|
`--target-release` только проверяет и записывает назначение будущего deployment;
|
||||||
|
команда backup сама не применяет новый релиз.
|
||||||
|
|
||||||
|
Перед публикацией выполняются tar/zstd listing, проверка типов/путей и
|
||||||
|
пробная распаковка с точным сравнением inventory. Checksums покрывают все
|
||||||
|
файлы snapshot и сохранённого релиза. Каталог публикуется атомарным rename
|
||||||
|
внутри backups; незавершённый snapshot не появляется под окончательным именем.
|
||||||
|
При успехе ранее запущенный сервер возвращается на тот же релиз через
|
||||||
|
`up --wait`. Остановленный сервер или отсутствующий контейнер не запускаются.
|
||||||
|
|
||||||
|
Ошибка backup возвращает non-zero. При ошибке после успешной остановки сервер
|
||||||
|
остаётся остановленным; оператор получает ошибку и status в
|
||||||
|
`MC_ROOT/.mcpack-backup-operation.json`. Ошибка resume сохраняет опубликованный
|
||||||
|
проверенный snapshot и пытается снова остановить сервер. Новый deployment
|
||||||
|
должен использовать `backupBeforeApply` из `platform/backups.mjs`: callback
|
||||||
|
применения допускается только после публикации snapshot и под той же блокировкой.
|
||||||
|
Этот gate проверен тестами и используется SSH deployment Phase 7 под общей
|
||||||
|
блокировкой всей операции: [deploy.md](deploy.md).
|
||||||
|
|
||||||
|
Snapshot не удаляется автоматически. Нужны свободное место под полный runtime,
|
||||||
|
пробную распаковку и копию релиза; операция завершается ошибкой при нехватке места.
|
||||||
|
Символические/жёсткие ссылки и специальные файлы в runtime сейчас отклоняются.
|
||||||
|
Команды вне wrapper не участвуют в блокировке; во время backup не изменять
|
||||||
|
runtime вручную и не запускать контейнер прямым Compose.
|
||||||
|
|
||||||
|
## Восстановление в отдельный каталог
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack restore \
|
||||||
|
--backup /absolute/server-root/backups/TIMESTAMP-UUID \
|
||||||
|
--destination /absolute/restored-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Destination должен **не существовать**. Команда сначала проверяет snapshot,
|
||||||
|
распаковывает во временный соседний каталог, сверяет bytes/mode/UID/GID,
|
||||||
|
проверяет сохранённый релиз и публикует новый root атомарным rename.
|
||||||
|
Исходный runtime не изменяется. Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
restored-server/runtime/
|
||||||
|
restored-server/release/
|
||||||
|
restored-server/.mcpack-runtime.json
|
||||||
|
restored-server/restore-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Host settings и EULA acceptance из snapshot не восстанавливаются; сервер
|
||||||
|
автоматически не запускается. Для ручного recovery подготовить `.env` из
|
||||||
|
`release/server/.env.example`, задать прежние UID/GID, подходящие memory/port
|
||||||
|
и принятие EULA. Используется прежний стабильный Compose project из binding.
|
||||||
|
Прежде чем запускать восстановленную копию под тем же project, остановить
|
||||||
|
и удалить **старый контейнер** через wrapper (данные старого root сохраняются):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MC_ROOT=/absolute/original-server \
|
||||||
|
MC_RELEASE_DIR=/absolute/original-release scripts/server.sh down
|
||||||
|
|
||||||
|
# На данном host settings уже настроены и EULA принята:
|
||||||
|
export MC_ROOT=/absolute/restored-server
|
||||||
|
export MC_RELEASE_DIR="$MC_ROOT/release"
|
||||||
|
export MC_ENV_FILE=/absolute/prepared-host-settings.env
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
scripts/server.sh exec -T minecraft mc-health
|
||||||
|
scripts/server.sh logs --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Не заменять binding или release на более новую версию: восстановленный мир
|
||||||
|
нужно запускать с сохранённым исходным релизом. Если прежний контейнер ещё
|
||||||
|
использует другой root, wrapper отклонит запуск. Это ручное восстановление
|
||||||
|
в отдельный root; переключение active release и rollback — последующие фазы.
|
||||||
|
|
||||||
|
## Область проверки
|
||||||
|
|
||||||
|
Проверен local Linux/WSL Docker и NeoForge 21.1.252 / Minecraft 1.21.1.
|
||||||
|
Primary Phase 0 runtime пока управляется старым spike wrapper без full-release
|
||||||
|
binding, поэтому его миграция не выполняется этой командой. Off-host копии,
|
||||||
|
retention, автоматический rollback и защита от внезапного отключения питания
|
||||||
|
во время записи (fsync) остаются последующими улучшениями.
|
||||||
|
|
||||||
|
Реальный опыт восстановления и свидетельства: [phase5-results.md](phase5-results.md).
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Build contract — Phase 2
|
||||||
|
|
||||||
|
Из корня проекта, с Node.js 22+ и npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
scripts/spike-tools.sh
|
||||||
|
./mcpack build
|
||||||
|
```
|
||||||
|
|
||||||
|
Дополнительные параметры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack build --config platform/config.yaml
|
||||||
|
./mcpack build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
`--config` выбирает YAML manifest; относительные пути pack/output внутри
|
||||||
|
него считаются от корня репозитория. По умолчанию build разрешает dirty
|
||||||
|
worktree и записывает это в manifest. `--release` требует чистого worktree.
|
||||||
|
Команды не делают commit/tag/deploy и не запускают Minecraft.
|
||||||
|
|
||||||
|
Имя, версия, Minecraft и loader читаются из TOML packwiz. Полный релиз
|
||||||
|
создаётся в `<output>/<name>-<version>/` и содержит:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<name>-<version>.mrpack
|
||||||
|
server/compose.yaml
|
||||||
|
server/.env.example
|
||||||
|
server/config/
|
||||||
|
server/README.md
|
||||||
|
manifest.json
|
||||||
|
SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
Релиз той же версии не перезаписывается. Для изменённого source нужно выбрать
|
||||||
|
новую версию pack, явно выполнить packwiz refresh, затем build. Для локального
|
||||||
|
эксперимента с прежней версией можно выбрать другой output directory через
|
||||||
|
отдельный config. Не изменять опубликованный release directory вручную.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
1. Parse/validate YAML и TOML: обязательные поля, индексы/hash, explicit side,
|
||||||
|
дубликаты mod targets/project IDs, пути, подтверждённый loader/image.
|
||||||
|
2. Проверить SHA256 packwiz/Compose по lock и Git state/известные secret paths.
|
||||||
|
3. Получить lock конкретной версии и временный каталог в output directory.
|
||||||
|
4. Скопировать pack; выполнить packwiz refresh/export. Любое изменение
|
||||||
|
metadata после refresh останавливает build, исходники не исправляются.
|
||||||
|
5. Проверить экспорт, сформировать серверные файлы и выполнить Compose config.
|
||||||
|
6. Записать manifest, покрыть SHA256SUMS все release files кроме SHA256SUMS;
|
||||||
|
проверить checksums и неизменность build inputs.
|
||||||
|
7. Атомарно переименовать готовый временный релиз; убрать временные файлы/lock.
|
||||||
|
|
||||||
|
При обычной ошибке partial release не остаётся. После принудительного убийства
|
||||||
|
процесса может остаться `.build-*.lock`/временный каталог: удалять их только
|
||||||
|
убедившись, что соответствующий build уже не работает.
|
||||||
|
|
||||||
|
Детерминированными являются имя и размещение артефактов. Побайтовая
|
||||||
|
воспроизводимость ZIP не обещается: packwiz может включать timestamps.
|
||||||
|
|
||||||
|
## Server payload
|
||||||
|
|
||||||
|
Compose образ закреплён literal digest из toolchain lock. Memory/port defaults
|
||||||
|
берутся из platform config; local env может переопределять их при запуске.
|
||||||
|
Runtime и release подключаются через абсолютные `MC_ROOT` и `MC_RELEASE_DIR`.
|
||||||
|
Release не зависит от checkout или его `.tools/`. Используется стабильное
|
||||||
|
Compose project name `minecraft-lab-spike`, как в текущем локальном runtime.
|
||||||
|
|
||||||
|
`server/config/` включается в релиз и монтируется как `/config`: его файлы
|
||||||
|
имеют приоритет над соответствующими pack defaults. Локальный исходный
|
||||||
|
Compose spike по-прежнему включает этот слой только явным override.
|
||||||
|
Server properties генерируются environment, отдельного source-файла нет.
|
||||||
|
|
||||||
|
`.env`, активный локальный compose override, SSH target и platform YAML
|
||||||
|
не копируются в release. Публичный `.env.example` имеет EULA=FALSE.
|
||||||
|
Проверка известных secret filenames не является универсальным secret scanner.
|
||||||
|
|
||||||
|
## Manifest v1
|
||||||
|
|
||||||
|
- `pack`: name/version, Minecraft, loader ID/version.
|
||||||
|
- `git`: commit и dirty flag; generated output исключается из dirty check.
|
||||||
|
- `tools`: packwiz commit/hash, Compose version/hash, Node version/npm lock hash.
|
||||||
|
- `server`: image, подтверждённая Java major, defaults и project name.
|
||||||
|
- `source_files`: относительные пути inputs и SHA256, без содержимого config.
|
||||||
|
- `managed_files.client/server`: runtime paths, owner (`pack`/`server`),
|
||||||
|
hashes и политика replace; server override помечает заменённого владельца.
|
||||||
|
- `runtime_policy`: неизвестные файлы preserve, defaultconfigs seed-only.
|
||||||
|
- `release_files`: hashes payload без manifest и SHA256SUMS, чтобы избежать
|
||||||
|
циклического хеширования. SHA256SUMS дополнительно покрывает manifest.
|
||||||
|
|
||||||
|
Ownership описывает контракт для будущего deploy; Phase 2 сама ничего
|
||||||
|
не удаляет из runtime. Совместимость Java проверяется по записи Phase 0
|
||||||
|
в `platform/compatibility.json`, а не новым запуском Docker при каждом build.
|
||||||
|
Новая комбинация требует отдельного spike и записи результата.
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
# SSH deployment — Phase 7
|
||||||
|
|
||||||
|
`./mcpack deploy` применяет готовый полный immutable release через SSH/rsync.
|
||||||
|
Если artifact ещё не создан, команда вызывает clean `build --release`;
|
||||||
|
если уже есть, проверяет его checksums и provenance. Development artifact
|
||||||
|
с `git.dirty=true` отклоняется, даже если текущий worktree уже чистый.
|
||||||
|
|
||||||
|
## Подготовка цели
|
||||||
|
|
||||||
|
На host нужны Linux x86_64, Node.js 22+, доступный Docker daemon, rsync,
|
||||||
|
GNU tar и zstd. Pinned Compose и небольшой Node agent с закреплённым YAML
|
||||||
|
parser передаются из локального bootstrap/dependencies; системный Docker
|
||||||
|
не устанавливается. У оператора должны быть права на весь runtime и его
|
||||||
|
владельцев; выбранные UID/GID согласовать с владельцем каталога.
|
||||||
|
|
||||||
|
В `platform/config.yaml` выбрать SSH host/alias и **отдельный root сборки**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
host: crab@192.168.1.61
|
||||||
|
directory: /home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike
|
||||||
|
memory: 2G
|
||||||
|
port: 25565
|
||||||
|
```
|
||||||
|
|
||||||
|
Host `.env` готовится отдельно, вне releases, с EULA/UID/GID/memory/port
|
||||||
|
и стабильным MC_PROJECT_NAME. Runtime startup читает именно этот файл;
|
||||||
|
build defaults не перезаписывают host settings. Первое применение требует
|
||||||
|
пустого runtime без старого binding; существующий неопознанный мир не
|
||||||
|
принимается автоматически. У разных сборок разные roots, projects и ports.
|
||||||
|
|
||||||
|
SSH host key должен быть известен заранее. Используются BatchMode,
|
||||||
|
StrictHostKeyChecking=yes и keepalive. Ключи/порты задать в SSH config;
|
||||||
|
password-only сессия может использовать заранее аутентифицированный
|
||||||
|
ControlMaster. Пароль не записывается в config, env, artifact или agent.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack deploy --artifact /absolute/full-release
|
||||||
|
# Порт/ControlMaster/known_hosts в отдельном SSH config:
|
||||||
|
./mcpack deploy --artifact /absolute/full-release --ssh-config /absolute/ssh-config
|
||||||
|
# Node из nvm, которого нет в non-interactive SSH PATH:
|
||||||
|
./mcpack deploy --artifact /absolute/full-release \
|
||||||
|
--ssh-config /absolute/ssh-config \
|
||||||
|
--remote-node /home/crab/.nvm/versions/node/v26.10.0/bin/node
|
||||||
|
```
|
||||||
|
|
||||||
|
`--config FILE` выбирает другой platform config. Timeout берётся из его
|
||||||
|
`deploy.healthcheck_timeout`; он должен быть больше 10 секунд stability.
|
||||||
|
Первый image/dependency download может требовать 600+ секунд. IPv6 host
|
||||||
|
и другие сложные адреса сейчас задавать через SSH alias.
|
||||||
|
|
||||||
|
## Выполнение и state
|
||||||
|
|
||||||
|
Под SSH удерживается одна `.mcpack-runtime.lock` на всё получение релиза,
|
||||||
|
backup и применение. Перед передачей проверяются незавершённый deployment,
|
||||||
|
source/binding, target version collision, host settings/права и свободное место.
|
||||||
|
Релиз передаётся rsync в `releases/.incoming-*`, проверяется на host и
|
||||||
|
публикуется atomic rename. Сбой передачи не останавливает исходный сервер.
|
||||||
|
Agent в `.mcpack/agent-UUID/` также проверяется по полной checksum coverage.
|
||||||
|
|
||||||
|
Перед применением проверяется каждый replace-managed файл прошлого release.
|
||||||
|
При изменении/missing file или занятом новом managed path возвращается конфликт.
|
||||||
|
Для небольших server-owned text configs выводится content diff, для остальных
|
||||||
|
файлов — expected/actual hash. Неуправляемые файлы не удаляются. Нужно перенести
|
||||||
|
полезные правки в source и построить новую версию; флага force для молчаливого
|
||||||
|
перетирания конфигов нет.
|
||||||
|
|
||||||
|
Если тот же release уже активен, выполняется `health` без restart; успешный
|
||||||
|
повторный deploy возвращает noop. Одинаковая версия с другим manifest/checksums
|
||||||
|
отклоняется до остановки. Если активный release unhealthy, повторный deploy
|
||||||
|
не перезапускает его автоматически.
|
||||||
|
|
||||||
|
При смене релиза исходный сервер останавливается, подтверждается остановка,
|
||||||
|
создаётся verified snapshot всего runtime и исходного release. Только после
|
||||||
|
этого apply callback меняет binding, удаляет отсутствующие в новом релизе
|
||||||
|
известные replace-managed файлы и запускает target. Конфликты проверяются
|
||||||
|
повторно непосредственно перед apply. Image применяет pack/server overrides
|
||||||
|
при запуске; runtime-generated/unmanaged данные сохраняются.
|
||||||
|
|
||||||
|
После стабильного mc-health/status query обновляются previous-release и
|
||||||
|
active-release, каждый через atomic rename. Deployment state записывается
|
||||||
|
перед каждым этапом и связывает source/target identities, прежние pointers,
|
||||||
|
backup path и факт apply. Пара указателей не является единым filesystem
|
||||||
|
transaction: interrupted `committing` требует просмотра state перед recovery.
|
||||||
|
|
||||||
|
## Ошибка и ручное восстановление
|
||||||
|
|
||||||
|
Любой failed/incomplete `deployment-state.json` блокирует следующую операцию.
|
||||||
|
При прерывании SSH во время upload worker сохраняет failed state и освобождает
|
||||||
|
lock; при принудительном убийстве worker/power loss lock может остаться.
|
||||||
|
Не удалять stale lock, пока соответствующий host process действительно жив.
|
||||||
|
|
||||||
|
После ошибки **до apply** source/runtime binding не переключаются. При ошибке
|
||||||
|
backup сервер может остаться остановленным; проверить logs и backup operation.
|
||||||
|
После подтверждения неизменности мира можно запустить исходный release.
|
||||||
|
Failed state нужно сохранить для аудита и явно разрешить только после recovery.
|
||||||
|
|
||||||
|
Если `applied=false`, binding совпадает с active manifest, причина исправлена
|
||||||
|
и исходный сервер проверен health, сохранить failed record под отдельным именем
|
||||||
|
в server root, затем повторить deploy. Для ручного config edit новый clean
|
||||||
|
release должен содержать ровно текущие bytes этого managed файла — такой
|
||||||
|
осознанный перенос разрешается. Любое другое содержимое остаётся конфликтом.
|
||||||
|
Пример архивирования **только после перечисленных проверок на host**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv "$MC_ROOT/deployment-state.json" \
|
||||||
|
"$MC_ROOT/deployment-state-resolved-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Не применять этот способ к `applied=true`, неизвестному binding или
|
||||||
|
interrupted committing: сначала восстановить/завершить реальное состояние.
|
||||||
|
|
||||||
|
После ошибки **apply/startup** target останавливается, source pointers не
|
||||||
|
продвигаются, binding остаётся на фактически применённом target и snapshot
|
||||||
|
сохраняется. Старый pack автоматически не запускается на изменённом мире.
|
||||||
|
Восстановить snapshot в новый root вместе с сохранённым source release по
|
||||||
|
[backups.md](backups.md), подготовить host settings, удалить старый контейнер,
|
||||||
|
запустить восстановленную копию и проверить health. Не менять binding вручную,
|
||||||
|
чтобы обойти защиту существующего мира.
|
||||||
|
|
||||||
|
Для первого неудачного deploy без snapshot использовать state/release/diagnostics:
|
||||||
|
пустой мир можно повторно инициализировать отдельно либо явно завершить запуск
|
||||||
|
того же target. При ошибке `committing` сначала проверить actual binding,
|
||||||
|
Docker health и оба pointer files. Snapshot recovery реализован в
|
||||||
|
[Phase 8](rollback.md). Простое удаление failed state не является recovery.
|
||||||
|
|
||||||
|
## Текущий проверенный host
|
||||||
|
|
||||||
|
На `crab@192.168.1.61:2228` подготовлен root
|
||||||
|
`/home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike`.
|
||||||
|
Release 0.0.5 healthy (обновление публикации порта после Phase 7), UID/GID 1000:1000, memory 2G,
|
||||||
|
MC_PROJECT_NAME=minecraft-lab-neoforge-spike. Offline mode включён.
|
||||||
|
|
||||||
|
Clean release собран в отдельном checkout и опубликован локально:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack deploy \
|
||||||
|
--artifact dist/minecraft-lab-neoforge-spike-0.0.5 \
|
||||||
|
--ssh-config .tools/phase7-ssh-target/config \
|
||||||
|
--remote-node /home/crab/.nvm/versions/node/v26.10.0/bin/node
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH config хранит только соединение/known_hosts/ControlPath. После окончания
|
||||||
|
ControlMaster для password login сначала заново аутентифицировать сессию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -F .tools/phase7-ssh-target/config minecraft-lab-target true
|
||||||
|
```
|
||||||
|
|
||||||
|
Host settings теперь содержат `MC_BIND_ADDRESS=0.0.0.0`; порт 25565
|
||||||
|
публикуется на всех IPv4-интерфейсах host. В Prism подключаться напрямую
|
||||||
|
к **192.168.1.61:25565**, без SSH tunnel. Локальный Phase 0 сервер остаётся
|
||||||
|
отдельным на 127.0.0.1:25565. Default для новых host остаётся 127.0.0.1;
|
||||||
|
LAN bind выбирается явно в host .env. Проверка — [network-results.md](network-results.md).
|
||||||
|
|
||||||
|
Сохранённый remote agent содержит `scripts/server.sh`. Для lifecycle на host
|
||||||
|
выбрать этот agent, задать абсолютные MC_ROOT/MC_RELEASE_DIR, указать nvm Node
|
||||||
|
в PATH и выполнить wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=/home/crab/.nvm/versions/node/v26.10.0/bin:$PATH
|
||||||
|
export MC_ROOT=/home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike
|
||||||
|
export MC_RELEASE_DIR="$MC_ROOT/releases/$(cat "$MC_ROOT/active-release")"
|
||||||
|
# Подставить один из проверенных каталогов .mcpack/agent-*:
|
||||||
|
/absolute/agent/scripts/server.sh health
|
||||||
|
/absolute/agent/scripts/server.sh logs --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
При failed deployment брать фактически применяемый release из state/binding,
|
||||||
|
а не автоматически active-release. Agents/releases/backups автоматически
|
||||||
|
не удаляются. Свидетельства и границы проверок — [phase7-results.md](phase7-results.md).
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# Phase 0 — integration spike
|
||||||
|
|
||||||
|
Дата: 2026-10-03. Статус: **предварительный Fabric-эксперимент проверен; целевой Phase 0 должен проверить NeoForge/Forge без аккаунтов**.
|
||||||
|
GUI-проверки не заменяются проверкой ZIP или Minecraft status query.
|
||||||
|
|
||||||
|
## Окружение и версии
|
||||||
|
|
||||||
|
Arch Linux x86_64 в WSL2, kernel `6.18.40.1-microsoft-standard-WSL2`.
|
||||||
|
Изначально отсутствовали Linux packwiz, Docker, Compose, Go и Java.
|
||||||
|
В Windows обнаружен каталог PrismLauncher; аккаунты и существующие instances
|
||||||
|
не читались и не изменялись.
|
||||||
|
|
||||||
|
| Компонент | Зафиксированное значение |
|
||||||
|
| --- | --- |
|
||||||
|
| Minecraft | 1.21.1 |
|
||||||
|
| Fabric Loader | 0.19.5 |
|
||||||
|
| Fabric installer/launcher | 1.1.2, наблюдаемое значение в .fabric-manifest.json |
|
||||||
|
| Fabric API | 0.116.17+1.21.1, Modrinth `Mys3P7lK`, both |
|
||||||
|
| Mod Menu | 11.0.5, Modrinth `6lgOkclV`, client |
|
||||||
|
| Text Placeholder API | 2.4.2+1.21, Modrinth `U5bhVym2`, client в этой сборке |
|
||||||
|
| packwiz commit | `ef87d964f8cbd52b3b13ea42453ef322290e2b9e` |
|
||||||
|
| packwiz GitHub Actions run | `34043101039`, Linux 64-bit x86 |
|
||||||
|
| packwiz binary SHA-256 | `7f8ed641be0c28988895651e3270cc9ad4b40c436e3d5cf9b3bcdb718b4369c6` |
|
||||||
|
| Prism Launcher | 11.0.3-main, commit d393323fdc52ef645c48a04ca73aff2bbe24ffc9 (из локального лога) |
|
||||||
|
| JVM сервера | Temurin 21.0.12.1+1-LTS |
|
||||||
|
| Docker Engine | 29.8.2, storage driver overlayfs |
|
||||||
|
| Compose | v5.6.0, локальный официальный binary |
|
||||||
|
| itzg image digest | `sha256:99dc8b37f97c3b95915452180bbedf29a7ac8252932e372ce6e5b6e383a5d1e2` |
|
||||||
|
| itzg source revision | `beed693663b4c963d1bb4af1101855eccbc6a37b` |
|
||||||
|
| itzg image creation | 2026-10-02T12:03:38.864Z |
|
||||||
|
|
||||||
|
Образ первоначально скачан по тегу java21; Compose использует только digest.
|
||||||
|
JVM проверена без запуска Minecraft: Temurin OpenJDK 21.0.12.1+1-LTS.
|
||||||
|
Команда: `docker run --rm --entrypoint java <закреплённый-image> -version`.
|
||||||
|
|
||||||
|
## Фактически выполнено
|
||||||
|
|
||||||
|
1. Получены точные версии из официальных GitHub, Fabric и Modrinth API.
|
||||||
|
2. Скачан официальный packwiz из привязанного к commit workflow run;
|
||||||
|
SHA-256 архива и binary закреплены в `toolchain.lock.env`.
|
||||||
|
3. Установлен Docker в WSL (`pacman -S --needed --noconfirm docker`),
|
||||||
|
daemon запущен командой `systemctl start docker`, без enable при загрузке.
|
||||||
|
4. Скачан и проверен SHA-256 официального Compose v5.6.0 в `.tools/`.
|
||||||
|
5. Создан source pack и добавлены закреплённые моды через packwiz.
|
||||||
|
6. Для Text Placeholder API сторона явно изменена с both на client:
|
||||||
|
здесь библиотека нужна только client-only Mod Menu.
|
||||||
|
7. Обновлён source index, выполнен export в временной копии source.
|
||||||
|
8. Проверены содержимое ZIP, версии dependencies и env каждой записи.
|
||||||
|
9. `scripts/spike-server.sh config --quiet` и `bash -n scripts/*.sh` успешны.
|
||||||
|
10. После явного принятия EULA пользователем выставлен MC_EULA=TRUE в игнорируемом server/.env.
|
||||||
|
11. Первый запуск, status healthcheck, side filtering и bind mounts проверены на реальном сервере.
|
||||||
|
12. Проверены recreate, смена версии мода, удаление модов, конфиги и datapack на существующем мире.
|
||||||
|
13. Startup с отсутствующим mrpack вернул 1; hash level.dat при ошибке не изменился.
|
||||||
|
14. Исходный snapshot восстановлен в отдельный каталог; world/config совпали до запуска, сервер 0.0.1 снова healthy.
|
||||||
|
15. `git diff --check` успешен; .tools, артефакты, world, backups и .env игнорируются Git.
|
||||||
|
|
||||||
|
Артефакт:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/minecraft-lab-spike-0.0.1/minecraft-lab-spike-0.0.1.mrpack
|
||||||
|
SHA-256: 923fa6e5b54921b7b19925bbe634d91e09b113ccd512f1edaaeab818de3a9338
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP содержит `modrinth.index.json` и `overrides/config/spike-marker.txt`.
|
||||||
|
Fabric API помечен required на обеих сторонах; Mod Menu и Text Placeholder API
|
||||||
|
имеют `server: unsupported`. На установленном сервере подтверждено отсутствие обоих client-only JAR.
|
||||||
|
Маркер проверяет копирование произвольного конфига; его не читает игровой мод.
|
||||||
|
|
||||||
|
## Команды создания и воспроизведения
|
||||||
|
|
||||||
|
Первоначальный init из `pack/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml init \
|
||||||
|
--name minecraft-lab-spike --version 0.0.1 --author minecraft-lab \
|
||||||
|
--mc-version 1.21.1 --modloader fabric --fabric-version 0.19.5 -y
|
||||||
|
../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml modrinth add --project-id P7dR8mSH --version-id Mys3P7lK -y
|
||||||
|
../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml modrinth add --project-id mOgUt4GM --version-id 6lgOkclV -y
|
||||||
|
# Явно изменить side в placeholder-api.pw.toml на client.
|
||||||
|
../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
В новом checkout достаточно уже закоммиченного source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/spike-tools.sh
|
||||||
|
scripts/spike-build.sh
|
||||||
|
scripts/spike-server.sh config --quiet
|
||||||
|
bsdtar -xOf dist/minecraft-lab-spike-0.0.1/minecraft-lab-spike-0.0.1.mrpack modrinth.index.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Для изменения source packwiz запускается из `pack/` с локальными cache/config,
|
||||||
|
как в командах выше. Проверка SHA-256 артефакта выполняется из его каталога:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dist/minecraft-lab-spike-0.0.1
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
Build v0 не создаёт production release manifest, Git tags или удалённый deployment.
|
||||||
|
Игровые версии читаются из pack.toml; выбор MC_PACK — ссылка на готовый артефакт.
|
||||||
|
|
||||||
|
## Матрица проверки
|
||||||
|
|
||||||
|
| Проверка | Статус |
|
||||||
|
| --- | --- |
|
||||||
|
| packwiz описывает и экспортирует сборку | PASS |
|
||||||
|
| ZIP содержит закреплённые dependencies и side metadata | PASS |
|
||||||
|
| Compose config валиден | PASS |
|
||||||
|
| Prism импортирует и запускает pack | PASS: instance 0.0.1, Minecraft/Fabric и три JAR подтверждены |
|
||||||
|
| Сервер устанавливает локальный mrpack и становится healthy | PASS: mc-health, 1.21.1, online=0 |
|
||||||
|
| На сервере нет client-only JAR | PASS: только fabric-api JAR |
|
||||||
|
| World и config находятся в host bind mount | PASS |
|
||||||
|
| World переживает recreation контейнера | PASS: level.dat hash до запуска совпал |
|
||||||
|
| Обновление удаляет старый mod JAR | PASS: 0.116.17 → 0.116.16; затем удалены все JAR |
|
||||||
|
| Конфиги: pack defaults, server override, ручная правка | PASS: override победил; update перезаписал ручную правку |
|
||||||
|
| Backup/restore возвращает world и прежний release | PASS: восстановлен baseline и pack 0.0.1 |
|
||||||
|
| Startup failure возвращает non-zero | PASS: отсутствующий mrpack, exit 1; world не изменён |
|
||||||
|
| Клиент Prism подключается к offline-серверу | PENDING: повторная попытка после online-mode=false |
|
||||||
|
| Datapack на существующем мире | PASS: file/phase0 автоматически загружен в 0.0.2 |
|
||||||
|
| NeoForge/Forge defaultconfigs | НЕ ПРОВЕРЕНО: другой loader |
|
||||||
|
|
||||||
|
## Порядок серверных проверок
|
||||||
|
|
||||||
|
После принятия EULA скопировать `.env.example` в `.env` и выставить MC_EULA=TRUE.
|
||||||
|
Сервер изолирован локальным адресом, без публикации RCON. Первоначально ошибочно требовался online-mode=true; по уточнению пользователя переключён на online-mode=false и enforce-secure-profile=false. Microsoft/Mojang-аккаунт не требуется.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/spike-server.sh up -d --wait --wait-timeout 600
|
||||||
|
scripts/spike-server.sh ps
|
||||||
|
scripts/spike-server.sh logs --tail 100
|
||||||
|
scripts/spike-server.sh exec -T minecraft java -version
|
||||||
|
scripts/spike-server.sh exec -T minecraft mc-health
|
||||||
|
ls runtime/mods runtime/config runtime/world
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем остановить сервер и записать hash `runtime/world/level.dat`.
|
||||||
|
Recreate выполняется при остановленном сервере:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/spike-server.sh stop
|
||||||
|
sha256sum runtime/world/level.dat
|
||||||
|
scripts/spike-server.sh create --force-recreate
|
||||||
|
sha256sum runtime/world/level.dat
|
||||||
|
scripts/spike-server.sh start
|
||||||
|
```
|
||||||
|
|
||||||
|
Два hash до запуска должны совпасть. После старта world может штатно измениться;
|
||||||
|
это не потеря данных. Дополнительно проверить UUID/seed и игровой маркер из клиента.
|
||||||
|
|
||||||
|
Для воспроизведения вариантов на новом checkout после исходного build выполнить `scripts/spike-variants.sh`. Скрипт создаёт source-копии в .tools/repro-variants и артефакты 0.0.2/0.0.3; runtime не меняет. Повторный запуск при существующих каталогах прекращается без перезаписи.
|
||||||
|
|
||||||
|
0.0.2: Fabric API fpB2VatZ (0.116.16+1.21.1), marker v2, статический datapack из testdata/spike-datapack. 0.0.3: удалены Mod Menu, Text Placeholder API и Fabric API, остаётся loader и config marker. Исходный pack/ всё время оставался на 0.0.1.
|
||||||
|
Перед сменой MC_PACK обязательно остановить сервер и создать snapshot.
|
||||||
|
Проверить удаление прежнего JAR и фактическое состояние marker.
|
||||||
|
Возврат старого pack выполнять вместе с соответствующим snapshot.
|
||||||
|
|
||||||
|
Минимальная ручная процедура backup (в корне, после stop):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p backups
|
||||||
|
task_snapshot="backups/$(date -u +%Y%m%dT%H%M%SZ)-$(date +%s%N).tar.zst"
|
||||||
|
tar --exclude=./.gitkeep -C runtime -I zstd -cf "$task_snapshot" .
|
||||||
|
tar -I zstd -tf "$task_snapshot" > /dev/null
|
||||||
|
sha256sum "$task_snapshot" > "$task_snapshot.sha256"
|
||||||
|
# Записать рядом исходный MC_PACK и SHA256SUMS релиза.
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore: сервер остановлен; сначала сохранить текущее состояние. Проверить hash
|
||||||
|
архива, распаковать в новый каталог рядом с runtime и проверить файлы.
|
||||||
|
Переименовать текущий runtime в сохранённый каталог, новый — в runtime;
|
||||||
|
выбрать исходный MC_PACK и запустить с healthcheck. Не распаковывать поверх мира.
|
||||||
|
Этот ручной сценарий проверен: snapshot phase0-before-update восстановлен в отдельный каталог, исходный runtime сохранён в .tools/runtime-before-restore, затем запущен pack 0.0.1.
|
||||||
|
|
||||||
|
Prism: импортировать исходный .mrpack, запустить Minecraft, проверить Mod Menu,
|
||||||
|
зайти на localhost:25565, поставить различимый игровой маркер и проверить его
|
||||||
|
после recreation. Записать версию Prism, JVM клиента и фактический результат.
|
||||||
|
|
||||||
|
## Фактические результаты переходов
|
||||||
|
|
||||||
|
- Первый запуск: Minecraft 1.21.1, Fabric 0.19.5, JVM Temurin 21.0.12.1+1; mc-health отвечает version=1.21.1 online=0 max=20.
|
||||||
|
- Docker inspect: /data — rw bind на runtime/, /modpacks — ro bind на dist/. Named volumes отсутствуют.
|
||||||
|
- Recreate: до повторного запуска SHA-256 level.dat остался `32832255e84e2d8b4e324527d21b233b0b0cc4615ae17ee0296e7e8c5d4d9728`.
|
||||||
|
- Update 0.0.1 → 0.0.2: только fabric-api-0.116.16+1.21.1.jar, старый 0.116.17 удалён; manual-runtime-v1 заменён на pack-default-v2.
|
||||||
|
- Datapack: лог «Found new data pack file/phase0, loading it automatically», затем «[Phase0] datapack-v2-loaded».
|
||||||
|
- Optional override: `MC_PACK=minecraft-lab-spike-0.0.2/minecraft-lab-spike-0.0.2.mrpack scripts/spike-server.sh -f server/compose.spike-override.yaml up -d --wait --wait-timeout 120`; marker стал server-override-v1.
|
||||||
|
- Update → 0.0.3 без override: runtime/mods пуст, управляемые файлы прежнего datapack удалены, unmanaged-probe.txt сохранился.
|
||||||
|
- Failure: MC_PACK=missing-phase0.mrpack, Compose --wait вернул 1; контейнер exited(1), hash мира остался прежним.
|
||||||
|
- Restore: sha256sum snapshot проверен, распаковка в .tools/runtime-restored, сравнение world/config с baseline успешно; прежний runtime сохранён, восстановленный переименован в runtime. После запуска исходного pack 0.0.1 сервер healthy.
|
||||||
|
|
||||||
|
Каждый изменяющий тест предварялся корректной остановкой и snapshot. Пять архивов в backups/ имеют SHA-256 и JSON metadata: исходный артефакт, image digest, server override и время; исключений нет. Имена phase0-* относятся к однократному запуску этого эксперимента. Для обычных backups использовать уникальные ID из примера выше.
|
||||||
|
|
||||||
|
Артефакты тестовых переходов (ZIP timestamps могут отличаться при повторном export):
|
||||||
|
|
||||||
|
| Релиз | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| 0.0.2 | ca10d1faffa8a71ee58431f7f3f2ee88be4c7572e035cac8824331129b701fab |
|
||||||
|
| 0.0.3 | ced6fe830055ce3f0ae2d1c4c14e0fb41b5a8580bdaf5cac3010b0bdc8a9ceba |
|
||||||
|
|
||||||
|
Финальное состояние: сервер запущен с исходным pack 0.0.1 и восстановленным baseline, optional override выключен. Импорт и запуск Prism подтверждены локальными instance metadata и client log. Подключение не завершилось: Failed to log in: The authentication servers are currently not reachable. В client log sessionHost/servicesHost=https://nope.invalid и UnknownHostException: nope.invalid; launcher log содержит четыре -Dminecraft.api.*.host с этим адресом. В pack и instance/global JvmArgs такого override нет, переменные JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS/_JAVA_OPTIONS Windows этих overrides не содержат. Для offline-клиента эти параметры не являются ошибкой настройки: изначальное требование online-mode=true противоречило цели пользователя. Требование аккаунта снято; нужен offline-server login. Account files и токены не читались.
|
||||||
|
|
||||||
|
## Обнаруженные ограничения и изменения архитектуры
|
||||||
|
|
||||||
|
- packwiz автоматически добавил Text Placeholder API и выставил both, хотя он
|
||||||
|
нужен этой сборке только на клиенте. Metadata проекта не заменяет проверку
|
||||||
|
конкретного состава зависимостей. Свой resolver не нужен.
|
||||||
|
- В Arch package index Compose 5.5.1 ссылался на отсутствующий файл (HTTP 404).
|
||||||
|
Не обновлялась вся система: Docker установлен отдельно, Compose скачан локально.
|
||||||
|
- В минимальном окружении отсутствует cmp; build сравнивает SHA-256 source и
|
||||||
|
refresh-копии вместо дополнительной системной зависимости.
|
||||||
|
- Network-вызовы sandbox требуют разрешения. Подстановка latest не используется
|
||||||
|
в воспроизводимых командах; закреплены результаты первоначального выбора.
|
||||||
|
- GitHub Actions artifacts имеют ограниченный срок хранения. Bootstrap по run ID
|
||||||
|
пригоден для spike, но не для долговременного release tooling. Для него нужен
|
||||||
|
build packwiz из закреплённого commit или сохранённый binary с checksum.
|
||||||
|
- Пакет содержит ссылки на Modrinth downloads, а не все JAR; offline install
|
||||||
|
не доказан. Образ также загружает Minecraft и loader при первом старте.
|
||||||
|
- Windows Test-NetConnection 127.0.0.1:25565 вернул TcpTestSucceeded=True после восстановления. GET sessionserver hasJoined без сессии возвращает 204 из Windows, WSL и контейнера; GET api.minecraftservices.com/publickeys возвращает 200 из Windows и контейнера. Эти пробы не передавали токены и не подтверждают login, но исключают общую недоступность этих endpoints.
|
||||||
|
- Минимальная сборка использует Fabric API, не добавляя новые блоки/предметы. Она доказывает установку loader и модов; сетевую совместимость content-модов понадобится проверить на реальной сборке.
|
||||||
|
- Fabric был неверно выбран вместо целевых NeoForge/Forge. Его результаты сохраняются только как предварительный эксперимент. Целевой Phase 0 ещё требует modpack на NeoForge/Forge, defaultconfigs и подключения offline-клиента.
|
||||||
|
- При смене mrpack управляемый config заменяется, включая ручную правку. Неуправляемый файл сохраняется. Требуется проверка drift в будущей платформе до применения пакета.
|
||||||
|
- Server override через /config с SYNC_SKIP_NEWER_IN_DESTINATION=false применяется после modpack defaults и побеждает их. Это намеренная политика теста, не универсальная политика production.
|
||||||
|
- Datapacks нужны в world/datapacks, а не в корневом /data/datapacks. В варианте 0.0.2 проверен путь overrides/world/datapacks/phase0. Это статические исходники datapack, не snapshot мира. Если сохранить предложенный pack/datapacks layout, нужен явный перенос в целевой world path; world name должен учитываться.
|
||||||
|
- Важное ограничение воспроизводимости: local mrpack закрепляет loader, но не версию Fabric installer/launcher. В runtime наблюдается 1.1.2. В закреплённом образе TYPE=MODRINTH вызывает install-modrinth-modpack напрямую, не передавая FABRIC_LAUNCHER_VERSION; в help этого helper нет соответствующего параметра. Поэтому простое добавление Fabric env в Compose не доказывает фиксацию installer. Перед production release нужно выбрать проверенный способ фиксировать/сохранять launcher или явно ограничить обещание воспроизводимости. Собственный mod resolver для этого не нужен.
|
||||||
|
- Production deployment и автоматический rollback остаются вне scope Phase 0.
|
||||||
|
|
||||||
|
Официальные источники:
|
||||||
|
|
||||||
|
- [packwiz installation](https://packwiz.infra.link/installation/)
|
||||||
|
- [packwiz Modrinth export](https://packwiz.infra.link/tutorials/hosting/modrinth/)
|
||||||
|
- [itzg local mrpack](https://docker-minecraft-server.readthedocs.io/en/latest/types-and-platforms/mod-platforms/modrinth-modpacks/)
|
||||||
|
- [itzg healthcheck](https://docker-minecraft-server.readthedocs.io/en/latest/misc/healthcheck/)
|
||||||
|
- [Modrinth format](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack)
|
||||||
|
|
||||||
|
## Уточнение цели пользователем
|
||||||
|
|
||||||
|
Собирать пользовательские модпаки NeoForge/Forge, распространять клиентский артефакт и запускать сервер без проверки аккаунтов. ONLINE_MODE=false, ENFORCE_SECURE_PROFILE=false. Смена auth режима предварена backup phase0-before-offline-mode. Проверка Microsoft login не входит в acceptance criteria.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Healthcheck — Phase 6
|
||||||
|
|
||||||
|
Проверенная готовность доступна через `scripts/server.sh` с теми же абсолютными
|
||||||
|
MC_ROOT/MC_RELEASE_DIR и host settings, что описаны в [runtime.md](runtime.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MC_ROOT=/absolute/server-root
|
||||||
|
export MC_RELEASE_DIR=/absolute/full-release
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
# Проверить уже созданный контейнер без запуска/перезапуска:
|
||||||
|
scripts/server.sh health --wait-timeout 120
|
||||||
|
# Первый запуск с загрузкой dependencies может требовать больше времени:
|
||||||
|
scripts/server.sh up --wait --wait-timeout 600
|
||||||
|
```
|
||||||
|
|
||||||
|
`up/start/restart --wait` и `health` возвращают 0 только после штатного Docker
|
||||||
|
healthcheck `mc-health` и свежих успешных Minecraft status queries. Running
|
||||||
|
container и TCP connect сами по себе не подтверждают готовность.
|
||||||
|
|
||||||
|
По умолчанию окно стабильности — 10 секунд. На всём окне должны проходить
|
||||||
|
queries, контейнер не должен меняться или перезапускаться. Неудачный query
|
||||||
|
или health status, отличный от healthy, сбрасывает окно; проверки повторяются
|
||||||
|
до deadline. Пример с явно выбранным окном:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/server.sh health --wait-timeout 30 --stable-seconds 3
|
||||||
|
scripts/server.sh restart --wait --wait-timeout 120 --stable-seconds 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Timeout — целое положительное число секунд; он должен быть больше окна
|
||||||
|
стабильности. Если флаг не задан, берётся `deploy.healthcheck_timeout` из
|
||||||
|
`platform/config.yaml` (сейчас 120). Timeout учитывает Compose launch и
|
||||||
|
ожидание readiness; проверка входных данных и сбор диагностических логов
|
||||||
|
выполняются отдельно. Docker queries ограничены оставшимся временем, отдельный
|
||||||
|
status query — также максимум 10 секундами. Опрос выполняется примерно раз
|
||||||
|
в секунду. `up --wait` запускается detached; сочетание с attach/abort options
|
||||||
|
отклоняется. Флаги поддерживают и `--wait-timeout=120`.
|
||||||
|
|
||||||
|
Обычный `up -d` без `--wait` по-прежнему означает только запуск Compose и
|
||||||
|
не гарантирует готовность. Backup resume из Phase 5 использует `up --wait`
|
||||||
|
и теперь проходит новую проверку перед возвращением success.
|
||||||
|
|
||||||
|
## Ошибки и сохранённые свидетельства
|
||||||
|
|
||||||
|
Non-zero возвращается для timeout, crash/exited, paused, restart loop,
|
||||||
|
замены контейнера, отсутствующего container/healthcheck, ошибки Docker query
|
||||||
|
и ошибки Compose launch. Наблюдаемый restart или изменение StartedAt/RestartCount
|
||||||
|
во время ожидания считается нестабильным запуском и завершается ошибкой;
|
||||||
|
количество перезапусков до начала проверки используется как baseline.
|
||||||
|
|
||||||
|
На неудачном запуске выводятся пути:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MC_ROOT/diagnostics/health-TIMESTAMP-UUID.json
|
||||||
|
MC_ROOT/diagnostics/health-TIMESTAMP-UUID.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Report содержит причину, elapsed/timeout/stability, последнее Docker state
|
||||||
|
(включая exit code/OOM/restart count), результат последнего status query,
|
||||||
|
project и SHA256 release manifest. При отказе самого Compose сохраняются
|
||||||
|
его exit/error/signal; readiness ещё не проверена. Log содержит последние
|
||||||
|
100 строк Compose logs. Если получение логов тоже не удалось, это записывается
|
||||||
|
в log. Диагностика имеет mode 600, каталог создаётся с mode 700.
|
||||||
|
|
||||||
|
Recovery state сообщает, что runtime и binding сохранены, active release
|
||||||
|
не переключался, automatic rollback не выполнялся. Поле backup_snapshot
|
||||||
|
ссылается на snapshot последней записанной backup operation, если он есть;
|
||||||
|
null не означает, что других snapshots нет. Их можно найти в MC_ROOT/backups.
|
||||||
|
Healthcheck не удаляет runtime, не распаковывает backup и не переключает релиз.
|
||||||
|
После timeout контейнер может продолжать startup; исправить причину/увеличить
|
||||||
|
timeout и повторить `health`. После crash можно запустить тот же релиз через
|
||||||
|
`up --wait`. При restart loop сначала изучить report/log и остановить сервер
|
||||||
|
через `scripts/server.sh stop`. Восстановление — [backups.md](backups.md).
|
||||||
|
|
||||||
|
Startup и отдельный `health` удерживают общую lifecycle lock до завершения
|
||||||
|
проверки. Другой wrapper не сможет одновременно выполнить backup/restart/down.
|
||||||
|
Контейнер другого runtime root отклоняется до запроса игрового протокола.
|
||||||
|
Прямые Docker/Compose команды и ручное изменение данных обходят эту блокировку.
|
||||||
|
|
||||||
|
Это ограниченная проверка готовности после запуска, не постоянно работающий
|
||||||
|
watchdog. Ручное подключение клиента и состав модов проверяются отдельно;
|
||||||
|
SSH deployment и переключение active-release используют эту проверку
|
||||||
|
в Phase 7: [deploy.md](deploy.md).
|
||||||
|
|
||||||
|
Реальные проверки crash/restart loop/timeout — [phase6-results.md](phase6-results.md).
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Управление модами — Phase 3
|
||||||
|
|
||||||
|
Из корня репозитория, после установки npm dependencies и packwiz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack add mouse-tweaks
|
||||||
|
./mcpack remove mouse-tweaks
|
||||||
|
./mcpack update sophisticated-backpacks
|
||||||
|
./mcpack update --all
|
||||||
|
```
|
||||||
|
|
||||||
|
`add` использует Modrinth; принимает slug, project ID, URL или одну строку
|
||||||
|
поиска в кавычках. Конкретная версия выбирается штатным version URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack add https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X
|
||||||
|
```
|
||||||
|
|
||||||
|
`remove` и `update <mod>` используют имя metadata, понятное packwiz, например
|
||||||
|
`sophisticated-backpacks`. Wrapper сохраняет stdin/stdout/stderr packwiz,
|
||||||
|
его prompts и код завершения. По умолчанию автоматического согласия нет.
|
||||||
|
`--yes` или `-y` явно передаёт packwiz `--yes`; при поиске packwiz может
|
||||||
|
выбрать первый результат, поэтому для точного выбора использовать slug/ID/URL.
|
||||||
|
|
||||||
|
Каждая команда поддерживает `--config FILE`. Config выбирает pack directory
|
||||||
|
относительно корня проекта; cwd вызывающей стороны не меняет выбор pack.
|
||||||
|
Проверяются config, пути и SHA256 закреплённого packwiz. Аргументы передаются
|
||||||
|
массивом без shell, строки поиска не исполняются как команды.
|
||||||
|
|
||||||
|
Команды изменяют source metadata/indexes через packwiz. Они не повышают
|
||||||
|
версию сборки, не запускают build, не изменяют готовые релизы и runtime.
|
||||||
|
Перед новым build выбрать новую `version` в `pack/pack.toml`, поскольку
|
||||||
|
существующий релиз той же версии не перезаписывается. Например, следующим
|
||||||
|
после 0.0.4 может быть 0.0.5. Правила build — [build.md](build.md).
|
||||||
|
|
||||||
|
Side metadata и dependencies остаются ответственностью packwiz; wrapper
|
||||||
|
не переписывает resolver и не удаляет автоматически оставшиеся dependencies.
|
||||||
|
Применение модов к уже работающему серверу относится к следующим фазам.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
bash scripts/verify-phase3.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Вторая команда требует сети для Modrinth и работает в одноразовом локальном
|
||||||
|
Git clone внутри `.tools/`, с копиями проверенных инструментов и установленными
|
||||||
|
npm dependencies. Удаляет Backpacks, добавляет 3.26.6.2174, обновляет до
|
||||||
|
3.26.7.2182 отдельно и через `--all`. После каждого состояния выпускает
|
||||||
|
полный тестовый релиз с Compose/manifest/checksums. Основной pack и runtime
|
||||||
|
не меняются. Network/provider errors возвращаются как ошибки packwiz.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Remote game port: 0.0.5
|
||||||
|
|
||||||
|
2026-10-03 удалённый NeoForge сервер переведён с loopback на полноценную
|
||||||
|
IPv4-публикацию порта. Подключение клиента теперь **192.168.1.61:25565**,
|
||||||
|
SSH tunnel не нужен.
|
||||||
|
|
||||||
|
- Source pack version поднята до 0.0.5; новый clean immutable release содержит
|
||||||
|
`${MC_BIND_ADDRESS:-127.0.0.1}:${MC_PORT:-25565}:25565` в Compose.
|
||||||
|
- Remote host `.env`: `MC_BIND_ADDRESS=0.0.0.0`; локальный default — 127.0.0.1.
|
||||||
|
Значение проверяется как IPv4 и берётся из host settings, а не ambient shell.
|
||||||
|
- Release построен через `scripts/build-clean-release.sh`: отдельный source
|
||||||
|
snapshot/Git checkout, clean build и проверенная atomic публикация в dist.
|
||||||
|
Основной Git worktree не коммитился; старые immutable releases не изменялись.
|
||||||
|
- Перед update создан verified backup исходного runtime и полного release 0.0.4:
|
||||||
|
`backups/20261003T161610568Z-ca83dc50-0582-4ac7-b98a-353dc957f486`
|
||||||
|
внутри remote server root. В snapshot сохранены 13 файлов мира и остальные
|
||||||
|
persistent данные; host `.env` остаётся вне snapshot.
|
||||||
|
- Deployment complete: active=0.0.5, previous=0.0.4, health=healthy.
|
||||||
|
Тот же `/runtime` bind используется после recreate. Server override marker
|
||||||
|
остался `server-override-v1`; состав/версии модов не менялись.
|
||||||
|
- Docker inspect подтверждает HostIp=0.0.0.0, HostPort=25565.
|
||||||
|
- Прямой Minecraft status query из локального workspace к 192.168.1.61:25565
|
||||||
|
без tunnel вернул Minecraft 1.21.1, protocol 767, online=0/max=20.
|
||||||
|
- Локальный Phase 0 сервер на loopback:25565 остался healthy.
|
||||||
|
|
||||||
|
`npm test`: 58 pass. Проверены build port interpolation, bind validation
|
||||||
|
и приоритет host settings; backup/deployment/health regression tests проходят.
|
||||||
|
Bash/Node syntax и git diff --check пройдены. Дополнительный protocol checker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/query-minecraft.mjs 192.168.1.61 25565
|
||||||
|
```
|
||||||
|
|
||||||
|
Свидетельства: `.tools/lan-port-evidence/deploy.log`, `direct-status.json`,
|
||||||
|
`remote-verification.json`, `primary-server-status.txt`, `.tools/lan-port-tests.log`.
|
||||||
|
Публикация проверена в LAN; router/NAT configuration этим изменением не выполнялась.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Phase 1: каркас проекта
|
||||||
|
|
||||||
|
Phase 1 завершена для рабочей основы NeoForge 21.1.252 / Minecraft 1.21.1.
|
||||||
|
|
||||||
|
## Каркас
|
||||||
|
|
||||||
|
- `platform/config.yaml`: schema version, pack/output paths, server defaults,
|
||||||
|
будущий SSH deployment target. Host/directory не заданы.
|
||||||
|
- `platform/schemas/`: место для будущей схемы вместе с parser в Phase 2.
|
||||||
|
- `pack/`: проверенный packwiz source без изменения версий и состава модов.
|
||||||
|
- `server/`: Compose, `.env.example`, явно подключаемый override template.
|
||||||
|
- `scripts/`: минимальные spike-инструменты; основного CLI пока нет.
|
||||||
|
- `docs/`, `examples/`, `testdata/`: результаты и материалы воспроизведения.
|
||||||
|
- `dist/`, `runtime/`: только `.gitkeep` включается в source.
|
||||||
|
|
||||||
|
`server.properties` генерируется из Compose environment, поэтому отдельный
|
||||||
|
source-файл не создан. Дополнительные pack-директории создаются по мере
|
||||||
|
появления контента; пустые файлы не добавляются в экспорт. Makefile и команды
|
||||||
|
deploy/backup/rollback появятся в соответствующих фазах, без пустых заглушек.
|
||||||
|
|
||||||
|
Platform manifest пока не читается spike-скриптами. Текущий runtime использует
|
||||||
|
env-файлы; подключение manifest и validation относятся к Phase 2.
|
||||||
|
|
||||||
|
## Проверка на чистом clone
|
||||||
|
|
||||||
|
Из неигнорируемых файлов рабочего дерева создан временный Git snapshot в
|
||||||
|
`.tools/phase1-check.0Od3T1/source`, затем выполнен локальный `git clone` в
|
||||||
|
соседний `clone/`. Основной репозиторий не коммитился и не менял index.
|
||||||
|
В clone отсутствовали прежние `.tools/`, runtime state, backups и `.env`.
|
||||||
|
|
||||||
|
Пройдены:
|
||||||
|
|
||||||
|
1. `scripts/spike-tools.sh`: новые downloads packwiz и Compose;
|
||||||
|
архив/бинарники совпали с закреплёнными SHA256, Compose v5.6.0.
|
||||||
|
2. `packwiz refresh`: успешен, `git diff --exit-code -- pack` подтвердил
|
||||||
|
отсутствие изменений metadata после refresh/export/build.
|
||||||
|
3. `packwiz modrinth export`: успешен, ZIP содержит index и pack config.
|
||||||
|
4. `scripts/spike-server.sh config --quiet`: основной Compose валиден.
|
||||||
|
5. То же с `-f server/compose.override.yaml.example`: слой overrides валиден.
|
||||||
|
6. `scripts/spike-build.sh`: экспорт в новый `dist/` с SHA256SUMS успешен.
|
||||||
|
7. Bash syntax и `git diff --check`: успешны.
|
||||||
|
8. `git check-ignore`: runtime, dist, tools, backups, локальные `.env` и
|
||||||
|
активный override игнорируются; `.env.example` не игнорируется.
|
||||||
|
|
||||||
|
Проверка Compose не запускала сервер; активный мир и артефакт не изменены.
|
||||||
|
Точный порядок работы для нового checkout описан в корневом README.
|
||||||
|
|
||||||
|
Bootstrap использует закреплённый GitHub Actions artifact packwiz; такие
|
||||||
|
артефакты имеют ограниченный срок хранения. Для долгосрочного bootstrap
|
||||||
|
потребуется постоянное хранение проверенного бинарника либо сборка
|
||||||
|
закреплённого commit. Текущий URL проверен свежим скачиванием.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Phase 2: build command
|
||||||
|
|
||||||
|
Phase 2 завершена. `./mcpack build` читает platform YAML, валидирует packwiz
|
||||||
|
source и создаёт полный релиз, не меняя source или активный runtime.
|
||||||
|
Node.js 22+ используется для orchestration с закреплёнными YAML/TOML parsers;
|
||||||
|
экспорт и dependency resolution остаются у packwiz.
|
||||||
|
|
||||||
|
## Выпущенный development artifact
|
||||||
|
|
||||||
|
`dist/minecraft-lab-neoforge-spike-0.0.4/` содержит `.mrpack`, `server/`,
|
||||||
|
`manifest.json`, `SHA256SUMS`. Все файлы кроме SHA256SUMS покрыты checksums,
|
||||||
|
включая manifest. Pack version увеличена с 0.0.1 до 0.0.4, чтобы сохранить
|
||||||
|
существующие spike-артефакты 0.0.1–0.0.3 без перезаписи. Состав модов,
|
||||||
|
Minecraft и NeoForge не менялись.
|
||||||
|
|
||||||
|
- `.mrpack` SHA256: `877b34bf582f9d06b9dbd2832db8a1b376a786be21826628229df52a6672cd52`.
|
||||||
|
- Manifest SHA256: `5933ed34f35473026a32f0c3eab9a8b86b6fb484461bac2f670e0a74930aaa0c`.
|
||||||
|
- Git commit: `85f6897365b8d4717d8670808d2223c3024d247f`, `dirty=true`.
|
||||||
|
|
||||||
|
Dirty flag отражает незакоммиченную текущую работу. Main repo не коммитился.
|
||||||
|
Development build допускает это; `--release` требует чистого worktree.
|
||||||
|
|
||||||
|
## Подтверждённые проверки
|
||||||
|
|
||||||
|
- Реальный `./mcpack build` с закреплённым packwiz: refresh/export успешны,
|
||||||
|
metadata в копии не менялась. Перед build версия явно обновлена и refresh
|
||||||
|
выполнен пользователю видимым отдельным шагом.
|
||||||
|
- Compose нового релиза валиден с абсолютными root/release bindings;
|
||||||
|
image literal digest и defaults взяты из lock/platform config.
|
||||||
|
- Checksums всех шести release files успешны.
|
||||||
|
- Manifest содержит Minecraft/loader, provenance инструментов, исходные
|
||||||
|
hashes и управляемые пути клиента/сервера. Mouse Tweaks отсутствует в
|
||||||
|
server ownership; server marker явно overrides pack marker.
|
||||||
|
- 12 автоматических тестов успешны. Используют настоящий Git и Compose,
|
||||||
|
контролируемый packwiz substitute для отказов и ZIP fixtures. Проверяют
|
||||||
|
чистый/dirty build, configurable output/defaults, checksums, отсутствие
|
||||||
|
local secrets/target, повторную сборку, stale source, export failure,
|
||||||
|
неподтверждённый loader, дубликаты, invalid YAML/paths, tracked secrets,
|
||||||
|
symlinks и tampered tool hash.
|
||||||
|
- Bash/Node syntax, `git diff --check` успешны.
|
||||||
|
- Активный NeoForge-сервер после build остаётся healthy на 0.0.1;
|
||||||
|
контейнер не пересоздавался, мир не менялся командой build.
|
||||||
|
|
||||||
|
Контракт и воспроизведение — [build.md](build.md). Full release Compose
|
||||||
|
проверен через `config`, запуск нового релиза в Phase 2 не выполнялся.
|
||||||
|
Deployment/cleanup/rollback по ownership manifest относятся к следующим
|
||||||
|
фазам. Build лишь фиксирует контракт.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Phase 3: mod wrappers
|
||||||
|
|
||||||
|
Phase 3 завершена: `mcpack add`, `remove`, `update <mod>`, `update --all`
|
||||||
|
делегируют операции закреплённому packwiz. Config/путь source/бинарник
|
||||||
|
проверяются до вызова; оригинальные prompts, stdout/stderr и exit code
|
||||||
|
packwiz сохраняются. Add поддерживает штатные Modrinth version URLs.
|
||||||
|
|
||||||
|
## Реальная integration-проверка
|
||||||
|
|
||||||
|
`bash scripts/verify-phase3.sh` успешно выполнен в тестовом clone
|
||||||
|
`.tools/phase3-check.m9vdq6/clone`. Лог: `.tools/phase3-integration.log`;
|
||||||
|
diff source: `.tools/phase3-check.m9vdq6/source-changes.diff`.
|
||||||
|
|
||||||
|
1. Remove Backpacks удалил `.pw.toml` и обновил index.
|
||||||
|
2. Add version URL создал metadata для 3.26.6.2174 / `pJxNzk4X`;
|
||||||
|
уже установленный Core сохранился.
|
||||||
|
3. Update одного мода заменил Backpacks на 3.26.7.2182 / `Igmp9PwW`.
|
||||||
|
4. Для update --all снова установлена старая версия. Команда реально
|
||||||
|
обновила её, после чего metadata вернулась к 3.26.7.2182.
|
||||||
|
5. После каждого состояния успешен full build: `phase3-remove`,
|
||||||
|
`phase3-add`, `phase3-update`, `phase3-all`. Для всех прошли Compose
|
||||||
|
config и SHA256SUMS, source metadata осталась консистентной.
|
||||||
|
|
||||||
|
Тестовые versions менялись только в clone, чтобы сохранить неизменяемые
|
||||||
|
готовые релизы. Основной pack остаётся 0.0.4 с прежними тремя модами;
|
||||||
|
активный сервер и мир не изменялись и не перезапускались.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
`npm test`: 16/16 успешны. Помимо 12 build-тестов проверены:
|
||||||
|
|
||||||
|
- точное отображение команд/аргументов packwiz, включая --all и --yes;
|
||||||
|
- отклонение неверных комбинаций до запуска tool;
|
||||||
|
- выбор source directory через config при вызове из другого cwd;
|
||||||
|
- literal search argv без shell execution;
|
||||||
|
- сохранение stdout/stderr и тестового exit code 17;
|
||||||
|
- отказ при изменённом hash packwiz.
|
||||||
|
|
||||||
|
Bash/Node syntax и `git diff --check` успешны. Реальные GUI/server проверки
|
||||||
|
новых модов не входят в Phase 3; wrappers меняют source, не deployment.
|
||||||
|
Пользовательские команды и контракт — [mods.md](mods.md).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Phase 4: server runtime
|
||||||
|
|
||||||
|
Phase 4 завершена для NeoForge 21.1.252 / Minecraft 1.21.1. Full release
|
||||||
|
0.0.4 реально запущен с пустой директорией данных через `scripts/server.sh`.
|
||||||
|
|
||||||
|
## Реальная Docker-проверка
|
||||||
|
|
||||||
|
- Server root: `.tools/phase4-server`, project: `minecraft-lab-phase4`.
|
||||||
|
- Настройки: EULA=TRUE по ранее полученному принятию, UID/GID=1000:1000,
|
||||||
|
memory=2G, IPv4 loopback port=25566.
|
||||||
|
- Первый `up -d --wait --wait-timeout 600` завершился healthy. Лог подтвердил
|
||||||
|
создание нового мира, Minecraft 1.21.1, NeoForge 21.1.252, Backpacks 3.26.7
|
||||||
|
и Core 1.5.5. Mouse Tweaks отсутствовал в серверных JAR.
|
||||||
|
- Docker inspect подтвердил обычные абсолютные bind mounts: host runtime
|
||||||
|
→ `/data` rw, release → `/modpacks` ro, server/config → `/config` ro.
|
||||||
|
- `world/level.dat` принадлежит 1000:1000. Pack загружен из `.mrpack`,
|
||||||
|
server override marker=`server-override-v1`, `online-mode=false` и
|
||||||
|
`enforce-secure-profile=false` подтверждены.
|
||||||
|
- Restart того же контейнера завершился healthy.
|
||||||
|
- После остановки и `create --force-recreate` все 23 файла мира совпали
|
||||||
|
по путям/SHA256 до нового запуска. Неуправляемый config marker сохранился.
|
||||||
|
- Вновь запущенный контейнер healthy; mc-health отвечает Minecraft 1.21.1.
|
||||||
|
- Финальная версия wrapper дополнительно проверена реальным up/exec/down
|
||||||
|
после добавления проверки принадлежности Compose project.
|
||||||
|
- Тестовый контейнер/network удалены через down, runtime и binding сохранены.
|
||||||
|
Основной сервер на 25565 не пересоздавался и остался healthy.
|
||||||
|
|
||||||
|
Свидетельства: `.tools/phase4-evidence/first-start.log`, `mounts.json`,
|
||||||
|
`before-recreate-world.json`, `recreate-verification.txt`,
|
||||||
|
`recreated-start.log`. Test root остаётся пригодным для последующих проверок.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
`npm test`: 25 тестов. Новые runtime tests используют controlled Compose/Docker
|
||||||
|
substitutes и проверяют integrity/coverage, EULA/UID/GID, immutable binding,
|
||||||
|
смену release/project, защиту существующего мира, failed startup/exit code,
|
||||||
|
operation lock, offline/bind contract, paths и конфликт project другого root.
|
||||||
|
Build/mod regression tests также проходят. Реальная Docker-проверка выше
|
||||||
|
подтверждает integration, которую substitutes сами по себе не доказывают.
|
||||||
|
|
||||||
|
Bash/Node syntax и `git diff --check` пройдены. Backup, миграция существующего
|
||||||
|
мира, remote deployment и расширенная диагностика healthcheck не реализуются
|
||||||
|
этим этапом. Контракт и команды — [runtime.md](runtime.md).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Phase 5: backups и восстановление
|
||||||
|
|
||||||
|
Phase 5 завершена для NeoForge 21.1.252 / Minecraft 1.21.1. Реализованы
|
||||||
|
`./mcpack backup`, `./mcpack restore` и обязательный gate `backupBeforeApply`
|
||||||
|
для будущего deployment. Контракт и проверенная инструкция: [backups.md](backups.md).
|
||||||
|
|
||||||
|
## Проверка с реальным Docker
|
||||||
|
|
||||||
|
Проверка 2026-10-03 выполнялась на отдельном root `.tools/phase4-server`,
|
||||||
|
project `minecraft-lab-phase4`, port 25566, full release 0.0.4. Основной
|
||||||
|
Phase 0 сервер на 25565 не останавливался и остался healthy.
|
||||||
|
|
||||||
|
- Запущенный сервер был корректно остановлен; backup всего runtime
|
||||||
|
опубликован после tar/zstd listing и пробной распаковки.
|
||||||
|
- Snapshot содержит runtime archive, metadata, полную копию release 0.0.4
|
||||||
|
и checksums всех файлов. Исключений нет.
|
||||||
|
- После backup сервер автоматически вернулся на исходный release в healthy;
|
||||||
|
`mc-health` подтвердил Minecraft 1.21.1.
|
||||||
|
- Затем намеренно подставлена команда tar с exit 42. Backup вернул non-zero,
|
||||||
|
оставил сервер остановленным, не создал дополнительный окончательный
|
||||||
|
snapshot и удалил временный staging. Metadata операции фиксирует failed.
|
||||||
|
- Старый тестовый контейнер удалён через down. Snapshot восстановлен в новый
|
||||||
|
`.tools/phase5-restored-server` без распаковки поверх существующего root.
|
||||||
|
- До запуска совпали все 416 inventory entries (160 файлов, включая 23 файла
|
||||||
|
world): пути, bytes/SHA256, mode, UID и GID. В наборе есть владельцы
|
||||||
|
1000:1000 и 0:0; обе группы сохранились.
|
||||||
|
- Подтверждены `mod-state/phase5.json` и unmanaged config вне world,
|
||||||
|
server.properties, ops.json, whitelist.json. Локальный host `.env` не
|
||||||
|
копировался: при запуске использован уже настроенный файл прежнего test root.
|
||||||
|
- Восстановленная копия запущена с собственным сохранённым `release/`:
|
||||||
|
NeoForge 21.1.252, Minecraft 1.21.1, статус healthy и успешный `mc-health`.
|
||||||
|
- После проверки восстановленный контейнер/network удалены через down.
|
||||||
|
Оба тестовых runtime и snapshot сохранены для дальнейших проверок.
|
||||||
|
|
||||||
|
Проверенный snapshot:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.tools/phase4-server/backups/20261003T134409150Z-edd387d7-f947-4c3d-8ffc-bc02649cdfdc
|
||||||
|
archive bytes: 162695375
|
||||||
|
archive SHA256: e52c82adb379942ed2b63fb0cd361dd7d2cfd153314b26b026bafd942c87c9ce
|
||||||
|
release manifest SHA256: 5933ed34f35473026a32f0c3eab9a8b86b6fb484461bac2f670e0a74930aaa0c
|
||||||
|
```
|
||||||
|
|
||||||
|
Свидетельства: `.tools/phase5-evidence/backup.log`, `failed-backup.log`,
|
||||||
|
`failure-verification.txt`, `restore-verification.txt`, `resumed-health.txt`,
|
||||||
|
`restored-health.txt`, `restored-start.log`, `primary-server-status.txt`,
|
||||||
|
`integration.log`. Сценарий — `scripts/verify-phase5.sh`; он требует специально
|
||||||
|
подготовленного test root Phase 4 и ещё не существующего restore destination.
|
||||||
|
Его нельзя запускать как универсальную команду на primary сервере.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
`npm test`: 37 tests pass. Backup/restore tests проверяют общий lifecycle lock,
|
||||||
|
stop/archive/verify/resume, stopped/absent state, разные snapshot ID,
|
||||||
|
восстановление bytes/modes/ownership и связанного release, запрет существующей
|
||||||
|
цели, повреждённый archive, неполное checksum coverage, ошибку tar/stop/resume,
|
||||||
|
unsupported links, проверку target до остановки и отказ применения до backup.
|
||||||
|
|
||||||
|
Gate test доказывает, что callback будущего apply не вызывается при ошибке
|
||||||
|
архива и вызывается только после verified snapshot под той же блокировкой.
|
||||||
|
При ошибке callback status учитывает текущее состояние контейнера и не
|
||||||
|
запускает старую сборку автоматически. SSH apply сам пока не реализован.
|
||||||
|
Build, mod commands и runtime regression tests также проходят. Node/Bash
|
||||||
|
syntax и `git diff --check` пройдены.
|
||||||
|
|
||||||
|
## Оставшиеся границы
|
||||||
|
|
||||||
|
Автоматическое удаление snapshot, off-host копии, retention, rollback и
|
||||||
|
переключение active release — последующие фазы. Пока runtime не поддерживает
|
||||||
|
links/special files в backup. Atomic publication защищает от обычной ошибки
|
||||||
|
процесса, но fsync для гарантии при отключении питания не реализован.
|
||||||
|
Primary старого spike wrapper не мигрирован на full-release binding.
|
||||||
|
Следующая Phase 6 — расширенная диагностика healthcheck, crash/restart-loop
|
||||||
|
и timeout с полезными logs.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Phase 6: healthcheck и диагностика startup
|
||||||
|
|
||||||
|
Phase 6 завершена: ожидание готовности проверяет pinned image healthcheck,
|
||||||
|
Minecraft status query и стабильность контейнера; отказ возвращает non-zero
|
||||||
|
и пути к сохранённым logs/report. Контракт — [health.md](health.md).
|
||||||
|
|
||||||
|
## Реальная Docker-проверка 2026-10-03
|
||||||
|
|
||||||
|
Исходный snapshot Phase 5 восстановлен в отдельный `.tools/phase6-server`.
|
||||||
|
Использован сохранённый полный NeoForge release 0.0.4, Minecraft 1.21.1,
|
||||||
|
NeoForge 21.1.252, pinned image, host UID/GID 1000:1000 и port 25566.
|
||||||
|
Project — свободный после Phase 5 `minecraft-lab-phase4`.
|
||||||
|
|
||||||
|
- `up --wait --wait-timeout 2 --stable-seconds 1` на холодном JVM старте
|
||||||
|
вернул timeout/non-zero через 2000 ms ожидания. Container оставлен для
|
||||||
|
диагностики; runtime/binding сохранились.
|
||||||
|
- `health --wait-timeout 120 --stable-seconds 3` затем вернул 0 после
|
||||||
|
Docker healthy и стабильных свежих Minecraft queries. TCP не использован
|
||||||
|
как достаточное свидетельство готовности.
|
||||||
|
- Только тестовый контейнер аварийно завершён `docker kill --signal KILL`.
|
||||||
|
Следующий health вернул crash/non-zero, report сохранил exit code 137
|
||||||
|
и OOMKilled=false; elapsed около 230 ms.
|
||||||
|
- Повторный `up --wait --wait-timeout 120 --stable-seconds 3` поднял тот же
|
||||||
|
NeoForge release и подтвердил готовность. Исходный модпак не заменялся.
|
||||||
|
- Для restart loop создан отдельный `.tools/phase6-loop-server` и намеренно
|
||||||
|
повреждённая Compose fixture: pinned image, entrypoint с exit 42 и
|
||||||
|
restart=on-failure:5. Это тестовая копия полного релиза с отдельной версией
|
||||||
|
`phase6-failure-fixture` в manifest и пересчитанными checksums;
|
||||||
|
опубликованный immutable release 0.0.4 не менялся. Fixture не доказывает
|
||||||
|
загрузку NeoForge: её назначение — реальный Docker restart loop.
|
||||||
|
- Docker inspect подтвердил RestartCount=1 до проверки. Health затем
|
||||||
|
вернул restart-loop/non-zero за около 1476 ms с RestartCount=2 и новым
|
||||||
|
StartedAt, сохранив logs с intentional failure marker.
|
||||||
|
- Для всех ошибок есть JSON report и файл последних logs, binding сохранился,
|
||||||
|
lifecycle lock освободилась, automatic rollback не выполнялся.
|
||||||
|
- Test containers/network удалены через down. Их runtime/release/diagnostics
|
||||||
|
оставлены для просмотра. Primary Phase 0 сервер на 25565 не останавливался,
|
||||||
|
не пересоздавался и остался healthy.
|
||||||
|
|
||||||
|
Сценарий: `scripts/verify-phase6.sh`. Он требует Phase 5 snapshot и новых
|
||||||
|
fixture roots, использует только копии test runtime. Свидетельства:
|
||||||
|
`.tools/phase6-evidence/{integration.log,timeout.txt,healthy.txt,healthy-start.log,
|
||||||
|
crash.txt,recovered.txt,restart-loop.txt,loop-docker-state.txt,assertions.txt,
|
||||||
|
primary-server-status.txt}`. Подробные reports/logs находятся в diagnostics
|
||||||
|
соответствующих test roots.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
`npm test`: 46 тестов. Deterministic health tests проверяют успешные
|
||||||
|
последовательные queries, провал протокола при running/healthy state,
|
||||||
|
starting/unhealthy, сброс окна после transient failure, crash/OOM/paused,
|
||||||
|
RestartCount/StartedAt/container identity, missing container/healthcheck,
|
||||||
|
inspection error, deadlines включая launch и слишком поздний query, строгие
|
||||||
|
флаги и диагностику Compose failure с сохранением binding/lock cleanup.
|
||||||
|
|
||||||
|
Regression tests Phase 2–5 проходят; backup resume теперь действительно
|
||||||
|
ожидает стабильную готовность. Bash/Node syntax и `git diff --check` пройдены.
|
||||||
|
|
||||||
|
SSH transport, switching active-release, automatic recovery/rollback и
|
||||||
|
continuous watchdog не добавляются в эту фазу. Следующая Phase 7 — SSH deployment
|
||||||
|
с уже готовыми backup gate и healthcheck.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Phase 7: SSH deployment
|
||||||
|
|
||||||
|
Phase 7 завершена для подтверждённой NeoForge 1.21.1 / 21.1.252 комбинации.
|
||||||
|
Добавлены `mcpack deploy`, SSH worker и host deployment state. Подробный
|
||||||
|
контракт и подготовка/recovery — [deploy.md](deploy.md).
|
||||||
|
|
||||||
|
## Реальный host пользователя
|
||||||
|
|
||||||
|
2026-10-03 выполнен deployment на `crab@192.168.1.61:2228` (minipc):
|
||||||
|
|
||||||
|
- Отдельный root: `/home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike`.
|
||||||
|
Базовый каталог для других сборок не используется как runtime.
|
||||||
|
- Подтверждены Docker 27.4.1, Node 26.10.0 через nvm, rsync, GNU tar и zstd.
|
||||||
|
Новые системные зависимости на remote host не устанавливались.
|
||||||
|
- Создан host `.env` с ранее принятой EULA, UID/GID 1000:1000, memory 2G,
|
||||||
|
port 25565, project minecraft-lab-neoforge-spike. Mode env=600, root=700.
|
||||||
|
- SSH key host закреплён в локальном known_hosts. Password authentication
|
||||||
|
использован для ControlMaster; пароль нигде не записан. Worker/rsync
|
||||||
|
используют BatchMode и StrictHostKeyChecking=yes.
|
||||||
|
- Clean release **0.0.4** построен в отдельном Git checkout, поскольку root
|
||||||
|
development release 0.0.4 имеет dirty=true и намеренно не допускается deploy.
|
||||||
|
Source repository пользователя не коммитился и immutable старый dist не менялся.
|
||||||
|
- Release manifest SHA256:
|
||||||
|
`4b9a060ecb74329c0c10ca8c24954f87f4b137dfb5240f844bb93208780a694f`.
|
||||||
|
- Первый deploy создал runtime без backup (first deploy), загрузил pinned
|
||||||
|
image/dependencies, запустил NeoForge и подтвердил status query + 10 s stability.
|
||||||
|
- Повторный deploy завершился healthy noop. Deployment state complete,
|
||||||
|
applied=false, noop=true; container StartedAt остаётся временем первого
|
||||||
|
запуска `2026-10-03T15:40:01.458777215Z`.
|
||||||
|
- Remote status query возвращает Minecraft 1.21.1. `online-mode=false` и
|
||||||
|
`enforce-secure-profile=false`; world принадлежит 1000:1000, Backpacks/Core
|
||||||
|
присутствуют, client-only Mouse Tweaks в server JAR нет.
|
||||||
|
- Сервер оставлен запущенным. Game port остаётся на remote IPv4 loopback;
|
||||||
|
инструкция SSH tunnel для клиента находится в deploy.md.
|
||||||
|
- Локальный primary Phase 0 сервер на 25565 не останавливался и остался healthy.
|
||||||
|
|
||||||
|
Свидетельства: `.tools/phase7-target-evidence/deploy-first.log`,
|
||||||
|
`deploy-noop.log`, `verification.txt`, `primary-server-status.txt`.
|
||||||
|
SSH config — `.tools/phase7-ssh-target/config`, только параметры соединения.
|
||||||
|
|
||||||
|
## Реальный изолированный SSH/rsync стенд
|
||||||
|
|
||||||
|
`scripts/verify-phase7.sh` создал loopback SSH server на 22227 с временными
|
||||||
|
ключами и новым Docker project minecraft-lab-phase7, port 25566. Полный
|
||||||
|
source snapshot помещён в отдельный Git clone, dependencies скопированы как
|
||||||
|
обычный ignored каталог (symlink node_modules делал worktree dirty).
|
||||||
|
|
||||||
|
Проверены реальные операции:
|
||||||
|
|
||||||
|
1. Clean build `0.0.4`, первая передача rsync и запуск healthy.
|
||||||
|
2. Повторная передача команды deploy той же версии: ID и StartedAt контейнера
|
||||||
|
совпали до/после, release не передавался повторно и restart не было.
|
||||||
|
3. Общий host lock заблокировал параллельный deploy до остановки сервера.
|
||||||
|
4. Clean build `0.0.5` с новым server override marker; update создал проверенный
|
||||||
|
backup исходного runtime/release, запустил target и обновил active/previous.
|
||||||
|
5. World/level.dat и persistent mod-state вне world сохранились; новый marker
|
||||||
|
применился после backup, unknown файлы не удалялись.
|
||||||
|
6. Ручное изменение managed config затем вызвало conflict/non-zero до stop/apply.
|
||||||
|
Изменённый файл сохранён, state failed/applied=false.
|
||||||
|
7. Test container/network и временный SSH daemon остановлены; snapshots,
|
||||||
|
оба releases/runtime/state/evidence сохранены.
|
||||||
|
|
||||||
|
Стенд: `.tools/phase7-check.6dbBak`, свидетельства в его `evidence/`:
|
||||||
|
`build-first.log`, `build-second.log`, `deploy-first.log`, `deploy-noop.log`,
|
||||||
|
`noop-container.txt`, `locked.log`, `deploy-second.log`, `update-assertions.txt`,
|
||||||
|
`manual-conflict.log`, `conflict-assertions.txt`, `primary-server-status.txt`.
|
||||||
|
|
||||||
|
## Автоматические проверки
|
||||||
|
|
||||||
|
`npm test`: 57 tests pass. Deployment tests используют controlled Compose/Docker
|
||||||
|
и проверяют first deploy, no-op, immutable version collision, backup-gated
|
||||||
|
update/world/mod-state, manual conflicts/diff, точный перенос ручной правки
|
||||||
|
в clean target, failure передачи/backup/startup, unresolved state, async lock
|
||||||
|
во время upload, dirty provenance, unsafe SSH arguments и CLI option scope.
|
||||||
|
|
||||||
|
При failure передачи source не останавливается. При failure backup target
|
||||||
|
не запускается. Failed startup оставляет target binding, source pointers,
|
||||||
|
verified snapshot и stopped state; старый pack не запускается автоматически.
|
||||||
|
Это unit fault injection, а не имитация сбоев на пользовательском live host.
|
||||||
|
Health failures с живым Docker проверены отдельно в Phase 6; tar failure и
|
||||||
|
restore — в Phase 5. Обычный SSH update выше подтверждает интеграцию gate.
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
|
||||||
|
Первый реальный remote deploy и no-op проверены на host пользователя;
|
||||||
|
обновление и ручной conflict — на loopback стенде. Forge остаётся непроверенным.
|
||||||
|
Аккаунты отключены; LAN game port не открывался, используется tunnel.
|
||||||
|
Новый unknown существующий мир не принимается автоматически.
|
||||||
|
|
||||||
|
Failed/incomplete state блокирует следующий deploy до ручного recovery.
|
||||||
|
Указатели каждый atomic, общая пара не transaction; state содержит этап
|
||||||
|
committing и прежние значения. fsync, retention, off-host backup и автоматический
|
||||||
|
recovery не реализованы. Remote agents автоматически не удаляются.
|
||||||
|
Следующая Phase 8 — rollback с восстановлением snapshot и исходного release.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Phase 8 — завершена
|
||||||
|
|
||||||
|
Проверено 4 октября 2026 (Asia/Novosibirsk): rollback по SSH без повторного build.
|
||||||
|
Оба режима автоматизированы; команды и условия описаны в [rollback.md](rollback.md).
|
||||||
|
|
||||||
|
На изолированном SSH/Docker-стенде восстановлен snapshot Phase 7, применён
|
||||||
|
чистый release 0.0.5, затем выполнен pack-only rollback на retained 0.0.4.
|
||||||
|
Маркеры мира и состояния модов сохранили текущий прогресс. Следующий snapshot
|
||||||
|
rollback восстановил исходные маркеры, убрал появившийся позже файл из активного
|
||||||
|
runtime и сохранил его вместе с текущим прогрессом в retired runtime.
|
||||||
|
|
||||||
|
Для обеих операций проверены backup текущего состояния, успешный health,
|
||||||
|
сохранение `.env` и публикации `0.0.0.0:25566`, правильные pointers и освобождение
|
||||||
|
общей блокировки. Просмотр планов сохранил список файлов root, ID контейнера и
|
||||||
|
время его запуска. Финальный Minecraft status query прошёл. Контейнер стенда удалён.
|
||||||
|
|
||||||
|
68 unit tests прошли: CLI подтверждения, readonly plan, corrupt snapshot до stop,
|
||||||
|
ошибка current backup, startup failure без автоматического возврата, snapshot
|
||||||
|
recovery после failed deploy по фактическому binding, lock и безопасные ID.
|
||||||
|
Старый immutable Compose проверен настоящим pinned Compose без зависимости от dist.
|
||||||
|
Обнаруженные права `700` у опубликованного release исправлены на `755`, чтобы
|
||||||
|
контейнер UID 1000 мог читать bind-mounted `.mrpack`; добавлена регрессионная проверка.
|
||||||
|
|
||||||
|
Доказательства: `.tools/phase8-check.Hgc1oL/evidence/`,
|
||||||
|
`.tools/phase8-unit-tests.log`, `.tools/phase8-integration.log`.
|
||||||
|
Рабочий host не откатывался: прямой query `192.168.1.61:25565` прошёл;
|
||||||
|
результат `.tools/phase8-target-evidence/direct-status.json`.
|
||||||
|
|
||||||
|
Ограничения: world compatibility подтверждает оператор; аварийное завершение
|
||||||
|
между обновлениями двух pointer files требует сверки state/binding. Незавершённый
|
||||||
|
rollback требует документированного ручного recovery. Retired runtime и backups
|
||||||
|
сохраняются без автоматической очистки. Forge остаётся непроверенной веткой.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Phase 9 — завершена
|
||||||
|
|
||||||
|
Проверено 4 октября 2026: `mcpack release VERSION` реализует workflow из §22 плана.
|
||||||
|
Требует clean Git worktree, проверяет SemVer без build metadata, отсутствие
|
||||||
|
release/tag и Git identity; обновляет только версию pack, выполняет candidate build,
|
||||||
|
коммитит версию, собирает полный release из чистого commit, проверяет SHA256SUMS
|
||||||
|
и provenance, создаёт annotated tag. Push, публикация и deploy не выполняются.
|
||||||
|
|
||||||
|
76 тестов прошли. Новые проверки покрывают успешный выпуск, грязное дерево,
|
||||||
|
занятые release/tag, некорректные аргументы/версии, lock, candidate export failure,
|
||||||
|
final build failure, отклонённый Git commit hook и сохранение concurrent source edit.
|
||||||
|
Общий build fixture выделен без изменения прежних проверок build.
|
||||||
|
|
||||||
|
`scripts/verify-phase9.sh` проверил настоящий pinned packwiz/Compose в отдельном
|
||||||
|
Git clone: обе сборки 0.0.6, чистый финальный manifest, полные checksums, отдельный
|
||||||
|
version commit только для `pack.toml`, annotated `v0.0.6` на manifest commit,
|
||||||
|
отказ повторного выпуска и удаление временного output/lock. HEAD и status
|
||||||
|
основного workspace до/после совпали.
|
||||||
|
|
||||||
|
Артефакт:
|
||||||
|
`.tools/phase9-check.FX8NZe/clone/dist/minecraft-lab-neoforge-spike-0.0.6/`.
|
||||||
|
Доказательства: `.tools/phase9-check.FX8NZe/evidence/`,
|
||||||
|
`.tools/phase9-unit-tests.log`, `.tools/phase9-integration.log`.
|
||||||
|
|
||||||
|
Основной workspace по-прежнему содержит uncommitted исходники; его выпуск
|
||||||
|
корректно заблокирован до обычного коммита проекта. Версия исходного pack остаётся
|
||||||
|
0.0.5, основной Git не получил release commit/tag, рабочий сервер не изменён.
|
||||||
|
Recovery и ограничения описаны в [releases.md](releases.md). Синхронизация
|
||||||
|
удалённых tags остаётся обязанностью оператора; автоматического push нет.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Выпуск релиза — Phase 9
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack release 0.0.6
|
||||||
|
# Дополнительная конфигурация:
|
||||||
|
./mcpack release 0.0.6 --config platform/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда требует чистый Git worktree, включая untracked files, активную ветку,
|
||||||
|
полный локальный clone и настроенные Git author/committer. Подготовьте исходники
|
||||||
|
обычным Git-коммитом заранее. Команда не коммитит остальные изменения проекта.
|
||||||
|
Версия — `MAJOR.MINOR.PATCH`, допускается prerelease (`0.1.0-rc.1`). Build metadata
|
||||||
|
с `+` пока не поддерживается форматом имён deployment. Версия должна отличаться
|
||||||
|
от текущей и не быть занята каталогом релиза или локальным tag `vVERSION`.
|
||||||
|
Удалённые tags автоматически не загружаются: синхронизация refs остаётся задачей оператора.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. Проверяет Git, конфигурацию, версию и наличие release/tag; берёт локальный lock.
|
||||||
|
2. Меняет только top-level `version` в `pack.toml`, сохраняя остальной текст.
|
||||||
|
3. Собирает пробный артефакт в отдельном временном output с pinned packwiz/Compose.
|
||||||
|
4. При успехе коммитит только `pack.toml` с сообщением `Release VERSION`.
|
||||||
|
5. Собирает финальный полный release из чистого коммита, проверяет checksums и provenance.
|
||||||
|
6. Создаёт annotated tag `vVERSION` на тот же commit и выводит каталог/commit/tag.
|
||||||
|
|
||||||
|
Пробный output удаляется. Ошибка до коммита восстанавливает точный исходный
|
||||||
|
`pack.toml`; параллельные изменения сохраняются для разбора. Если финальная сборка
|
||||||
|
или создание tag завершается ошибкой, version commit остаётся, tag не создаётся
|
||||||
|
этой операцией. Финальный артефакт, если уже создан, остаётся для проверки.
|
||||||
|
Автоматического Git reset коммита, перезаписи существующего release/tag, push,
|
||||||
|
публикации или deployment нет.
|
||||||
|
|
||||||
|
После прерывания изучите HEAD, status, refs, `dist/` и `.tools/release.lock`.
|
||||||
|
Убедитесь, что процесс закончился, прежде чем убрать stale lock. После ошибки
|
||||||
|
финальной сборки исправьте причину и осознанно завершите выпуск из чистого коммита:
|
||||||
|
`./mcpack build --release`, затем проверьте manifest/SHA256SUMS и создайте annotated
|
||||||
|
`vVERSION` на manifest commit. Если артефакт уже существует, проверьте его целиком,
|
||||||
|
не запускайте повторный build поверх него. Tag создавайте только после проверки.
|
||||||
|
|
||||||
|
В текущем workspace пока незакоммиченные исходники: `release` корректно откажет.
|
||||||
|
Phase 9 проверена на отдельном source snapshot, с настоящими packwiz/Compose,
|
||||||
|
version commit и tag; основной Git, версия pack и рабочий сервер не менялись.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Rollback — Phase 8
|
||||||
|
|
||||||
|
Команды работают по SSH с root из `platform/config.yaml`. При необходимости
|
||||||
|
передайте `--ssh-config FILE` и `--remote-node /absolute/path/to/node`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack rollback
|
||||||
|
./mcpack rollback-pack 0.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
Без подтверждения это только чтение: active/previous, actual binding, состояния
|
||||||
|
операций и список snapshots с временем и исходным релизом. Нет загрузки agent,
|
||||||
|
блокировки, остановки сервера или сборки. Список не заменяет проверку snapshot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mcpack rollback-pack 0.0.4 --confirm-compatible
|
||||||
|
./mcpack rollback --backup SNAPSHOT_ID --accept-progress-loss
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый режим запускает сохранённый полный релиз с текущим миром. Оператор
|
||||||
|
подтверждает совместимость модов и мира; SemVer её не доказывает. Ручные изменения
|
||||||
|
managed configs блокируют перезапись, если target не содержит ровно эти изменения.
|
||||||
|
|
||||||
|
Второй режим восстанавливает весь runtime и исходный релиз snapshot. Прогресс
|
||||||
|
после указанного времени исчезает из активного runtime, включая данные модов.
|
||||||
|
Архив, inventory и release проверяются до остановки; распаковка происходит в
|
||||||
|
отдельном каталоге. Текущий runtime переносится в `retired-runtimes/UUID/runtime`,
|
||||||
|
а не удаляется. Дополнительно перед обоими режимами создаётся проверенный backup
|
||||||
|
текущего состояния при остановленном сервере под общей блокировкой.
|
||||||
|
|
||||||
|
`.env` и секреты сохраняются. Явный `MC_BIND_ADDRESS` действует и для старого
|
||||||
|
Compose через временный port overlay; immutable release не переписывается.
|
||||||
|
Pointers обновляются только после успешного startup/health. Состояние записывается
|
||||||
|
в `rollback-state.json`, предыдущий deployment — в `operation-history/`.
|
||||||
|
|
||||||
|
После неудачного deploy snapshot recovery определяет применённый релиз по binding,
|
||||||
|
даже если active ещё указывает на предыдущий. Pack-only в таком состоянии запрещён.
|
||||||
|
При ошибке rollback сервер останавливается; автоматического запуска старой сборки
|
||||||
|
на изменённом мире нет. Незавершённый rollback блокирует следующие операции.
|
||||||
|
|
||||||
|
Для ручного восстановления после ошибки сначала изучите state, binding,
|
||||||
|
diagnostics и оба pointers. Сохраните current backup, retired runtime и staged
|
||||||
|
restore. Восстановите проверенный snapshot в отдельный root по [backups.md](backups.md),
|
||||||
|
с host settings этого сервера; удалите остановленный старый контейнер и проверьте
|
||||||
|
source release и health восстановленной копии. Завершать failed state можно лишь
|
||||||
|
после сверки фактического binding и pointers; удаление state само по себе не recovery.
|
||||||
|
Backups и retired runtime автоматически не очищаются.
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# Server runtime — Phase 4
|
||||||
|
|
||||||
|
Полный релиз содержит готовый Docker Compose. `scripts/server.sh` управляет
|
||||||
|
им локально, проверяя artifact checksums, manifest, pinned Compose и
|
||||||
|
подтверждённую Minecraft/loader/image/Java комбинацию. Wrapper требует
|
||||||
|
Node/npm dependencies, Docker daemon и локальный Compose из bootstrap.
|
||||||
|
Сам payload можно запускать обычным Docker Compose на подготовленном host.
|
||||||
|
|
||||||
|
## Первоначальная настройка
|
||||||
|
|
||||||
|
Выбрать отдельный server root и готовый full release. Пример для второго
|
||||||
|
локального сервера, пока основной работает на 25565:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .tools/local-server
|
||||||
|
cp server/.env.example .tools/local-server/.env
|
||||||
|
chmod 600 .tools/local-server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
В локальном `.env` явно задать:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
MC_EULA=TRUE
|
||||||
|
MC_MEMORY=2G
|
||||||
|
MC_PORT=25566
|
||||||
|
MC_BIND_ADDRESS=127.0.0.1
|
||||||
|
MC_UID=1000
|
||||||
|
MC_GID=1000
|
||||||
|
MC_PROJECT_NAME=minecraft-lab-local
|
||||||
|
```
|
||||||
|
|
||||||
|
`MC_EULA=TRUE` означает ваше принятие https://www.minecraft.net/en-us/eula.
|
||||||
|
Для текущего эксперимента оно уже получено. На новом host EULA принять
|
||||||
|
самостоятельно. UID/GID выбрать для владельца runtime на host; image
|
||||||
|
инициализирует права данных с этими значениями. Оператор должен иметь
|
||||||
|
доступ к Docker, server root и локальному `.env`.
|
||||||
|
|
||||||
|
Файл использует простые `KEY=value` без кавычек/interpolation. Пока разрешены
|
||||||
|
только перечисленные ключи. Его значения имеют приоритет над случайными
|
||||||
|
ambient MC_EULA/MEMORY/PORT/UID/GID из shell. Project можно выбрать также
|
||||||
|
через `MC_PROJECT_NAME` в shell; он должен оставаться стабильным для этого root.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MC_ROOT="$PWD/.tools/local-server"
|
||||||
|
export MC_RELEASE_DIR="$PWD/dist/minecraft-lab-neoforge-spike-0.0.4"
|
||||||
|
scripts/server.sh config --quiet
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 600
|
||||||
|
scripts/server.sh ps
|
||||||
|
scripts/server.sh logs --tail 100
|
||||||
|
scripts/server.sh exec -T minecraft mc-health
|
||||||
|
```
|
||||||
|
|
||||||
|
Пути должны быть абсолютными; cwd не влияет на mounts. `MC_ENV_FILE` может
|
||||||
|
выбрать другой абсолютный файл настроек вне immutable release; по умолчанию
|
||||||
|
используется `MC_ROOT/.env`. Для второго runtime нужен отдельный стабильный
|
||||||
|
project и свободный порт. Wrapper отклоняет project, чьи существующие
|
||||||
|
контейнеры подключают другой `/data`.
|
||||||
|
|
||||||
|
## Lifecycle и данные
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/server.sh restart --timeout 120
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
scripts/server.sh stop
|
||||||
|
scripts/server.sh create --force-recreate
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
scripts/server.sh down
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart/create/stop/down относятся к тому же выбранному релизу. `down`
|
||||||
|
удаляет контейнер и network, но сохраняет host runtime. Данные не находятся
|
||||||
|
в named volume: `MC_ROOT/runtime` монтируется в `/data` read-write.
|
||||||
|
Release и его `server/config` монтируются read-only. Онлайн-проверка аккаунтов
|
||||||
|
выключена. `MC_BIND_ADDRESS` в host `.env` выбирает IPv4 интерфейс:
|
||||||
|
по умолчанию 127.0.0.1; для remote LAN задать 0.0.0.0.
|
||||||
|
|
||||||
|
Первая инициализация разрешена только с пустым runtime (или `.gitkeep`).
|
||||||
|
В root создаётся `.mcpack-runtime.json`: project и SHA256 manifest выбранного
|
||||||
|
релиза. Binding записывается перед запуском и сохраняется даже при его
|
||||||
|
неудаче, чтобы повторный up продолжал ту же попытку. Это не active-release
|
||||||
|
и не отметка успешного deployment.
|
||||||
|
|
||||||
|
Смена релиза/project на связанном runtime и неявное принятие существующего
|
||||||
|
мира без binding запрещены. Backup/restore реализованы в Phase 5;
|
||||||
|
смена релиза требует будущего deploy workflow. Текущий старый Phase 0 мир управляется `scripts/spike-server.sh`;
|
||||||
|
миграция на новый wrapper в Phase 4 не выполняется.
|
||||||
|
|
||||||
|
Lifecycle-операции и весь цикл backup используют общую `.mcpack-runtime.lock`
|
||||||
|
в server root. Snapshot/recovery procedure — [backups.md](backups.md). После
|
||||||
|
принудительного убийства процесса lock может остаться; удалять только когда
|
||||||
|
соответствующий процесс точно завершён. `config` проверяет контракт без
|
||||||
|
запуска Docker и без создания runtime/binding. `up --wait` теперь проверяет
|
||||||
|
Minecraft status query и окно стабильной работы, включая restart loops.
|
||||||
|
Отдельная проверка — `scripts/server.sh health`; timeout/report/logs описаны
|
||||||
|
в [health.md](health.md).
|
||||||
|
|
||||||
|
Обычный Compose также работает с release payload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MC_ROOT=/absolute/server-root MC_RELEASE_DIR=/absolute/full-release \
|
||||||
|
docker compose --project-name minecraft-lab-local \
|
||||||
|
--env-file /absolute/server-root/.env \
|
||||||
|
-f /absolute/full-release/server/compose.yaml up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
При прямом вызове Compose проверки/binding/lock wrapper не выполняются;
|
||||||
|
для локального lifecycle используйте `scripts/server.sh`.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Phase 0: NeoForge, Minecraft 1.21.1
|
||||||
|
|
||||||
|
Целевая сборка работает без онлайн-проверки аккаунта. Пользователь выбрал
|
||||||
|
NeoForge на Minecraft 1.21.1; EULA принята для локального эксперимента.
|
||||||
|
Phase 0 завершена для NeoForge 21.1.252 на Minecraft 1.21.1. Импорт и вход
|
||||||
|
клиента пользователь подтвердил; оставшиеся серверные сценарии проверены
|
||||||
|
на том же loader. Это не подтверждает поддержку Forge.
|
||||||
|
|
||||||
|
## Закреплённая сборка
|
||||||
|
|
||||||
|
- Minecraft 1.21.1, NeoForge 21.1.252.
|
||||||
|
- Sophisticated Backpacks 3.26.7.2182 и Sophisticated Core 1.5.5.2363: обе стороны.
|
||||||
|
- Mouse Tweaks 2.26.1: только клиент.
|
||||||
|
- Source: `pack/`; версии и download hashes находятся в packwiz metadata.
|
||||||
|
- Инструменты и digest Docker image закреплены в `toolchain.lock.env`.
|
||||||
|
- Артефакт: `dist/minecraft-lab-neoforge-spike-0.0.1/minecraft-lab-neoforge-spike-0.0.1.mrpack`.
|
||||||
|
- SHA256: `67d6802ae30a1d42413dc44d76f3cc7f0a77885dbbea5329ab9c2e288bda2d4e`.
|
||||||
|
|
||||||
|
## Подтверждённые результаты
|
||||||
|
|
||||||
|
`scripts/spike-build.sh` экспортировал `.mrpack`. В `modrinth.index.json`
|
||||||
|
указаны зависимости Minecraft 1.21.1 и NeoForge 21.1.252; для Mouse Tweaks
|
||||||
|
записано `server: unsupported`.
|
||||||
|
|
||||||
|
`scripts/spike-server.sh up -d --wait --wait-timeout 600` успешно запустил
|
||||||
|
сервер. Первый запуск закончился таймаутом загрузки Maven-зависимостей;
|
||||||
|
повторный запуск того же артефакта завершился успешно. Это внешняя ошибка
|
||||||
|
загрузки, воспроизводимость установки без сети пока не подтверждена.
|
||||||
|
|
||||||
|
Сервер стал healthy; `mc-health` ответил с версией Minecraft 1.21.1.
|
||||||
|
Лог подтвердил NeoForge 21.1.252 и загрузку Backpacks/Core. В `runtime/mods/`
|
||||||
|
ровно два соответствующих JAR: клиентский Mouse Tweaks исключён.
|
||||||
|
|
||||||
|
В `runtime/server.properties` подтверждены `online-mode=false` и
|
||||||
|
`enforce-secure-profile=false`. Порт привязан к `127.0.0.1:25565`.
|
||||||
|
Windows TCP-проверка `127.0.0.1:25565` успешна. При первой попытке клиент
|
||||||
|
разрешил `localhost` в IPv6 `::1` и получил Connection refused; для текущей
|
||||||
|
публикации порта подключаться к `127.0.0.1:25565`.
|
||||||
|
После смены адреса пользователь подтвердил успешный вход в мир. Импорт в
|
||||||
|
Prism, запуск клиента с NeoForge и подключение без проверки аккаунта пройдены.
|
||||||
|
Мир, моды и сгенерированные конфиги доступны как обычные файлы в bind-mounted
|
||||||
|
`runtime/`.
|
||||||
|
|
||||||
|
Сгенерированные серверные конфиги NeoForge и Backpacks находятся в
|
||||||
|
`runtime/config/`, включая `neoforge-server.toml` и
|
||||||
|
`sophisticatedbackpacks-server.toml`. Нельзя предполагать, что серверный
|
||||||
|
конфиг обязательно создаётся в `world/serverconfig/`; политика конфигов
|
||||||
|
должна учитывать выбранную версию loader.
|
||||||
|
|
||||||
|
Локальные свидетельства (не коммитятся):
|
||||||
|
|
||||||
|
- `.tools/neoforge-evidence/mrpack-index.json`;
|
||||||
|
- `.tools/neoforge-evidence/first-start.log`;
|
||||||
|
- `.tools/neoforge-evidence/healthy-start.log`.
|
||||||
|
|
||||||
|
## Оставшиеся серверные сценарии: пройдены
|
||||||
|
|
||||||
|
Перед тестами сервер остановлен. Полный `runtime/` сохранён в
|
||||||
|
`backups/phase0-neoforge-baseline.tar.zst`; архив проверен чтением и SHA256.
|
||||||
|
Backup содержит мир после подтверждённого пользовательского входа.
|
||||||
|
Часть файлов мира принадлежит UID контейнера и недоступна в sandbox;
|
||||||
|
backup и чтение этих файлов выполнены с разрешённым повышением доступа.
|
||||||
|
Ошибка чтения файла должна прерывать backup, а не оставлять частичный архив
|
||||||
|
как успешный результат.
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
| --- | --- |
|
||||||
|
| Recreate | `create --force-recreate` не изменил SHA256 `world/level.dat` до запуска; затем сервер healthy. |
|
||||||
|
| Смена версии | Релиз 0.0.2 заменил Backpacks 3.26.7.2182 на 3.26.6.2174; старый JAR отсутствовал, новая версия загружена. Это проверка замены версии, включая downgrade. |
|
||||||
|
| Удаление модов | Релиз 0.0.3 без модов запустился healthy; `runtime/mods/` пуст. |
|
||||||
|
| Pack config | Ручной `manual-runtime-edit` в управляемом `config/spike-marker.txt` заменён на `neoforge-pack-v2` при смене релиза. |
|
||||||
|
| Server override | С дополнительным Compose marker стал `server-override-v1`, несмотря на pack default и ручную правку. |
|
||||||
|
| Неуправляемые файлы | `config/unmanaged-probe.txt` сохранил `preserve-me` после смены релиза и удаления модов. |
|
||||||
|
| Defaultconfigs | Шаблон `removeErroringEntities=true` не заменил существующий `config/neoforge-server.toml` со значением false. После переноса текущего конфига вне runtime и запуска 0.0.2 на том же мире новый конфиг получил true. |
|
||||||
|
| Datapack | Добавленный в 0.0.2 `world/datapacks/phase0` выполнил load-функцию на существующем мире: в логе есть `[Phase0] datapack-v2-loaded`. В 0.0.3 управляемые файлы datapack удалены. |
|
||||||
|
| Startup failure | Отсутствующий `missing-neoforge-phase0.mrpack` привёл к exit 1; SHA256 остановленного `level.dat` до/после совпал. |
|
||||||
|
| Restore | Backup извлечён в отдельную директорию. Все 28 файлов мира совпали по путям и SHA256 с архивом до запуска; восстановленный `level.dat` совпал с исходным. Возвращён релиз 0.0.1, сервер healthy. |
|
||||||
|
|
||||||
|
Тестовые варианты воспроизводятся `bash scripts/spike-neoforge-variants.sh`
|
||||||
|
из корня на чистом checkout после bootstrap. Скрипт не меняет canonical
|
||||||
|
`pack/`, не запускает сервер и отказывается перезаписывать готовые релизы.
|
||||||
|
|
||||||
|
Артефакты проверки:
|
||||||
|
|
||||||
|
- 0.0.2 SHA256: `4f26f91f7b6e2db80caf522d370db37607ab46de5951e9cd8178f709b9e12ffd`.
|
||||||
|
- 0.0.3 SHA256: `ba0e2476ac7f095f13f5feed73584f38042f93457563dd00920c0211422494a2`.
|
||||||
|
- `.tools/neoforge-evidence/v2-start.log`, `v2-override-start.log`, `v3-start.log`.
|
||||||
|
- `.tools/neoforge-evidence/config-results.txt`, `controlled-failure.log`.
|
||||||
|
- `.tools/neoforge-evidence/baseline-level.sha256`, `before-failure-level.sha256`.
|
||||||
|
- `.tools/neoforge-evidence/restored-world-manifest.json`, `restore-verification.txt`, `restored-start.log`.
|
||||||
|
|
||||||
|
После тестов сохранённое исходное состояние вновь находится в `runtime/`,
|
||||||
|
промежуточное — в `.tools/neoforge-runtime-after-tests`. Активен 0.0.1 с
|
||||||
|
Backpacks 3.26.7.2182 и Core 1.5.5.2363, marker `neoforge-pack-v1`,
|
||||||
|
`online-mode=false`, `enforce-secure-profile=false`. Подключение:
|
||||||
|
`127.0.0.1:25565`; повторный импорт instance не требуется.
|
||||||
|
|
||||||
|
## Выводы для следующих фаз
|
||||||
|
|
||||||
|
Управляемые pack-конфиги заменяются при смене релиза, поэтому ручные правки
|
||||||
|
не являются устойчивым источником настроек. Серверные overrides должны быть
|
||||||
|
явными. Defaultconfigs в проверенной версии служат seed, а не механизмом
|
||||||
|
обновления существующего конфига. Runtime-generated конфиги сохраняются,
|
||||||
|
пока явно не включены в управляемые пути.
|
||||||
|
|
||||||
|
Политики replace/seed-only/preserve и ownership manifest пока не реализованы
|
||||||
|
в CLI: spike подтвердил поведение закреплённых компонентов. Контролируемая
|
||||||
|
ошибка проверена до старта игры; сохранность мира при произвольном сбое
|
||||||
|
мода или миграции формата мира этим тестом не подтверждается.
|
||||||
|
|
||||||
|
## Предварительный Fabric-эксперимент
|
||||||
|
|
||||||
|
Результаты предыдущего эксперимента сохранены в `fabric-spike-results.md`,
|
||||||
|
source — в `examples/fabric-spike/`. Они не заменяют проверки NeoForge/Forge.
|
||||||
|
Перед сменой loader Fabric-сервер остановлен, создан и проверен архив
|
||||||
|
`backups/phase0-fabric-before-neoforge.tar.zst`; старый runtime сохранён в
|
||||||
|
`.tools/fabric-runtime`. NeoForge запущен с новым миром.
|
||||||
|
Forge требует отдельного integration spike.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pack-default-v1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
hash-format = "sha256"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "config/spike-marker.txt"
|
||||||
|
hash = "39fea4a10184116744521fd9111dada3590293955c7a80dc87932c78ac98b5e0"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/fabric-api.pw.toml"
|
||||||
|
hash = "9645e76b2915aa43b0918e1ef20e1c01b64ba9073e35c8bcd70cf34af8ab604e"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/modmenu.pw.toml"
|
||||||
|
hash = "62f164a7b7c8731436389dc1855862d1eb1fa73fd76407b0fb8d129b7b6f00a9"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/placeholder-api.pw.toml"
|
||||||
|
hash = "2b8ac8de79c5561e98265a274dd19081f0de540b1cb5cd3c2151f31a9402ee13"
|
||||||
|
metafile = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Fabric API"
|
||||||
|
filename = "fabric-api-0.116.17+1.21.1.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/Mys3P7lK/fabric-api-0.116.17%2B1.21.1.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "98c478217da19181f0e4df3ea6c2da6bdbda271c7544a4aea048513a49b444f1ff5b71dc0df6303017c48e8ff69b7bb6af6179bdf0d0e09ab3c1ee08411857a8"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "P7dR8mSH"
|
||||||
|
version = "Mys3P7lK"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Mod Menu"
|
||||||
|
filename = "modmenu-11.0.5.jar"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/mOgUt4GM/versions/6lgOkclV/modmenu-11.0.5.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "92e8e96267ff18d320903090f2ad9b32408a218919f5a0d578e232ad774c89d49a34a8271806652be7d6283f24e98432bc4af7f95855e78b843f9100d68696c7"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "mOgUt4GM"
|
||||||
|
version = "6lgOkclV"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Text Placeholder API"
|
||||||
|
filename = "placeholder-api-2.4.2+1.21.jar"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/eXts2L7r/versions/U5bhVym2/placeholder-api-2.4.2%2B1.21.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "fc13d3a5c048dbaab86318edaf8b6c6b46ef9f1d367e8f063d19f5a9b0da66c5ae419d92c8c4608edc89a01eb44d91ffcf017fea73f39b222cbd85e82f70a233"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "eXts2L7r"
|
||||||
|
version = "U5bhVym2"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "minecraft-lab-spike"
|
||||||
|
author = "minecraft-lab"
|
||||||
|
version = "0.0.1"
|
||||||
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
|
[index]
|
||||||
|
file = "index.toml"
|
||||||
|
hash-format = "sha256"
|
||||||
|
hash = "2eb5847935978aa737b6ad733db45cc76953e7f8fc40152c27b14cf7e449ae83"
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
fabric = "0.19.5"
|
||||||
|
minecraft = "1.21.1"
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Reproduce the two disposable source variants used in Phase 0.
|
||||||
|
# Exports artifacts only; never changes runtime or starts Docker.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
task_dir="$task_root/.tools/repro-fabric-variants"
|
||||||
|
mkdir "$task_dir"
|
||||||
|
cp -a "$task_root/examples/fabric-spike/pack" "$task_dir/update-pack"
|
||||||
|
cp -a "$task_root/examples/fabric-spike/pack" "$task_dir/loader-only-pack"
|
||||||
|
task_packwiz=("$task_root/.tools/packwiz/packwiz" --cache "$task_root/.tools/packwiz/cache" --config "$task_root/.tools/packwiz/config.toml")
|
||||||
|
cd "$task_dir/update-pack"
|
||||||
|
sed -i 's/^version = "0.0.1"$/version = "0.0.2"/' pack.toml
|
||||||
|
printf 'pack-default-v2\n' > config/spike-marker.txt
|
||||||
|
mkdir -p world/datapacks
|
||||||
|
cp -a "$task_root/testdata/spike-datapack" world/datapacks/phase0
|
||||||
|
"${task_packwiz[@]}" modrinth add --project-id P7dR8mSH --version-id fpB2VatZ -y
|
||||||
|
"${task_packwiz[@]}" refresh
|
||||||
|
"$task_root/scripts/spike-build.sh" "$PWD"
|
||||||
|
cd "$task_dir/loader-only-pack"
|
||||||
|
sed -i 's/^version = "0.0.1"$/version = "0.0.3"/' pack.toml
|
||||||
|
printf 'loader-only-v3\n' > config/spike-marker.txt
|
||||||
|
"${task_packwiz[@]}" remove modmenu -y
|
||||||
|
"${task_packwiz[@]}" remove placeholder-api -y
|
||||||
|
"${task_packwiz[@]}" remove fabric-api -y
|
||||||
|
"${task_packwiz[@]}" refresh
|
||||||
|
"$task_root/scripts/spike-build.sh" "$PWD"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
exec node "$task_root/scripts/mcpack.mjs" "$@"
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
Платформа должна быть оптимизирована под:
|
Платформа должна быть оптимизирована под:
|
||||||
|
|
||||||
- Prism Launcher как клиентский лаунчер.
|
- пользовательские модпаки на NeoForge и Forge;
|
||||||
|
- запуск без обязательного Microsoft/Mojang-аккаунта и без онлайн-проверки сессии;
|
||||||
|
- Prism Launcher как первоначальный клиентский лаунчер.
|
||||||
- Docker Compose как серверный runtime.
|
- Docker Compose как серверный runtime.
|
||||||
- удалённый доступ к серверу по SSH.
|
- удалённый доступ к серверу по SSH.
|
||||||
- Git как источник истины для описания сборки и конфигурации.
|
- Git как источник истины для описания сборки и конфигурации.
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
|
|
||||||
Не разрабатывать собственный resolver зависимостей Minecraft-модов без реальной необходимости.
|
Не разрабатывать собственный resolver зависимостей Minecraft-модов без реальной необходимости.
|
||||||
|
|
||||||
|
Авторизация аккаунтов не является задачей платформы. Целевой сервер использует online-mode=false и enforce-secure-profile=false; для Prism допускается offline-аккаунт/Launch Offline. NeoForge и Forge — целевые loader; проверка Fabric не заменяет их integration spike.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 2. Основная архитектура
|
# 2. Основная архитектура
|
||||||
@@ -447,6 +451,8 @@ services:
|
|||||||
EULA: "${MC_EULA:?Set MC_EULA after accepting the Minecraft EULA}"
|
EULA: "${MC_EULA:?Set MC_EULA after accepting the Minecraft EULA}"
|
||||||
MEMORY: "${MC_MEMORY:-8G}"
|
MEMORY: "${MC_MEMORY:-8G}"
|
||||||
TYPE: MODRINTH
|
TYPE: MODRINTH
|
||||||
|
ONLINE_MODE: "false"
|
||||||
|
ENFORCE_SECURE_PROFILE: "false"
|
||||||
MODRINTH_MODPACK: /modpack/friday-minecraft-0.4.0.mrpack
|
MODRINTH_MODPACK: /modpack/friday-minecraft-0.4.0.mrpack
|
||||||
volumes:
|
volumes:
|
||||||
- ${MC_ROOT:?Set absolute server root}/runtime:/data
|
- ${MC_ROOT:?Set absolute server root}/runtime:/data
|
||||||
@@ -548,6 +554,8 @@ runtime/
|
|||||||
|
|
||||||
Manifest содержит управляемые пути. Удалять только файлы прежнего релиза; неизвестные файлы не удалять молча. Для конфигов, изменяемых самим runtime, определить в spike политику replace, seed-only или preserve. Проверить defaultconfigs и datapacks на существующем мире.
|
Manifest содержит управляемые пути. Удалять только файлы прежнего релиза; неизвестные файлы не удалять молча. Для конфигов, изменяемых самим runtime, определить в spike политику replace, seed-only или preserve. Проверить defaultconfigs и datapacks на существующем мире.
|
||||||
|
|
||||||
|
Не определять тип или владельца конфига только по директории: в проверяемом NeoForge 1.21.1 серверные конфиги создаются в `runtime/config/`. Путь, поддержку `defaultconfigs/` и per-world overrides проверять для закреплённого loader; результаты одной ветки не переносить автоматически на Forge.
|
||||||
|
|
||||||
Не коммитить runtime-generated файлы автоматически.
|
Не коммитить runtime-generated файлы автоматически.
|
||||||
|
|
||||||
В будущем добавить явную команду для переноса полезных runtime-изменений обратно в source.
|
В будущем добавить явную команду для переноса полезных runtime-изменений обратно в source.
|
||||||
@@ -949,7 +957,8 @@ mcpack doctor --remote
|
|||||||
Задачи:
|
Задачи:
|
||||||
|
|
||||||
- создать минимальный packwiz pack;
|
- создать минимальный packwiz pack;
|
||||||
- добавить один Modrinth-мод;
|
- выбрать и закрепить NeoForge или Forge и совместимую версию Minecraft;
|
||||||
|
- добавить минимальный совместимый набор Modrinth-модов, включая gameplay-мод и client-only мод;
|
||||||
- экспортировать `.mrpack`;
|
- экспортировать `.mrpack`;
|
||||||
- импортировать `.mrpack` в Prism;
|
- импортировать `.mrpack` в Prism;
|
||||||
- запустить совместимый modded server через `itzg/minecraft-server`;
|
- запустить совместимый modded server через `itzg/minecraft-server`;
|
||||||
@@ -969,19 +978,21 @@ mcpack doctor --remote
|
|||||||
docs/spike-results.md
|
docs/spike-results.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Не писать основной CLI до подтверждения integration path. Минимальные скрипты, Compose и каркас для spike допустимы. Prism import и подключение требуют реальной GUI-сессии и учётной записи игрока: при их отсутствии отметить проверки pending, а не passed.
|
Не писать основной CLI до подтверждения integration path. Минимальные скрипты, Compose и каркас для spike допустимы. Prism import и подключение требуют реальной GUI-сессии; проверять offline-аккаунт/Launch Offline с сервером online-mode=false. При отсутствии GUI отметить проверки pending, а не passed.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- Prism импортирует сгенерированный pack;
|
- Prism импортирует сгенерированный pack;
|
||||||
- сервер успешно запускается;
|
- сервер успешно запускается;
|
||||||
- modded client подключается к серверу;
|
- modded client с целевым loader подключается к серверу без онлайн-проверки аккаунта;
|
||||||
- runtime-файлы существуют как обычные host-файлы.
|
- runtime-файлы существуют как обычные host-файлы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1 — Оформление каркаса после spike
|
## Phase 1 — Оформление каркаса после spike
|
||||||
|
|
||||||
|
Статус: завершена для NeoForge 1.21.1. Каркас и проверка чистого локального clone описаны в `docs/phase1-results.md`. Platform manifest подключается к командам в Phase 2.
|
||||||
|
|
||||||
Создать:
|
Создать:
|
||||||
|
|
||||||
- структуру репозитория;
|
- структуру репозитория;
|
||||||
@@ -1006,6 +1017,8 @@ docker compose config
|
|||||||
|
|
||||||
## Phase 2 — Build command
|
## Phase 2 — Build command
|
||||||
|
|
||||||
|
Статус: завершена. `./mcpack build` читает platform config и создаёт полный development release 0.0.4; `--release` требует чистого Git worktree. Проверки и текущие ограничения описаны в `docs/phase2-results.md`, контракт — в `docs/build.md`.
|
||||||
|
|
||||||
Реализовать:
|
Реализовать:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1043,6 +1056,8 @@ dist/<name>-<version>/manifest.json
|
|||||||
|
|
||||||
## Phase 3 — Управление модами
|
## Phase 3 — Управление модами
|
||||||
|
|
||||||
|
Статус: завершена. Mod wrappers сохраняют вывод/prompts/exit code packwiz. Реальные add/remove/update/update --all и четыре full build проверены в тестовом clone; результаты — `docs/phase3-results.md`, контракт — `docs/mods.md`.
|
||||||
|
|
||||||
Предоставить тонкие wrappers:
|
Предоставить тонкие wrappers:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1064,6 +1079,8 @@ Acceptance criteria:
|
|||||||
|
|
||||||
## Phase 4 — Server runtime
|
## Phase 4 — Server runtime
|
||||||
|
|
||||||
|
Статус: завершена. Full release 0.0.4 проверен на пустом runtime через `scripts/server.sh`: pinned Minecraft/NeoForge/image, host bind mounts, restart/recreate без потери мира. Основной Phase 0 сервер не менялся. Результаты — `docs/phase4-results.md`, контракт — `docs/runtime.md`.
|
||||||
|
|
||||||
Создать Docker runtime, который:
|
Создать Docker runtime, который:
|
||||||
|
|
||||||
- использует pinned Minecraft version;
|
- использует pinned Minecraft version;
|
||||||
@@ -1084,6 +1101,8 @@ docker compose up -d
|
|||||||
|
|
||||||
## Phase 5 — Backups и восстановление
|
## Phase 5 — Backups и восстановление
|
||||||
|
|
||||||
|
Статус: завершена. `./mcpack backup` сохраняет весь остановленный runtime и исходный полный релиз под общей lifecycle lock; `restore` проверяет snapshot и восстанавливает только в новый root. Ошибка backup блокирует apply callback. Реальный backup/ошибка tar/запуск восстановленного NeoForge проверены на отдельном сервере; результаты — `docs/phase5-results.md`, инструкция — `docs/backups.md`.
|
||||||
|
|
||||||
До remote deployment реализовать раздел 16: блокировку, остановку, backup и проверенную процедуру восстановления.
|
До remote deployment реализовать раздел 16: блокировку, остановку, backup и проверенную процедуру восстановления.
|
||||||
|
|
||||||
Acceptance criteria: ошибка backup блокирует применение; snapshot восстанавливается в отдельный каталог; данные модов вне world сохраняются.
|
Acceptance criteria: ошибка backup блокирует применение; snapshot восстанавливается в отдельный каталог; данные модов вне world сохраняются.
|
||||||
@@ -1092,12 +1111,16 @@ Acceptance criteria: ошибка backup блокирует применение
|
|||||||
|
|
||||||
## Phase 6 — Healthcheck
|
## Phase 6 — Healthcheck
|
||||||
|
|
||||||
|
Статус: завершена. `scripts/server.sh up/start/restart --wait` и `health` проверяют штатный mc-health, свежий Minecraft status query и окно стабильности. Реальные timeout, crash с exit 137 и restart loop проверены на отдельных test roots; ошибки возвращают non-zero с report/logs и recovery state. Результаты — `docs/phase6-results.md`, контракт — `docs/health.md`.
|
||||||
|
|
||||||
Реализовать раздел 18. Успешный старт возвращает 0; crash, restart loop и timeout — non-zero с указанием logs.
|
Реализовать раздел 18. Успешный старт возвращает 0; crash, restart loop и timeout — non-zero с указанием logs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7 — SSH Deployment
|
## Phase 7 — SSH Deployment
|
||||||
|
|
||||||
|
Статус: завершена для проверенного NeoForge 1.21.1. `mcpack deploy` использует SSH/rsync, общую host lock, immutable checksums, проверку ручных конфликтов, backup-before-apply и healthcheck перед active/previous pointers. Полный update flow проверен на локальном SSH-стенде; clean release 0.0.4 реально установлен и healthy на crab@192.168.1.61:2228 в отдельном root. Повторный deploy — no-op. Результаты — `docs/phase7-results.md`, контракт — `docs/deploy.md`.
|
||||||
|
|
||||||
Реализовать полный flow раздела 12 с готовыми backup и healthcheck.
|
Реализовать полный flow раздела 12 с готовыми backup и healthcheck.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
@@ -1114,7 +1137,8 @@ Acceptance criteria:
|
|||||||
|
|
||||||
## Phase 8 — Rollback
|
## Phase 8 — Rollback
|
||||||
|
|
||||||
Реализовать rollback предыдущего release.
|
Статус: завершена. Реализованы pack-only и snapshot rollback по SSH без build.
|
||||||
|
Результаты и ограничения: [docs/phase8-results.md](docs/phase8-results.md).
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
@@ -1124,13 +1148,21 @@ mcpack rollback
|
|||||||
|
|
||||||
без указания режима ничего не меняет. Явный rollback использует сохранённый полный релиз без повторного build; pack-only требует подтверждения совместимости.
|
без указания режима ничего не меняет. Явный rollback использует сохранённый полный релиз без повторного build; pack-only требует подтверждения совместимости.
|
||||||
|
|
||||||
Восстановление persistent snapshot может оставаться ручным, но процедура обязательна и проверяется на практике.
|
Восстановление persistent snapshot автоматизировано и проверено на изолированном
|
||||||
|
SSH/Docker-стенде. Оба режима требуют явного подтверждения и проверенного backup
|
||||||
|
текущего состояния; pointers меняются после health. Ручной recovery после ошибки
|
||||||
|
описан в [docs/rollback.md](docs/rollback.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9 — Release workflow
|
## Phase 9 — Release workflow
|
||||||
|
|
||||||
Реализовать:
|
Статус: завершена. Candidate build, version commit, чистый финальный build,
|
||||||
|
checksums и annotated tag проверены с настоящим packwiz в отдельном Git clone.
|
||||||
|
Результаты: [docs/phase9-results.md](docs/phase9-results.md).
|
||||||
|
Workflow и recovery: [docs/releases.md](docs/releases.md).
|
||||||
|
|
||||||
|
Реализовано:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mcpack release <version>
|
mcpack release <version>
|
||||||
@@ -1579,7 +1611,7 @@ mcpack logs
|
|||||||
|
|
||||||
- client artifact импортируется в Prism;
|
- client artifact импортируется в Prism;
|
||||||
- сервер запускается из того же определения сборки;
|
- сервер запускается из того же определения сборки;
|
||||||
- клиент может подключиться;
|
- клиент на NeoForge/Forge может подключиться без Microsoft/Mojang-аккаунта;
|
||||||
- моды версионируются через packwiz;
|
- моды версионируются через packwiz;
|
||||||
- server files хранятся как обычные директории на host;
|
- server files хранятся как обычные директории на host;
|
||||||
- world переживает recreation контейнера;
|
- world переживает recreation контейнера;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
neoforge-pack-v1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
hash-format = "sha256"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "config/spike-marker.txt"
|
||||||
|
hash = "db43653501b8d024d61686a59700621f857ae0a6eef414448dd0ab4a06dd9428"
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/mouse-tweaks.pw.toml"
|
||||||
|
hash = "6bc414cc0f2d4c078fe83f384d786343f701f371d657c5279b5b0df1595804bd"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/sophisticated-backpacks.pw.toml"
|
||||||
|
hash = "c0f73c72c3903c3302a2589ba75fd4e1f4c1b3e2b1fe478340b2c826b55e5cd4"
|
||||||
|
metafile = true
|
||||||
|
|
||||||
|
[[files]]
|
||||||
|
file = "mods/sophisticated-core.pw.toml"
|
||||||
|
hash = "e824f362b3537caa9dc64e54021f80b0f9f7a8eac36603fd8a3e53f3c7264670"
|
||||||
|
metafile = true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Mouse Tweaks"
|
||||||
|
filename = "MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
||||||
|
side = "client"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/aC3cM3Vq/versions/9I21YYxf/MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "f95c0cc881211ee0442434976775e07aae40b4a2ba4e3a85455bc57ca9d3c991e239cd98374a8abd442dc9c378dcd9001e2caa1bf69991d424b5652dd7f3483b"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "aC3cM3Vq"
|
||||||
|
version = "9I21YYxf"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Sophisticated Backpacks"
|
||||||
|
filename = "sophisticatedbackpacks-1.21.1-3.26.7.2182.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/TyCTlI4b/versions/Igmp9PwW/sophisticatedbackpacks-1.21.1-3.26.7.2182.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "e720916477f8faaea3ac45609f557e5997db3ea8e83d10b06d8ffd3b0a0e1348dc17b00f773c1c7d0495f795b12d002f17e99d0bf67008c1efe6c77c28d5a015"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "TyCTlI4b"
|
||||||
|
version = "Igmp9PwW"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "Sophisticated Core"
|
||||||
|
filename = "sophisticatedcore-1.21.1-1.5.5.2363.jar"
|
||||||
|
side = "both"
|
||||||
|
|
||||||
|
[download]
|
||||||
|
url = "https://cdn.modrinth.com/data/nmoqTijg/versions/nSoNwJfm/sophisticatedcore-1.21.1-1.5.5.2363.jar"
|
||||||
|
hash-format = "sha512"
|
||||||
|
hash = "8bc1f6f05c0ca0722b58210283fb3deb31155d3185936053e92c90f41bc61220e49d23bf83791f09121afcbe30ba469c5a04134b1b8595591e33a2c1cfc16768"
|
||||||
|
|
||||||
|
[update]
|
||||||
|
[update.modrinth]
|
||||||
|
mod-id = "nmoqTijg"
|
||||||
|
version = "nSoNwJfm"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "minecraft-lab-neoforge-spike"
|
||||||
|
author = "minecraft-lab"
|
||||||
|
version = "0.0.5"
|
||||||
|
pack-format = "packwiz:1.1.0"
|
||||||
|
|
||||||
|
[index]
|
||||||
|
file = "index.toml"
|
||||||
|
hash-format = "sha256"
|
||||||
|
hash = "2782087550a243c663e3fed2c6267af78930fe3810e9dd1b844be6d14a680bd4"
|
||||||
|
|
||||||
|
[versions]
|
||||||
|
minecraft = "1.21.1"
|
||||||
|
neoforge = "21.1.252"
|
||||||
Generated
+46
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "minecraft-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "minecraft-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"smol-toml": "1.9.0",
|
||||||
|
"yaml": "2.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/smol-toml": {
|
||||||
|
"version": "1.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
|
||||||
|
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cyyynthia"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "minecraft-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": { "node": ">=22" },
|
||||||
|
"scripts": { "test": "node --test tests/*.test.mjs" },
|
||||||
|
"dependencies": { "smol-toml": "1.9.0", "yaml": "2.9.1" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Platform manifest
|
||||||
|
|
||||||
|
`config.yaml` читается `mcpack build`: paths и server defaults влияют на релиз.
|
||||||
|
Имя/версия сборки, Minecraft, loader и моды остаются в TOML packwiz.
|
||||||
|
Схема config — `schemas/config.schema.json`; CLI проверяет её правила и
|
||||||
|
дополнительные ограничения путей. YAML/TOML parses выполняются библиотеками
|
||||||
|
с версиями из `package-lock.json`.
|
||||||
|
|
||||||
|
`server.host` и `server.directory` выбирают SSH host и отдельный root сборки.
|
||||||
|
`deploy.healthcheck_timeout` используется локальным server wrapper при
|
||||||
|
`up/start/restart --wait` и `health`, если timeout не передан явно.
|
||||||
|
Target и transport используются командой deploy и не копируются в release
|
||||||
|
payload. Local build не требует SSH. Контракт — [docs/deploy.md](../docs/deploy.md).
|
||||||
|
|
||||||
|
`compatibility.json` фиксирует подтверждённые сочетания Minecraft/loader/image
|
||||||
|
и Java. Добавлять запись только после реальной integration-проверки.
|
||||||
|
Это запись доказанной совместимости, а `toolchain.lock.env` определяет
|
||||||
|
инструменты текущего build. Изменение image требует новой проверки.
|
||||||
|
|
||||||
|
`build.mjs` — orchestration вокруг packwiz и Compose. Dependency resolver
|
||||||
|
и загрузка mod metadata остаются задачей packwiz.
|
||||||
|
Контракт и manifest описаны в [docs/build.md](../docs/build.md).
|
||||||
|
|
||||||
|
`health.mjs` проверяет стабильную готовность Minecraft и сохраняет диагностику;
|
||||||
|
контракт — [docs/health.md](../docs/health.md).
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { absolute, verifyRelease, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
|
||||||
|
|
||||||
|
const fail = message => { throw new Error(message); };
|
||||||
|
const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep);
|
||||||
|
const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
||||||
|
|
||||||
|
function hashFile(file) {
|
||||||
|
const digest = crypto.createHash('sha256'), buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||||
|
const handle = fs.openSync(file, 'r');
|
||||||
|
try {
|
||||||
|
let count;
|
||||||
|
while ((count = fs.readSync(handle, buffer, 0, buffer.length, null))) digest.update(buffer.subarray(0, count));
|
||||||
|
return digest.digest('hex');
|
||||||
|
} finally { fs.closeSync(handle); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, env = process.env) {
|
||||||
|
try { return execFileSync(command, args, { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); }
|
||||||
|
catch (error) { fail(`${command} failed: ${error.stderr?.toString().trim() || error.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimeInventory(directory) {
|
||||||
|
const entries = [];
|
||||||
|
function walk(file, relative) {
|
||||||
|
if (relative !== '.' && !safe(relative)) fail(`Unsupported runtime path: ${relative}`);
|
||||||
|
const stat = fs.lstatSync(file);
|
||||||
|
if (!stat.isDirectory() && (!stat.isFile() || stat.nlink > 1)) fail(`Backup supports regular files/directories without links: ${relative}`);
|
||||||
|
const record = { path: relative, type: stat.isDirectory() ? 'directory' : 'file', mode: stat.mode & 0o7777, uid: stat.uid, gid: stat.gid };
|
||||||
|
if (stat.isFile()) Object.assign(record, { size: stat.size, sha256: hashFile(file) });
|
||||||
|
entries.push(record);
|
||||||
|
if (stat.isDirectory()) for (const child of fs.readdirSync(file).sort()) walk(path.join(file, child), relative === '.' ? child : `${relative}/${child}`);
|
||||||
|
}
|
||||||
|
walk(directory, '.');
|
||||||
|
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFiles(directory) {
|
||||||
|
return runtimeInventory(directory).filter(entry => entry.type === 'file').map(entry => entry.path).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChecksums(directory) {
|
||||||
|
const lines = payloadFiles(directory).filter(file => file !== 'SHA256SUMS').map(file => `${hashFile(path.join(directory, file))} ${file}\n`);
|
||||||
|
fs.writeFileSync(path.join(directory, 'SHA256SUMS'), lines.join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyChecksums(directory) {
|
||||||
|
const expected = new Map();
|
||||||
|
for (const line of fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8').trim().split('\n')) {
|
||||||
|
const match = /^([a-f0-9]{64}) (.+)$/.exec(line);
|
||||||
|
if (!match || !safe(match[2]) || match[2] === 'SHA256SUMS' || expected.has(match[2])) fail('Invalid snapshot checksum file');
|
||||||
|
expected.set(match[2], match[1]);
|
||||||
|
}
|
||||||
|
const actual = payloadFiles(directory).filter(file => file !== 'SHA256SUMS');
|
||||||
|
assert.deepStrictEqual([...expected.keys()].sort(), actual, 'Snapshot checksum coverage differs');
|
||||||
|
for (const [file, sha256] of expected) if (hashFile(path.join(directory, file)) !== sha256) fail(`Snapshot checksum mismatch: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function archivePaths(archive) {
|
||||||
|
const raw = run('tar', ['-I', 'zstd', '-tf', archive]).trim().split('\n');
|
||||||
|
const paths = raw.map(entry => {
|
||||||
|
if (entry === './' || entry === '.') return '.';
|
||||||
|
const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
|
||||||
|
if (!safe(normalized)) fail('Unsafe path in backup archive');
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
if (new Set(paths).size !== paths.length) fail('Duplicate archive entry');
|
||||||
|
const listing = run('tar', ['-I', 'zstd', '-tvf', archive]);
|
||||||
|
if (listing.trim().split('\n').some(line => !['-', 'd'].includes(line[0]))) fail('Backup archive contains unsupported links/special files');
|
||||||
|
return paths.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract(archive, destination) {
|
||||||
|
if (fs.existsSync(destination)) fail('Extraction destination already exists');
|
||||||
|
fs.mkdirSync(destination);
|
||||||
|
run('tar', ['--same-permissions', '--numeric-owner', '--same-owner', '-I', 'zstd', '-xf', archive, '-C', destination]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySnapshot(directory) {
|
||||||
|
directory = absolute(directory, 'backup directory');
|
||||||
|
verifyChecksums(directory);
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(path.join(directory, 'metadata.json'), 'utf8'));
|
||||||
|
if (metadata.schema_version !== 1 || metadata.archive?.file !== 'runtime.tar.zst' || !Array.isArray(metadata.runtime_inventory) || metadata.exclusions?.length !== 0 || metadata.stopped_confirmed !== true) fail('Unsupported backup metadata');
|
||||||
|
const archive = path.join(directory, metadata.archive.file);
|
||||||
|
if (hashFile(archive) !== metadata.archive.sha256 || fs.statSync(archive).size !== metadata.archive.size) fail('Archive differs from backup metadata');
|
||||||
|
const paths = new Set();
|
||||||
|
for (const entry of metadata.runtime_inventory) {
|
||||||
|
if ((entry.path !== '.' && !safe(entry.path)) || paths.has(entry.path) || !['file', 'directory'].includes(entry.type)) fail('Invalid snapshot inventory');
|
||||||
|
paths.add(entry.path);
|
||||||
|
if (![entry.mode, entry.uid, entry.gid].every(value => Number.isInteger(value) && value >= 0)) fail('Invalid snapshot ownership/mode');
|
||||||
|
if (entry.type === 'file' && (!/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 0)) fail('Invalid snapshot file hash/size');
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(archivePaths(archive), [...paths].sort(), 'Archive paths differ from runtime inventory');
|
||||||
|
const release = verifyRelease(path.join(directory, 'release'));
|
||||||
|
if (release.identity !== metadata.source_release?.manifest_sha256 || hashFile(path.join(directory, 'release/SHA256SUMS')) !== metadata.source_release.checksums_sha256 || metadata.runtime_binding?.release_manifest_sha256 !== release.identity || metadata.runtime_binding.schema_version !== 1 || !/^[a-z0-9][a-z0-9_-]*$/.test(metadata.runtime_binding.project)) fail('Backup is not linked to its retained release');
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containers({ repository, env, lockToken }) {
|
||||||
|
const text = runtime({ repository, env, lockToken, args: ['ps', '--all', '--format', 'json'], capture: true }).trim();
|
||||||
|
const list = !text ? [] : text.startsWith('[') ? JSON.parse(text) : text.split('\n').map(line => JSON.parse(line));
|
||||||
|
if (!Array.isArray(list) || list.some(item => typeof item.ID !== 'string' || !/^[a-f0-9]{12,64}$/.test(item.ID))) fail('Invalid container status');
|
||||||
|
return list.length ? JSON.parse(run('docker', ['inspect', ...list.map(item => item.ID)], env)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(directory, release) {
|
||||||
|
return { name: `${release.manifest.pack.name}-${release.manifest.pack.version}`, manifest_sha256: release.identity, checksums_sha256: hashFile(path.join(directory, 'SHA256SUMS')), pack: release.manifest.pack, image: release.manifest.server.image };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedLifecycle(options, args) {
|
||||||
|
const status = runtime({ ...options, args });
|
||||||
|
if (status !== 0) fail(`Compose ${args[0]} failed with exit ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOperation({ repository, env = process.env, targetRelease, waitTimeout = 120, lockToken: heldToken }, apply) {
|
||||||
|
const root = absolute(env.MC_ROOT, 'MC_ROOT'), data = absolute(path.join(root, 'runtime'), 'runtime');
|
||||||
|
const sourcePath = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR');
|
||||||
|
const sourceRelease = verifyRelease(sourcePath);
|
||||||
|
const target = targetRelease ? summary(absolute(targetRelease, 'target release'), verifyRelease(targetRelease)) : null;
|
||||||
|
if (!Number.isInteger(waitTimeout) || waitTimeout < 1) fail('Invalid backup wait timeout');
|
||||||
|
const backups = absolute(path.join(root, 'backups'), 'backups');
|
||||||
|
if (contained(data, backups) || contained(sourcePath, backups)) fail('Backup directory overlaps runtime/release');
|
||||||
|
const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding');
|
||||||
|
if (!fs.existsSync(stateFile)) fail('Backup requires an initialized full-release runtime binding');
|
||||||
|
const perform = lockToken => {
|
||||||
|
const lifecycle = { repository, env, lockToken };
|
||||||
|
checkedLifecycle(lifecycle, ['config', '--quiet']);
|
||||||
|
const before = containers(lifecycle);
|
||||||
|
if (before.length > 1 || before.some(container => container.State.Paused || container.State.Restarting || container.State.Dead)) fail('Backup requires one normally running/stopped server (or no container)');
|
||||||
|
const priorState = before.length ? before[0].State.Running ? 'running' : 'stopped' : 'absent';
|
||||||
|
if (!apply && priorState === 'running' && readSettings(absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE')).MC_EULA !== 'TRUE') fail('Restoring running state requires MC_EULA=TRUE in local settings');
|
||||||
|
const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`;
|
||||||
|
const operationFile = path.join(root, '.mcpack-backup-operation.json');
|
||||||
|
const operation = { schema_version: 1, id, phase: 'preflight', prior_state: priorState, source_release: sourceRelease.identity, target_release: target?.manifest_sha256 ?? null, stopped_confirmed: false, snapshot: null };
|
||||||
|
const save = phase => {
|
||||||
|
operation.phase = phase;
|
||||||
|
fs.writeFileSync(operationFile + '.tmp', JSON.stringify(operation, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
fs.renameSync(operationFile + '.tmp', operationFile);
|
||||||
|
};
|
||||||
|
let temporary, published, resuming = false;
|
||||||
|
try {
|
||||||
|
save('stopping');
|
||||||
|
checkedLifecycle(lifecycle, ['stop']);
|
||||||
|
if (containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused)) fail('Server did not stop completely');
|
||||||
|
operation.stopped_confirmed = true;
|
||||||
|
const inventory = runtimeInventory(data);
|
||||||
|
fs.mkdirSync(backups, { recursive: true });
|
||||||
|
temporary = fs.mkdtempSync(path.join(backups, `.backup-${id}-`));
|
||||||
|
save('archiving');
|
||||||
|
const archive = path.join(temporary, 'runtime.tar.zst');
|
||||||
|
run('tar', ['--format=pax', '-C', data, '-I', 'zstd', '-cf', archive, '.'], env);
|
||||||
|
assert.deepStrictEqual(runtimeInventory(data), inventory, 'Runtime changed while archiving');
|
||||||
|
fs.cpSync(sourcePath, path.join(temporary, 'release'), { recursive: true });
|
||||||
|
if (verifyRelease(path.join(temporary, 'release')).identity !== sourceRelease.identity) fail('Retained release changed during copy');
|
||||||
|
const metadata = { schema_version: 1, id, created_at: new Date().toISOString(), source_root: root, prior_state: priorState, stopped_confirmed: true, source_release: summary(sourcePath, sourceRelease), target_release: target, runtime_binding: JSON.parse(fs.readFileSync(stateFile, 'utf8')), exclusions: [], archive: { file: 'runtime.tar.zst', sha256: hashFile(archive), size: fs.statSync(archive).size }, runtime_inventory: inventory };
|
||||||
|
fs.writeFileSync(path.join(temporary, 'metadata.json'), JSON.stringify(metadata, null, 2) + '\n');
|
||||||
|
save('verifying');
|
||||||
|
archivePaths(archive);
|
||||||
|
const verification = path.join(temporary, '.verification');
|
||||||
|
extract(archive, verification);
|
||||||
|
assert.deepStrictEqual(runtimeInventory(verification), inventory, 'Extracted snapshot differs from stopped runtime');
|
||||||
|
fs.rmSync(verification, { recursive: true });
|
||||||
|
writeChecksums(temporary);
|
||||||
|
verifySnapshot(temporary);
|
||||||
|
published = path.join(backups, id);
|
||||||
|
if (fs.existsSync(published)) fail('Snapshot ID already exists');
|
||||||
|
fs.renameSync(temporary, published);
|
||||||
|
temporary = undefined;
|
||||||
|
operation.snapshot = published;
|
||||||
|
save('published');
|
||||||
|
if (apply) {
|
||||||
|
save('applying');
|
||||||
|
const result = apply({ snapshot: published, metadata, lockToken });
|
||||||
|
save('complete');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (priorState === 'running') {
|
||||||
|
resuming = true;
|
||||||
|
save('resuming');
|
||||||
|
checkedLifecycle(lifecycle, ['up', '-d', '--wait', '--wait-timeout', String(waitTimeout)]);
|
||||||
|
operation.stopped_confirmed = false;
|
||||||
|
}
|
||||||
|
save('complete');
|
||||||
|
return published;
|
||||||
|
} catch (error) {
|
||||||
|
if (resuming) {
|
||||||
|
try { checkedLifecycle(lifecycle, ['stop']); operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running); }
|
||||||
|
catch { operation.stopped_confirmed = false; }
|
||||||
|
} else if (apply && published) {
|
||||||
|
// The apply callback may have started a server before failing. Never
|
||||||
|
// report the pre-apply stopped state as the current container state.
|
||||||
|
try { operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused); }
|
||||||
|
catch { operation.stopped_confirmed = false; }
|
||||||
|
}
|
||||||
|
operation.error = error.message;
|
||||||
|
save('failed');
|
||||||
|
throw new Error(`${error.message}. ${published ? `Verified snapshot retained: ${published}. ` : 'No snapshot published. '}${operation.stopped_confirmed ? 'Runtime remains stopped.' : 'Inspect server state before continuing.'}`);
|
||||||
|
} finally { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||||
|
};
|
||||||
|
return heldToken ? perform(heldToken) : withRuntimeLock(root, perform);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backup = options => snapshotOperation(options);
|
||||||
|
|
||||||
|
// Future deployment can only enter its apply phase under the same lock after
|
||||||
|
// a stopped, verified snapshot. Failure never starts an older pack automatically.
|
||||||
|
export function backupBeforeApply(options, apply) {
|
||||||
|
if (typeof apply !== 'function' || !options.targetRelease) fail('Backup-before-apply requires a verified target release and apply callback');
|
||||||
|
return snapshotOperation(options, apply);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSnapshot({ backupDirectory, destination }) {
|
||||||
|
const snapshot = absolute(backupDirectory, 'backup directory');
|
||||||
|
const metadata = verifySnapshot(snapshot);
|
||||||
|
destination = absolute(destination, 'restore destination');
|
||||||
|
if (fs.existsSync(destination)) fail('Restore destination must not exist; never extract over an existing runtime');
|
||||||
|
if (contained(snapshot, destination) || contained(path.join(metadata.source_root, 'runtime'), destination)) fail('Restore destination overlaps snapshot/source runtime');
|
||||||
|
const parent = path.dirname(destination);
|
||||||
|
fs.mkdirSync(parent, { recursive: true });
|
||||||
|
const lock = path.join(parent, `.restore-${path.basename(destination)}.lock`);
|
||||||
|
try { fs.mkdirSync(lock); } catch { fail('Another restore is running (or a stale restore lock remains)'); }
|
||||||
|
let temporary;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(destination)) fail('Restore destination appeared during verification');
|
||||||
|
temporary = fs.mkdtempSync(path.join(parent, `.restore-${path.basename(destination)}-`));
|
||||||
|
extract(path.join(snapshot, 'runtime.tar.zst'), path.join(temporary, 'runtime'));
|
||||||
|
assert.deepStrictEqual(runtimeInventory(path.join(temporary, 'runtime')), metadata.runtime_inventory, 'Restored runtime differs from snapshot inventory');
|
||||||
|
fs.cpSync(path.join(snapshot, 'release'), path.join(temporary, 'release'), { recursive: true });
|
||||||
|
if (verifyRelease(path.join(temporary, 'release')).identity !== metadata.source_release.manifest_sha256) fail('Restored release identity mismatch');
|
||||||
|
fs.writeFileSync(path.join(temporary, '.mcpack-runtime.json'), JSON.stringify(metadata.runtime_binding, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
fs.writeFileSync(path.join(temporary, 'restore-report.json'), JSON.stringify({ schema_version: 1, backup_id: metadata.id, restored_at: new Date().toISOString(), source_release: metadata.source_release, runtime_entries_verified: metadata.runtime_inventory.length, host_settings_copied: false }, null, 2) + '\n');
|
||||||
|
if (fs.existsSync(destination)) fail('Restore destination appeared during extraction');
|
||||||
|
fs.renameSync(temporary, destination);
|
||||||
|
temporary = undefined;
|
||||||
|
return destination;
|
||||||
|
} finally {
|
||||||
|
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
||||||
|
fs.rmdirSync(lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { parseDocument, stringify as yamlStringify } from 'yaml';
|
||||||
|
import { parse as tomlParse } from 'smol-toml';
|
||||||
|
|
||||||
|
const fail = message => { throw new Error(message); };
|
||||||
|
const hash = (data, algorithm = 'sha256') => crypto.createHash(algorithm).update(data).digest('hex');
|
||||||
|
const fileHash = file => hash(fs.readFileSync(file));
|
||||||
|
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
const requiredString = (value, label) => typeof value === 'string' && value.length > 0 ? value : fail(`${label} must be a nonempty string`);
|
||||||
|
const secretName = name => (/^\.env(?:\..*)?$/.test(name) && name !== '.env.example') || /^(id_rsa|id_ed25519)$/.test(name);
|
||||||
|
|
||||||
|
function run(command, args, { cwd, env = process.env, log = () => {} } = {}) {
|
||||||
|
try {
|
||||||
|
const output = execFileSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
if (output.trim()) log(output.trim());
|
||||||
|
return output;
|
||||||
|
} catch (error) {
|
||||||
|
fail(`${path.basename(command)} failed: ${error.stderr?.toString().trim() || error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function yaml(text, label) {
|
||||||
|
const doc = parseDocument(text, { uniqueKeys: true });
|
||||||
|
if (doc.errors.length || doc.warnings.length) fail(`${label}: ${[...doc.errors, ...doc.warnings].map(e => e.message).join('; ')}`);
|
||||||
|
return doc.toJS({ maxAliasCount: 50 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function keys(value, allowed, label) {
|
||||||
|
if (!plain(value)) fail(`${label} must be a mapping`);
|
||||||
|
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`Unknown ${label} key: ${key}`);
|
||||||
|
for (const key of allowed) if (!(key in value)) fail(`Missing ${label}.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConfig(config) {
|
||||||
|
keys(config, ['schema_version', 'pack', 'build', 'server', 'deploy'], 'config');
|
||||||
|
if (config.schema_version !== 1) fail('Unsupported config schema_version');
|
||||||
|
keys(config.pack, ['directory'], 'pack');
|
||||||
|
keys(config.build, ['output_directory'], 'build');
|
||||||
|
keys(config.server, ['host', 'directory', 'memory', 'port'], 'server');
|
||||||
|
keys(config.deploy, ['transport', 'healthcheck_timeout'], 'deploy');
|
||||||
|
requiredString(config.pack.directory, 'pack.directory');
|
||||||
|
requiredString(config.build.output_directory, 'build.output_directory');
|
||||||
|
if (!/^[1-9]\d*[MG]$/.test(config.server.memory)) fail('server.memory must use M or G, for example 2G');
|
||||||
|
if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) fail('Invalid server.port');
|
||||||
|
for (const key of ['host', 'directory']) if (config.server[key] !== null) requiredString(config.server[key], `server.${key}`);
|
||||||
|
if ((config.server.host === null) !== (config.server.directory === null)) fail('Set both server.host and server.directory, or leave both null');
|
||||||
|
if (config.server.directory !== null && !config.server.directory.startsWith('/')) fail('Remote server.directory must be absolute');
|
||||||
|
if (config.deploy.transport !== 'ssh') fail('Only SSH deploy transport is supported');
|
||||||
|
if (!Number.isInteger(config.deploy.healthcheck_timeout) || config.deploy.healthcheck_timeout < 1) fail('Invalid healthcheck_timeout');
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelative(value) {
|
||||||
|
return typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
function inside(root, value) {
|
||||||
|
const result = path.resolve(root, requiredString(value, 'path'));
|
||||||
|
const relative = path.relative(root, result);
|
||||||
|
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail(`Path must be inside repository: ${value}`);
|
||||||
|
let current = root;
|
||||||
|
for (const part of relative.split(path.sep)) {
|
||||||
|
current = path.join(current, part);
|
||||||
|
try {
|
||||||
|
if (fs.lstatSync(current).isSymbolicLink()) fail(`Symlinks are not allowed: ${value}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function files(directory) {
|
||||||
|
const result = [];
|
||||||
|
function walk(current) {
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const file = path.join(current, entry.name);
|
||||||
|
if (secretName(entry.name)) fail(`Known secret filename is not allowed in source: ${file}`);
|
||||||
|
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail(`Only regular files/directories are allowed: ${file}`);
|
||||||
|
if (entry.isDirectory()) walk(file);
|
||||||
|
else {
|
||||||
|
const relative = path.relative(directory, file).split(path.sep).join('/');
|
||||||
|
if (!safeRelative(relative)) fail(`Invalid file path: ${relative}`);
|
||||||
|
result.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(directory);
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLock(file) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||||
|
const match = /^([A-Z][A-Z0-9_]*)=(\S+)$/.exec(line);
|
||||||
|
if (!match || match[1] in result) fail('Invalid or duplicate toolchain lock entry');
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
for (const key of ['PACKWIZ_BINARY_SHA256', 'COMPOSE_SHA256']) if (!/^[a-f0-9]{64}$/.test(result[key])) fail(`Invalid ${key}`);
|
||||||
|
if (!/^[a-f0-9]{40}$/.test(result.PACKWIZ_COMMIT)) fail('Invalid PACKWIZ_COMMIT');
|
||||||
|
if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(result.MC_IMAGE)) fail('MC_IMAGE must be pinned by digest');
|
||||||
|
requiredString(result.COMPOSE_VERSION, 'COMPOSE_VERSION');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePack(directory, compatibility, image) {
|
||||||
|
const tree = files(directory);
|
||||||
|
const pack = tomlParse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8'));
|
||||||
|
for (const key of ['name', 'version']) if (typeof pack[key] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack[key])) fail(`Invalid pack ${key}`);
|
||||||
|
if (pack['pack-format'] !== 'packwiz:1.1.0') fail('Unsupported pack format');
|
||||||
|
const minecraft = requiredString(pack.versions?.minecraft, 'Minecraft version');
|
||||||
|
const loaders = Object.keys(pack.versions).filter(key => key !== 'minecraft');
|
||||||
|
if (loaders.length !== 1) fail('Specify exactly one loader');
|
||||||
|
const loader = loaders[0], loaderVersion = requiredString(pack.versions[loader], 'loader version');
|
||||||
|
const verified = compatibility.verified?.find(v => v.minecraft === minecraft && v.loader === loader && v.loader_version === loaderVersion && v.image === image);
|
||||||
|
if (!verified || verified.java_major !== 21) fail('Minecraft/loader/image combination has not been verified by the spike');
|
||||||
|
if (!safeRelative(pack.index?.file) || pack.index['hash-format'] !== 'sha256') fail('Pack index must have a safe path and SHA256');
|
||||||
|
const indexFile = path.join(directory, pack.index.file);
|
||||||
|
if (fileHash(indexFile) !== pack.index.hash) fail('Stale pack index hash: run packwiz refresh explicitly');
|
||||||
|
const index = tomlParse(fs.readFileSync(indexFile, 'utf8'));
|
||||||
|
if (index['hash-format'] !== 'sha256' || !Array.isArray(index.files)) fail('Unsupported or missing index files');
|
||||||
|
const targets = new Set(), modIds = new Set(), indexed = new Set();
|
||||||
|
for (const entry of index.files) {
|
||||||
|
if (!safeRelative(entry.file) || indexed.has(entry.file)) fail(`Unsafe or duplicate indexed path: ${entry.file}`);
|
||||||
|
indexed.add(entry.file);
|
||||||
|
const file = path.join(directory, entry.file);
|
||||||
|
if (!tree.includes(entry.file) || fileHash(file) !== entry.hash) fail(`Missing or stale indexed file: ${entry.file}; run packwiz refresh explicitly`);
|
||||||
|
if (entry.metafile) {
|
||||||
|
const mod = tomlParse(fs.readFileSync(file, 'utf8'));
|
||||||
|
if (!safeRelative(mod.filename) || mod.filename.includes('/')) fail(`Invalid mod filename: ${entry.file}`);
|
||||||
|
if (!['client', 'server', 'both'].includes(mod.side)) fail(`Explicit side metadata required: ${entry.file}`);
|
||||||
|
const target = path.posix.join(path.posix.dirname(entry.file), mod.filename);
|
||||||
|
if (targets.has(target)) fail(`Duplicate mod target: ${target}`);
|
||||||
|
targets.add(target);
|
||||||
|
const modId = mod.update?.modrinth?.['mod-id'];
|
||||||
|
if (modId && modIds.has(modId)) fail(`Duplicate Modrinth project: ${modId}`);
|
||||||
|
if (modId) modIds.add(modId);
|
||||||
|
const download = mod.download;
|
||||||
|
let url;
|
||||||
|
try { url = new URL(download?.url); } catch { fail(`Missing download URL: ${entry.file}`); }
|
||||||
|
if (url.protocol !== 'https:' || url.username || url.password) fail(`Download URL must be HTTPS without credentials: ${entry.file}`);
|
||||||
|
const length = { sha512: 128, sha256: 64, sha1: 40, md5: 32 }[download['hash-format']];
|
||||||
|
if (!length || !new RegExp(`^[a-f0-9]{${length}}$`).test(download.hash)) fail(`Invalid download hash: ${entry.file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pack, minecraft, loader, loaderVersion, verified, tree };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedServer(root, release, artifact, config, lock) {
|
||||||
|
const source = path.join(root, 'server');
|
||||||
|
const compose = yaml(fs.readFileSync(path.join(source, 'compose.yaml'), 'utf8'), 'server compose');
|
||||||
|
const service = compose.services?.minecraft;
|
||||||
|
if (!service || Object.keys(compose.services).length !== 1) fail('Expected one minecraft Compose service');
|
||||||
|
if (service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false') fail('Compose must use MODRINTH and offline authentication');
|
||||||
|
service.image = lock.MC_IMAGE;
|
||||||
|
service.ports = [`\${MC_BIND_ADDRESS:-127.0.0.1}:\${MC_PORT:-${config.server.port}}:25565`];
|
||||||
|
service.environment.MEMORY = `\${MC_MEMORY:-${config.server.memory}}`;
|
||||||
|
service.environment.MODRINTH_MODPACK = `/modpacks/${artifact}`;
|
||||||
|
service.environment.SYNC_SKIP_NEWER_IN_DESTINATION = 'false';
|
||||||
|
service.volumes = [
|
||||||
|
{ type: 'bind', source: '${MC_ROOT:?Set absolute server root}/runtime', target: '/data' },
|
||||||
|
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}', target: '/modpacks', read_only: true },
|
||||||
|
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}/server/config', target: '/config', read_only: true }
|
||||||
|
];
|
||||||
|
const destination = path.join(release, 'server');
|
||||||
|
fs.mkdirSync(destination);
|
||||||
|
fs.writeFileSync(path.join(destination, 'compose.yaml'), yamlStringify(compose));
|
||||||
|
// Allowlist local settings; never copy server/.env or deployment target.
|
||||||
|
const example = readLockExample(path.join(source, '.env.example'));
|
||||||
|
example.MC_EULA = 'FALSE';
|
||||||
|
example.MC_MEMORY = config.server.memory;
|
||||||
|
example.MC_PORT = String(config.server.port);
|
||||||
|
fs.writeFileSync(path.join(destination, '.env.example'), '# Accept Minecraft EULA before setting MC_EULA=TRUE.\n' + Object.entries(example).map(([key, value]) => `${key}=${value}\n`).join(''));
|
||||||
|
fs.cpSync(path.join(source, 'config'), path.join(destination, 'config'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destination, 'README.md'), `# Immutable server release\n\nCopy server/.env.example to a local settings file outside this release.\nAccept the Minecraft EULA before setting MC_EULA=TRUE.\n\nSet MC_ROOT to an absolute server root and MC_RELEASE_DIR to this release.\nUse the same Compose project name for all releases:\n\n\`\`\`bash\ndocker compose --project-name minecraft-lab-spike --env-file /absolute/local-settings.env -f "$MC_RELEASE_DIR/server/compose.yaml" config --quiet\n\`\`\`\n\nThis release uses offline authentication. MC_BIND_ADDRESS selects the IPv4 interface; default is 127.0.0.1, use 0.0.0.0 for remote LAN access.\nserver/config overrides corresponding pack settings at startup.\nRuntime-generated server.properties is configured through Compose environment.\n`);
|
||||||
|
return compose;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLockExample(file) {
|
||||||
|
const result = {};
|
||||||
|
const allowed = ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_BIND_ADDRESS'];
|
||||||
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||||
|
const match = /^([A-Z_]+)=([A-Za-z0-9.]+)$/.exec(line);
|
||||||
|
if (!match || !allowed.includes(match[1]) || match[1] in result) fail('Unexpected setting in server/.env.example');
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('Invalid MC_BIND_ADDRESS in env example');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedFiles(extracted, index, release) {
|
||||||
|
const sides = { client: new Map(), server: new Map() };
|
||||||
|
for (const file of index.files ?? []) {
|
||||||
|
if (!safeRelative(file.path) || !plain(file.hashes) || !file.hashes.sha512 || !file.hashes.sha1) fail('Invalid exported mod metadata');
|
||||||
|
for (const side of ['client', 'server']) if (file.env?.[side] !== 'unsupported') {
|
||||||
|
if (sides[side].has(file.path)) fail(`Duplicate exported target: ${file.path}`);
|
||||||
|
sides[side].set(file.path, { path: file.path, owner: 'pack', policy: 'replace', hashes: file.hashes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const prefix of ['overrides', 'client-overrides', 'server-overrides']) {
|
||||||
|
const directory = path.join(extracted, prefix);
|
||||||
|
if (!fs.existsSync(directory)) continue;
|
||||||
|
for (const file of files(directory)) for (const side of ['client', 'server']) {
|
||||||
|
if (prefix !== 'overrides' && prefix !== `${side}-overrides`) continue;
|
||||||
|
sides[side].set(file, { path: file, owner: 'pack', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const directory = path.join(release, 'server/config');
|
||||||
|
for (const file of files(directory)) {
|
||||||
|
const target = `config/${file}`;
|
||||||
|
const previous = sides.server.get(target);
|
||||||
|
sides.server.set(target, { path: target, owner: 'server', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) }, ...(previous ? { overrides: previous.owner } : {}) });
|
||||||
|
}
|
||||||
|
return Object.fromEntries(Object.entries(sides).map(([side, entries]) => [side, [...entries.values()].sort((a, b) => a.path.localeCompare(b.path))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packwizContext({ root, configFile = 'platform/config.yaml' }) {
|
||||||
|
root = fs.realpathSync(root);
|
||||||
|
const config = validateConfig(yaml(fs.readFileSync(inside(root, configFile), 'utf8'), 'platform config'));
|
||||||
|
if (path.isAbsolute(config.pack.directory)) fail('Pack directory must be relative to repository');
|
||||||
|
const source = inside(root, config.pack.directory);
|
||||||
|
inside(root, path.join(config.pack.directory, 'pack.toml'));
|
||||||
|
if (!fs.statSync(path.join(source, 'pack.toml')).isFile()) fail('pack.toml must be a regular file');
|
||||||
|
const lock = readLock(inside(root, 'toolchain.lock.env'));
|
||||||
|
const executable = path.join(root, '.tools/packwiz/packwiz');
|
||||||
|
if (!fs.existsSync(executable)) fail('Tools missing: run scripts/spike-tools.sh');
|
||||||
|
if (fileHash(executable) !== lock.PACKWIZ_BINARY_SHA256) fail('Pinned packwiz checksum mismatch');
|
||||||
|
return { source, executable, args: ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function build({ root, configFile = 'platform/config.yaml', releaseOnly = false, outputDirectory, log = console.log }) {
|
||||||
|
root = fs.realpathSync(root);
|
||||||
|
const configuration = inside(root, configFile);
|
||||||
|
const config = validateConfig(yaml(fs.readFileSync(configuration, 'utf8'), 'platform config'));
|
||||||
|
if (path.isAbsolute(config.pack.directory) || path.isAbsolute(config.build.output_directory)) fail('Pack/output directories must be relative to repository');
|
||||||
|
const source = inside(root, config.pack.directory), output = inside(root, outputDirectory ?? config.build.output_directory);
|
||||||
|
// Build may never write into source, runtime, tooling, or a parent thereof.
|
||||||
|
const protectedPaths = [source, ...['server', 'platform', 'scripts', 'runtime', '.tools', '.git', 'node_modules', 'docs', 'examples', 'testdata'].map(v => path.join(root, v))];
|
||||||
|
for (const protectedPath of protectedPaths) if (output === protectedPath || output.startsWith(protectedPath + path.sep) || protectedPath.startsWith(output + path.sep)) fail('Output overlaps a protected/source directory');
|
||||||
|
for (const value of ['server/compose.yaml', 'server/.env.example', 'server/config', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json']) inside(root, value);
|
||||||
|
const lock = readLock(path.join(root, 'toolchain.lock.env'));
|
||||||
|
const compatibility = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'), 'utf8'));
|
||||||
|
const validated = validatePack(source, compatibility, lock.MC_IMAGE);
|
||||||
|
const { pack, minecraft, loader, loaderVersion, verified } = validated;
|
||||||
|
const toolFiles = { packwiz: path.join(root, '.tools/packwiz/packwiz'), compose: path.join(root, '.tools/docker-compose') };
|
||||||
|
for (const [tool, file] of Object.entries(toolFiles)) {
|
||||||
|
if (!fs.existsSync(file)) fail('Tools missing: run scripts/spike-tools.sh');
|
||||||
|
if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} checksum mismatch`);
|
||||||
|
}
|
||||||
|
const gitCommit = run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim();
|
||||||
|
const tracked = run('git', ['ls-files', '-z'], { cwd: root }).split('\0').filter(Boolean);
|
||||||
|
const outputRelative = path.relative(root, output).split(path.sep).join('/');
|
||||||
|
if (tracked.some(file => file !== `${outputRelative}/.gitkeep` && (file === outputRelative || file.startsWith(outputRelative + '/')))) fail('Output directory contains tracked files');
|
||||||
|
const statusArgs = ['status', '--porcelain', '--untracked-files=all', '--', '.', `:(exclude)${outputRelative}`];
|
||||||
|
const gitStatus = run('git', statusArgs, { cwd: root });
|
||||||
|
const gitDirty = gitStatus.length > 0;
|
||||||
|
if (tracked.some(file => file.split('/').some(secretName))) fail('Known secret path is tracked by Git');
|
||||||
|
if (releaseOnly && gitDirty) fail('--release requires a clean Git worktree');
|
||||||
|
const releaseName = `${pack.name}-${pack.version}`, artifact = `${releaseName}.mrpack`, destination = path.join(output, releaseName);
|
||||||
|
if (fs.existsSync(destination)) fail(`Release already exists: ${destination}; choose a new pack version or output directory`);
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const lockDirectory = path.join(output, `.build-${releaseName}.lock`);
|
||||||
|
try { fs.mkdirSync(lockDirectory); } catch { fail(`Build is already locked: ${lockDirectory}`); }
|
||||||
|
let temporary;
|
||||||
|
try {
|
||||||
|
temporary = fs.mkdtempSync(path.join(output, `.build-${releaseName}-`));
|
||||||
|
const copied = path.join(temporary, 'source'), release = path.join(temporary, 'release');
|
||||||
|
fs.cpSync(source, copied, { recursive: true });
|
||||||
|
fs.mkdirSync(release);
|
||||||
|
const inputFiles = [...validated.tree.map(file => path.join(source, file)), ...['server/compose.yaml', 'server/.env.example', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json', 'package.json', 'platform/build.mjs', 'platform/commands.mjs', 'platform/runtime.mjs', 'platform/backups.mjs', 'platform/health.mjs', 'platform/deployment.mjs', 'platform/ssh-deploy.mjs', 'platform/rollback.mjs', 'scripts/deploy-worker.mjs', 'scripts/mcpack.mjs'].map(file => path.join(root, file)), ...files(path.join(root, 'server/config')).map(file => path.join(root, 'server/config', file)), configuration];
|
||||||
|
const inputs = [...new Set(inputFiles)].sort().map(file => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256: fileHash(file) }));
|
||||||
|
const copiedHashes = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
||||||
|
for (const [file, sha256] of copiedHashes) if (sha256 !== fileHash(path.join(source, file))) fail(`Source changed while copying: ${file}`);
|
||||||
|
const packwizArgs = ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')];
|
||||||
|
run(toolFiles.packwiz, [...packwizArgs, 'refresh'], { cwd: copied, log });
|
||||||
|
const refreshed = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
||||||
|
if (JSON.stringify(copiedHashes) !== JSON.stringify(refreshed)) fail('Stale source: run packwiz refresh explicitly; build never changes source');
|
||||||
|
run(toolFiles.packwiz, [...packwizArgs, 'modrinth', 'export', '--output', path.join(release, artifact)], { cwd: copied, log });
|
||||||
|
const exportedTree = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
||||||
|
if (JSON.stringify(copiedHashes) !== JSON.stringify(exportedTree)) fail('Packwiz export changed source metadata');
|
||||||
|
const archive = path.join(release, artifact), extracted = path.join(temporary, 'extracted');
|
||||||
|
const zipPaths = run('bsdtar', ['-tf', archive]).trim().split('\n').filter(Boolean);
|
||||||
|
for (const file of zipPaths) if (!safeRelative(file.replace(/\/$/, ''))) fail(`Unsafe archive path: ${file}`);
|
||||||
|
fs.mkdirSync(extracted);
|
||||||
|
run('bsdtar', ['--no-same-owner', '-xf', archive, '-C', extracted]);
|
||||||
|
files(extracted);
|
||||||
|
const exported = JSON.parse(fs.readFileSync(path.join(extracted, 'modrinth.index.json'), 'utf8'));
|
||||||
|
if (exported.name !== pack.name || exported.versionId !== pack.version || exported.dependencies?.minecraft !== minecraft || exported.dependencies?.[loader] !== loaderVersion || Object.keys(exported.dependencies).length !== 2) fail('Export metadata differs from source pack');
|
||||||
|
generatedServer(root, release, artifact, config, lock);
|
||||||
|
const composeEnv = { PATH: process.env.PATH, HOME: process.env.HOME, MC_ROOT: root, MC_RELEASE_DIR: release };
|
||||||
|
run(toolFiles.compose, ['--project-name', 'minecraft-lab-spike', '--env-file', path.join(release, 'server/.env.example'), '-f', path.join(release, 'server/compose.yaml'), 'config', '--quiet'], { env: composeEnv });
|
||||||
|
const managed = managedFiles(extracted, exported, release);
|
||||||
|
const manifest = {
|
||||||
|
schema_version: 1,
|
||||||
|
pack: { name: pack.name, version: pack.version, minecraft, loader: { id: loader, version: loaderVersion } },
|
||||||
|
artifact,
|
||||||
|
git: { commit: gitCommit, dirty: gitDirty },
|
||||||
|
tools: { packwiz: { commit: lock.PACKWIZ_COMMIT, sha256: lock.PACKWIZ_BINARY_SHA256 }, compose: { version: lock.COMPOSE_VERSION, sha256: lock.COMPOSE_SHA256 }, node: process.version, npm_lock_sha256: fileHash(path.join(root, 'package-lock.json')) },
|
||||||
|
server: { image: lock.MC_IMAGE, java_major: verified.java_major, memory: config.server.memory, port: config.server.port, compose_project: 'minecraft-lab-spike' },
|
||||||
|
source_files: inputs,
|
||||||
|
managed_files: managed,
|
||||||
|
runtime_policy: { unmanaged: 'preserve', defaultconfigs: 'seed-only' },
|
||||||
|
release_files: files(release).map(file => ({ path: file, sha256: fileHash(path.join(release, file)) }))
|
||||||
|
};
|
||||||
|
fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
const checksums = files(release).map(file => `${fileHash(path.join(release, file))} ${file}\n`).join('');
|
||||||
|
fs.writeFileSync(path.join(release, 'SHA256SUMS'), checksums);
|
||||||
|
run('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release, log });
|
||||||
|
for (const input of inputs) if (fileHash(path.join(root, input.path)) !== input.sha256) fail(`Source changed during build: ${input.path}`);
|
||||||
|
for (const [tool, file] of Object.entries(toolFiles)) if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} changed during build`);
|
||||||
|
if (run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim() !== gitCommit || run('git', statusArgs, { cwd: root }) !== gitStatus) fail('Git state changed during build');
|
||||||
|
if (fs.existsSync(destination)) fail('Release destination appeared during build');
|
||||||
|
fs.renameSync(release, destination);
|
||||||
|
return destination;
|
||||||
|
} finally {
|
||||||
|
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
||||||
|
fs.rmdirSync(lockDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { packwizContext } from './build.mjs';
|
||||||
|
|
||||||
|
export const usage = `Usage:
|
||||||
|
./mcpack build [--config FILE] [--release]
|
||||||
|
./mcpack release VERSION [--config FILE]
|
||||||
|
./mcpack add <modrinth-slug|ID|URL|search> [--yes] [--config FILE]
|
||||||
|
./mcpack remove <mod> [--yes] [--config FILE]
|
||||||
|
./mcpack update <mod> [--yes] [--config FILE]
|
||||||
|
./mcpack update --all [--yes] [--config FILE]
|
||||||
|
./mcpack deploy [--artifact RELEASE_DIR] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
|
||||||
|
./mcpack rollback [--backup ID --accept-progress-loss] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
|
||||||
|
./mcpack rollback-pack VERSION [--confirm-compatible] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
|
||||||
|
./mcpack backup [--target-release DIR] [--wait-timeout SECONDS]
|
||||||
|
./mcpack restore --backup SNAPSHOT_DIR --destination NEW_SERVER_ROOT
|
||||||
|
|
||||||
|
Mod commands edit packwiz source and retain packwiz prompts/output.
|
||||||
|
Development builds may be dirty; --release requires a clean Git worktree.`;
|
||||||
|
|
||||||
|
export function parseArgs(args) {
|
||||||
|
args = [...args];
|
||||||
|
const command = args.shift();
|
||||||
|
if (!['build', 'release', 'add', 'remove', 'update', 'backup', 'restore', 'deploy', 'rollback', 'rollback-pack'].includes(command)) throw new Error(usage);
|
||||||
|
const options = { command, configFile: 'platform/config.yaml', releaseOnly: false, yes: false, all: false };
|
||||||
|
const seen = new Set();
|
||||||
|
while (args.length) {
|
||||||
|
const argument = args.shift();
|
||||||
|
const option = argument === '-y' ? '--yes' : argument;
|
||||||
|
if (['--config', '--release', '--yes', '--all', '--target-release', '--wait-timeout', '--backup', '--destination', '--artifact', '--ssh-config', '--remote-node', '--confirm-compatible', '--accept-progress-loss'].includes(option)) {
|
||||||
|
if (seen.has(option)) throw new Error(`Repeated argument: ${option}`);
|
||||||
|
seen.add(option);
|
||||||
|
if (['--config', '--target-release', '--wait-timeout', '--backup', '--destination', '--artifact', '--ssh-config', '--remote-node'].includes(option)) {
|
||||||
|
const value = args.shift();
|
||||||
|
if (!value || value.startsWith('-') || /[\0\r\n]/.test(value)) throw new Error(`Missing or invalid value: ${option}`);
|
||||||
|
options[{'--config':'configFile','--target-release':'targetRelease','--wait-timeout':'waitTimeout','--backup':'backupDirectory','--destination':'destination','--artifact':'artifact','--ssh-config':'sshConfig','--remote-node':'remoteNode'}[option]] = value;
|
||||||
|
} else options[option === '--release' ? 'releaseOnly' : option.slice(2)] = true;
|
||||||
|
} else {
|
||||||
|
if (argument.startsWith('-') || options.mod || /[\0\r\n]/.test(argument)) throw new Error(`Unexpected argument: ${argument}`);
|
||||||
|
options.mod = argument;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command === 'rollback' || command === 'rollback-pack') {
|
||||||
|
if (options.yes || options.all || options.releaseOnly || options.artifact || options.targetRelease || options.waitTimeout || options.destination) throw new Error('Invalid rollback arguments');
|
||||||
|
if (command === 'rollback-pack') {
|
||||||
|
if (!options.mod || options.backupDirectory || options['accept-progress-loss']) throw new Error('rollback-pack requires a version and optional --confirm-compatible');
|
||||||
|
options.version = options.mod;
|
||||||
|
options.confirmCompatible = Boolean(options['confirm-compatible']);
|
||||||
|
} else {
|
||||||
|
if (options.mod || options['confirm-compatible'] || (options['accept-progress-loss'] && !options.backupDirectory)) throw new Error('rollback accepts only an optional snapshot ID and progress-loss acknowledgment');
|
||||||
|
options.backupId = options.backupDirectory;
|
||||||
|
options.acceptProgressLoss = Boolean(options['accept-progress-loss']);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (options['confirm-compatible'] || options['accept-progress-loss']) throw new Error('Confirmation option is only for rollback');
|
||||||
|
if (command === 'deploy') {
|
||||||
|
if (options.mod || options.yes || options.all || options.releaseOnly || options.targetRelease || options.waitTimeout || options.backupDirectory || options.destination) throw new Error('deploy accepts only --artifact and --config');
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (options.artifact || options.sshConfig || options.remoteNode) throw new Error('--artifact is only valid for deploy');
|
||||||
|
const backupOptions = ['--target-release', '--wait-timeout', '--backup', '--destination'];
|
||||||
|
if (command === 'backup' || command === 'restore') {
|
||||||
|
if (options.mod || options.yes || options.all || options.releaseOnly || seen.has('--config')) throw new Error('Invalid backup/restore arguments');
|
||||||
|
if (command === 'backup') {
|
||||||
|
if (options.backupDirectory || options.destination) throw new Error('backup accepts only --target-release and --wait-timeout');
|
||||||
|
if (options.waitTimeout && (!/^[1-9]\d*$/.test(options.waitTimeout) || !Number.isSafeInteger(Number(options.waitTimeout)))) throw new Error('Invalid --wait-timeout');
|
||||||
|
options.waitTimeout = Number(options.waitTimeout ?? 120);
|
||||||
|
} else if (!options.backupDirectory || !options.destination || options.targetRelease || options.waitTimeout) throw new Error('restore requires --backup and --destination');
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
if (backupOptions.some(option => seen.has(option))) throw new Error('Backup/restore option used with another command');
|
||||||
|
if(command==='release'){
|
||||||
|
if(!options.mod||options.yes||options.all||options.releaseOnly)throw new Error('release requires VERSION and accepts only --config');
|
||||||
|
options.version=options.mod;return options;
|
||||||
|
}
|
||||||
|
if (command === 'build') {
|
||||||
|
if (options.mod || options.yes || options.all) throw new Error('build accepts only --config and --release');
|
||||||
|
} else {
|
||||||
|
if (options.releaseOnly) throw new Error('--release is only valid for build');
|
||||||
|
if (command !== 'update' && options.all) throw new Error('--all is only valid for update');
|
||||||
|
if (options.all && options.mod) throw new Error('Choose update <mod> or update --all');
|
||||||
|
if (!options.all && !options.mod) throw new Error(`${command} requires a mod`);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function packwizArguments(options) {
|
||||||
|
const arguments_ = options.yes ? ['--yes'] : [];
|
||||||
|
if (options.command === 'add') {
|
||||||
|
arguments_.push('modrinth', 'add', options.mod);
|
||||||
|
} else if (options.command === 'remove') arguments_.push('remove', options.mod);
|
||||||
|
else if (options.command === 'update') arguments_.push('update', ...(options.all ? ['--all'] : [options.mod]));
|
||||||
|
else throw new Error('Expected a mod command');
|
||||||
|
return arguments_;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manageMod({ root, ...options }) {
|
||||||
|
const context = packwizContext({ root, configFile: options.configFile });
|
||||||
|
const result = spawnSync(context.executable, [...context.args, ...packwizArguments(options)], { cwd: context.source, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.signal) throw new Error(`packwiz terminated by ${result.signal}`);
|
||||||
|
return result.status ?? 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"verified": [
|
||||||
|
{
|
||||||
|
"minecraft": "1.21.1",
|
||||||
|
"loader": "neoforge",
|
||||||
|
"loader_version": "21.1.252",
|
||||||
|
"java_major": 21,
|
||||||
|
"image": "itzg/minecraft-server@sha256:99dc8b37f97c3b95915452180bbedf29a7ac8252932e372ce6e5b6e383a5d1e2",
|
||||||
|
"evidence": "docs/spike-results.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
schema_version: 1
|
||||||
|
pack:
|
||||||
|
directory: ./pack
|
||||||
|
build:
|
||||||
|
output_directory: ./dist
|
||||||
|
server:
|
||||||
|
host: crab@192.168.1.61
|
||||||
|
directory: /home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike
|
||||||
|
memory: 2G
|
||||||
|
port: 25565
|
||||||
|
deploy:
|
||||||
|
transport: ssh
|
||||||
|
healthcheck_timeout: 120
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { absolute, verifyRelease, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
|
||||||
|
import { backupBeforeApply } from './backups.mjs';
|
||||||
|
|
||||||
|
const fail = message => { throw new Error(message); };
|
||||||
|
export const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
const safe = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) && !['.','..'].includes(value);
|
||||||
|
export function releaseName(manifest) {
|
||||||
|
if (!safe(manifest.pack?.name) || !safe(manifest.pack?.version)) fail('Unsafe release name/version');
|
||||||
|
return `${manifest.pack.name}-${manifest.pack.version}`;
|
||||||
|
}
|
||||||
|
export function deployableRelease(directory) {
|
||||||
|
const release = verifyRelease(directory);
|
||||||
|
if (release.manifest.git?.dirty !== false || !/^[a-f0-9]{40,64}$/.test(release.manifest.git?.commit)) fail('Deploy requires a release built from a clean Git worktree');
|
||||||
|
releaseName(release.manifest);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
export function atomicJson(file, value) {
|
||||||
|
absolute(file, 'state path');
|
||||||
|
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
|
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||||
|
fs.renameSync(temporary, file);
|
||||||
|
}
|
||||||
|
export function pointer(root, name) {
|
||||||
|
const file = absolute(path.join(root, name), name);
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const value = fs.readFileSync(file,'utf8').trim();
|
||||||
|
if (!safe(value)) fail(`Invalid ${name}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
export function writePointer(root, name, value) {
|
||||||
|
const file = absolute(path.join(root,name), name), temp = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
|
fs.writeFileSync(temp, value+'\n', { flag:'wx',mode:0o600 });
|
||||||
|
fs.renameSync(temp,file);
|
||||||
|
}
|
||||||
|
export function managed(release) {
|
||||||
|
const files = release.manifest.managed_files?.server;
|
||||||
|
if (!Array.isArray(files)) fail('Release lacks managed server inventory');
|
||||||
|
const entries = new Map();
|
||||||
|
for (const file of files) {
|
||||||
|
if (typeof file.path !== 'string' || !file.path || /[\\\r\n\0]/.test(file.path) || file.path.split('/').some(p=>!p||p==='.'||p==='..') || file.path.startsWith('/') || entries.has(file.path) || !['replace','seed-only','preserve'].includes(file.policy) || !file.hashes || !Object.keys(file.hashes).length) fail('Invalid managed runtime inventory');
|
||||||
|
for (const [algorithm,hash] of Object.entries(file.hashes)) if (!['sha1','sha256','sha512'].includes(algorithm) || !/^[a-f0-9]+$/.test(hash)) fail('Invalid managed runtime hash');
|
||||||
|
entries.set(file.path,file);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
export function configConflicts(root, source, target, sourceDirectory) {
|
||||||
|
const old = source ? managed(source) : new Map(), next = managed(target), conflicts=[];
|
||||||
|
for (const [relative, file] of old) {
|
||||||
|
if (file.policy !== 'replace') continue;
|
||||||
|
const actual = absolute(path.join(root,'runtime',relative), 'managed runtime path');
|
||||||
|
if (!fs.existsSync(actual) || !fs.lstatSync(actual).isFile()) { conflicts.push({path:relative,reason:'managed-file-missing'}); continue; }
|
||||||
|
const bytes=fs.readFileSync(actual);
|
||||||
|
const replacement=next.get(relative);
|
||||||
|
// A new clean release may deliberately incorporate the exact manual edit.
|
||||||
|
// Allow that reviewed content; do not allow an unrelated overwrite/removal.
|
||||||
|
if(replacement?.policy==='replace'&&Object.entries(replacement.hashes).every(([algorithm,expected])=>crypto.createHash(algorithm).update(bytes).digest('hex')===expected))continue;
|
||||||
|
for(const [algorithm,expected] of Object.entries(file.hashes)) {
|
||||||
|
const found=crypto.createHash(algorithm).update(bytes).digest('hex');
|
||||||
|
if(found!==expected){
|
||||||
|
const conflict={path:relative,reason:'managed-file-edited',algorithm,expected,actual:found};
|
||||||
|
const original=sourceDirectory&&file.owner==='server'?path.join(sourceDirectory,'server',relative):null;
|
||||||
|
if(original&&fs.existsSync(original)&&bytes.length<=16384&&fs.statSync(original).size<=16384){
|
||||||
|
const before=fs.readFileSync(original,'utf8'),after=bytes.toString('utf8');
|
||||||
|
if(!before.includes('\0')&&!after.includes('\0'))conflict.diff=`--- release/${relative}\n+++ runtime/${relative}\n`+before.split('\n').map(line=>'-'+line).join('\n')+'\n'+after.split('\n').map(line=>'+'+line).join('\n');
|
||||||
|
}
|
||||||
|
conflicts.push(conflict);break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [relative,file] of next) if(file.policy==='replace'&&!old.has(relative)&&fs.existsSync(absolute(path.join(root,'runtime',relative),'managed runtime path')))conflicts.push({path:relative,reason:'new-managed-path-already-exists'});
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
export function space(root, bytes) {
|
||||||
|
const stats = fs.statfsSync(root,{bigint:true}), available=stats.bavail*stats.bsize;
|
||||||
|
if(available<BigInt(bytes))fail(`Insufficient free space: need at least ${bytes} bytes, have ${available}`);
|
||||||
|
}
|
||||||
|
export function runtimeSize(root) {
|
||||||
|
const directory=path.join(root,'runtime');
|
||||||
|
let sum=0;
|
||||||
|
const walk=file=>{const stat=fs.lstatSync(file);if(stat.isSymbolicLink()||(!stat.isFile()&&!stat.isDirectory()))fail('Runtime contains unsupported links/special files');if(stat.isFile())sum+=stat.size;else for(const child of fs.readdirSync(file))walk(path.join(file,child));};
|
||||||
|
if(fs.existsSync(directory))walk(directory);
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
// receive(stage) runs while the same host lock is held. It must finish upload
|
||||||
|
// before resolving. No stop or data mutation occurs during transmission.
|
||||||
|
export async function deployOnHost({ repository, root, targetName, targetIdentity, targetChecksums, releaseBytes, timeout=120, receive, env=process.env, execute=runtime }) {
|
||||||
|
root=absolute(root,'server root');
|
||||||
|
if(!safe(targetName)||!/^[a-f0-9]{64}$/.test(targetIdentity)||!/^[a-f0-9]{64}$/.test(targetChecksums)||!Number.isSafeInteger(releaseBytes)||releaseBytes<1||!Number.isSafeInteger(timeout)||timeout<=10)fail('Invalid deploy request');
|
||||||
|
if(!fs.statSync(root).isDirectory())fail('Prepare server root before deploying');
|
||||||
|
const settings=readSettings(absolute(path.join(root,'.env'),'host settings'));
|
||||||
|
if(settings.MC_EULA!=='TRUE')fail('Prepare local host EULA acceptance before deploy');
|
||||||
|
fs.accessSync(root,fs.constants.W_OK);
|
||||||
|
return await withRuntimeLock(root,async lockToken=>{
|
||||||
|
const rollbackState=absolute(path.join(root,'rollback-state.json'),'rollback state');
|
||||||
|
if(fs.existsSync(rollbackState)&&JSON.parse(fs.readFileSync(rollbackState)).phase!=='complete')fail('Unresolved rollback state; inspect rollback-state.json before deployment');
|
||||||
|
const stateFile=absolute(path.join(root,'deployment-state.json'),'deployment state');
|
||||||
|
if(fs.existsSync(stateFile)&&JSON.parse(fs.readFileSync(stateFile)).phase!=='complete')fail('Unresolved deployment state; inspect deployment-state.json and recover before a new operation');
|
||||||
|
const releases=absolute(path.join(root,'releases'),'releases');
|
||||||
|
fs.mkdirSync(releases,{recursive:true});
|
||||||
|
const active=pointer(root,'active-release'), previous=pointer(root,'previous-release');
|
||||||
|
if(!active&&previous)fail('Previous pointer exists without active release');
|
||||||
|
const sourcePath=active?path.join(releases,active):null;
|
||||||
|
const source=sourcePath?deployableRelease(sourcePath):null;
|
||||||
|
const bindingFile=absolute(path.join(root,'.mcpack-runtime.json'),'runtime binding');
|
||||||
|
if(source){
|
||||||
|
if(releaseName(source.manifest)!==active)fail('Active directory/name mismatch');
|
||||||
|
const binding=JSON.parse(fs.readFileSync(bindingFile));
|
||||||
|
if(binding.release_manifest_sha256!==source.identity)fail('Applied runtime differs from active release; manual recovery required');
|
||||||
|
}else if(fs.existsSync(bindingFile)||(fs.existsSync(path.join(root,'runtime'))&&fs.readdirSync(path.join(root,'runtime')).some(name=>name!=='.gitkeep')))fail('First deploy requires an empty unbound runtime');
|
||||||
|
const project=settings.MC_PROJECT_NAME??source?.manifest.server.compose_project;
|
||||||
|
const lifecycle=(release,args)=>execute({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:release,MC_ENV_FILE:path.join(root,'.env'),...(project?{MC_PROJECT_NAME:project}:{})},args,lockToken});
|
||||||
|
if(source && lifecycle(sourcePath,['config','--quiet'])!==0)fail('Source Compose validation failed');
|
||||||
|
const final=absolute(path.join(releases,targetName),'target release');
|
||||||
|
let target=fs.existsSync(final)?deployableRelease(final):null;
|
||||||
|
if(target&&(target.identity!==targetIdentity||sha256(path.join(final,'SHA256SUMS'))!==targetChecksums))fail('Same release version has different contents; immutable release collision');
|
||||||
|
const state={schema_version:1,id:crypto.randomUUID(),created_at:new Date().toISOString(),phase:'preflight',source_release:active,target_release:targetName,source_manifest_sha256:source?.identity??null,target_manifest_sha256:targetIdentity,target_checksums_sha256:targetChecksums,backup_snapshot:null,first_deploy:!source,applied:false,pointers_before:{active,previous}};
|
||||||
|
const save=phase=>{state.phase=phase;state.updated_at=new Date().toISOString();atomicJson(stateFile,state);};
|
||||||
|
let staging;
|
||||||
|
try{
|
||||||
|
save('preflight');
|
||||||
|
space(root,releaseBytes*2+runtimeSize(root)*3+64*1024*1024);
|
||||||
|
if(!target){
|
||||||
|
staging=fs.mkdtempSync(path.join(releases,'.incoming-'));
|
||||||
|
state.staging_directory=staging;
|
||||||
|
save('uploading');
|
||||||
|
await receive(staging);
|
||||||
|
target=deployableRelease(staging);
|
||||||
|
if(target.identity!==targetIdentity||releaseName(target.manifest)!==targetName||sha256(path.join(staging,'SHA256SUMS'))!==targetChecksums)fail('Transferred release differs from requested identity');
|
||||||
|
save('publishing');
|
||||||
|
// mkdtemp is private; the container UID must traverse the mounted release.
|
||||||
|
fs.chmodSync(staging,0o755);
|
||||||
|
fs.renameSync(staging,final);staging=null;
|
||||||
|
}
|
||||||
|
const conflicts=configConflicts(root,source,target,sourcePath);
|
||||||
|
if(conflicts.length){state.conflicts=conflicts;fail(`Managed config conflicts: ${JSON.stringify(conflicts)}. Incorporate edits into source; runtime was not replaced`);}
|
||||||
|
// Validate target Compose before stopping, without changing existing binding.
|
||||||
|
const validationRoot=fs.mkdtempSync(path.join(root,'.validate-'));
|
||||||
|
try{if(execute({repository,env:{...env,MC_ROOT:validationRoot,MC_RELEASE_DIR:final,MC_ENV_FILE:path.join(root,'.env'),...(project?{MC_PROJECT_NAME:project}:{})},args:['config','--quiet']})!==0)fail('Target Compose validation failed');}
|
||||||
|
finally{fs.rmdirSync(validationRoot);}
|
||||||
|
if(source?.identity===target.identity){
|
||||||
|
save('checking-current');
|
||||||
|
if(lifecycle(sourcePath,['health','--wait-timeout',String(timeout)])!==0)fail('Already active release is unhealthy; no automatic restart');
|
||||||
|
state.noop=true;save('complete');return state;
|
||||||
|
}
|
||||||
|
const apply=({snapshot}={})=>{
|
||||||
|
state.backup_snapshot=snapshot??null;save('applying');
|
||||||
|
// Recheck after stop/backup, immediately before replacing managed files.
|
||||||
|
const stoppedConflicts=configConflicts(root,source,target,sourcePath);
|
||||||
|
if(stoppedConflicts.length){state.conflicts=stoppedConflicts;fail(`Managed configs changed during deployment: ${JSON.stringify(stoppedConflicts)}`);}
|
||||||
|
state.applied=true;save('applying');
|
||||||
|
if(source){
|
||||||
|
const next=managed(target);
|
||||||
|
for(const [relative,file]of managed(source))if(file.policy==='replace'&&!next.has(relative))fs.unlinkSync(absolute(path.join(root,'runtime',relative),'removed managed path'));
|
||||||
|
atomicJson(bindingFile,{schema_version:1,project,release_manifest_sha256:target.identity});
|
||||||
|
}
|
||||||
|
save('starting');
|
||||||
|
if(lifecycle(final,['up','-d','--wait','--wait-timeout',String(timeout)])!==0)fail('Target startup/health failed; inspect diagnostics');
|
||||||
|
save('committing');
|
||||||
|
if(active)writePointer(root,'previous-release',active);
|
||||||
|
writePointer(root,'active-release',targetName);
|
||||||
|
save('complete');return state;
|
||||||
|
};
|
||||||
|
if(source){
|
||||||
|
save('backing-up');
|
||||||
|
return backupBeforeApply({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:sourcePath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},targetRelease:final,waitTimeout:timeout,lockToken},apply);
|
||||||
|
}
|
||||||
|
return apply();
|
||||||
|
}catch(error){
|
||||||
|
if(state.applied){
|
||||||
|
try{state.stop_exit=lifecycle(final,['stop']);state.stopped_confirmed=state.stop_exit===0;}catch(stopError){state.stop_error=stopError.message;}
|
||||||
|
}
|
||||||
|
// The backup status preserves a published snapshot even if failure occurred
|
||||||
|
// before the apply callback wrote the deployment record.
|
||||||
|
if(!state.backup_snapshot && state.phase==='backing-up')try{state.backup_snapshot=JSON.parse(fs.readFileSync(path.join(root,'.mcpack-backup-operation.json'))).snapshot;}catch{}
|
||||||
|
state.error=error.message;state.recovery=state.applied?'Restore snapshot into a separate root with its retained source release; do not start old pack on this runtime':'Runtime was not applied; inspect backup/server state before resuming source release';
|
||||||
|
save('failed');throw new Error(`${error.message}. State: ${stateFile}. ${state.recovery}`);
|
||||||
|
}finally{if(staging)fs.rmSync(staging,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
|
||||||
|
const sleep = milliseconds => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
||||||
|
|
||||||
|
// Synchronous so the lifecycle lock remains held through startup/readiness.
|
||||||
|
// Adapters also allow deterministic testing without real time or Docker.
|
||||||
|
export function waitForHealth({ inspect, probe, timeoutSeconds = 120, stableSeconds = 10, now = () => performance.now(), pause = sleep, startedAtMs }) {
|
||||||
|
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || !Number.isSafeInteger(stableSeconds) || stableSeconds < 1 || stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed positive stability window');
|
||||||
|
const started = startedAtMs ?? now(), deadline = started + timeoutSeconds * 1000;
|
||||||
|
let identity, restarts, startedAt, healthySince, lastState = null, lastProbe = null;
|
||||||
|
const result = (ok, reason) => ({ ok, reason, elapsed_ms: Math.round(now() - started), timeout_seconds: timeoutSeconds, stable_seconds: stableSeconds, state: lastState, probe: lastProbe });
|
||||||
|
const budget = () => Math.max(1, Math.ceil(deadline - now()));
|
||||||
|
try {
|
||||||
|
while (now() < deadline) {
|
||||||
|
const container = inspect(budget());
|
||||||
|
if (!container) return result(false, 'missing-container');
|
||||||
|
lastState = { id: container.Id, status: container.State?.Status, running: container.State?.Running, restarting: container.State?.Restarting, paused: container.State?.Paused, dead: container.State?.Dead, exit_code: container.State?.ExitCode, oom_killed: container.State?.OOMKilled, error: container.State?.Error, restart_count: container.RestartCount, started_at: container.State?.StartedAt, health: container.State?.Health?.Status };
|
||||||
|
if (identity === undefined) { identity = container.Id; restarts = container.RestartCount; startedAt = container.State?.StartedAt; }
|
||||||
|
if (!identity || !Number.isSafeInteger(restarts) || restarts < 0) return result(false, 'invalid-container-state');
|
||||||
|
if (identity !== container.Id) return result(false, 'container-replaced');
|
||||||
|
if (container.State?.Restarting || container.RestartCount !== restarts || container.State?.StartedAt !== startedAt) return result(false, 'restart-loop');
|
||||||
|
if (!container.State?.Running || container.State?.Dead || ['exited', 'dead', 'removing'].includes(container.State?.Status)) return result(false, 'crash');
|
||||||
|
if (container.State?.Paused) return result(false, 'paused');
|
||||||
|
if (!container.State?.Health) return result(false, 'missing-healthcheck');
|
||||||
|
if (now() >= deadline) break;
|
||||||
|
if (container.State.Health.Status === 'healthy') {
|
||||||
|
lastProbe = probe(container.Id, budget());
|
||||||
|
if (now() >= deadline) break;
|
||||||
|
if (lastProbe.status === 0) {
|
||||||
|
healthySince ??= now();
|
||||||
|
if (now() - healthySince >= stableSeconds * 1000) return result(true, 'healthy');
|
||||||
|
} else healthySince = undefined;
|
||||||
|
} else healthySince = undefined;
|
||||||
|
pause(Math.min(1000, Math.max(0, deadline - now())));
|
||||||
|
}
|
||||||
|
return result(false, 'timeout');
|
||||||
|
} catch (error) {
|
||||||
|
lastProbe = { ...lastProbe, error: error.message };
|
||||||
|
return result(false, now() >= deadline ? 'timeout' : 'inspection-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHealthReport({ root, project, releaseIdentity, result, logs }) {
|
||||||
|
const directory = path.join(root, 'diagnostics');
|
||||||
|
if (fs.existsSync(directory) && (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink())) throw new Error('Unsafe diagnostics directory');
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`;
|
||||||
|
const reportPath = path.join(directory, `health-${id}.json`), logsPath = path.join(directory, `health-${id}.log`);
|
||||||
|
let backup = null;
|
||||||
|
try { backup = JSON.parse(fs.readFileSync(path.join(root, '.mcpack-backup-operation.json'), 'utf8')).snapshot ?? null; } catch { /* No prior backup. */ }
|
||||||
|
const report = { schema_version: 1, created_at: new Date().toISOString(), project, release_manifest_sha256: releaseIdentity, ...result, recovery: { runtime_preserved: true, release_binding_preserved: true, active_release_changed: false, automatic_rollback: false, backup_snapshot: backup }, logs: logsPath };
|
||||||
|
fs.writeFileSync(logsPath, logs, { mode: 0o600, flag: 'wx' });
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||||
|
return { reportPath, logsPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthOptions(args, defaultTimeout = 120) {
|
||||||
|
args = args.flatMap(item => {
|
||||||
|
const match = /^(--wait-timeout|--stable-seconds)=(.*)$/.exec(item);
|
||||||
|
if (item.startsWith('--wait=')) throw new Error('Use --wait without a value');
|
||||||
|
return match ? [match[1], match[2]] : [item];
|
||||||
|
});
|
||||||
|
const command = args[0], wait = command === 'health' || args.includes('--wait');
|
||||||
|
let timeoutSeconds = defaultTimeout, stableSeconds = 10;
|
||||||
|
const seen = new Set(), forwarded = [command === 'health' ? 'ps' : command];
|
||||||
|
for (let index = 1; index < args.length; index++) {
|
||||||
|
const item = args[index];
|
||||||
|
if (['--wait', '--wait-timeout', '--stable-seconds'].includes(item)) {
|
||||||
|
if (seen.has(item)) throw new Error(`Repeated health argument: ${item}`);
|
||||||
|
seen.add(item);
|
||||||
|
if (item === '--wait') continue;
|
||||||
|
const value = args[++index];
|
||||||
|
if (!/^[1-9]\d*$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) throw new Error(`Invalid ${item}`);
|
||||||
|
if (item === '--wait-timeout') timeoutSeconds = Number(value); else stableSeconds = Number(value);
|
||||||
|
} else {
|
||||||
|
if (command === 'health') throw new Error(`Unexpected health argument: ${item}`);
|
||||||
|
forwarded.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seen.size && !wait) throw new Error('Health options require --wait');
|
||||||
|
if (wait && !['up', 'start', 'restart', 'health'].includes(command)) throw new Error('--wait supports up/start/restart/health only');
|
||||||
|
if (wait && stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed stability window; use --stable-seconds');
|
||||||
|
if (wait && command === 'up' && (forwarded.includes('--abort-on-container-exit') || forwarded.includes('--abort-on-container-failure') || forwarded.includes('--attach') || forwarded.includes('--attach-dependencies') || forwarded.includes('--exit-code-from'))) throw new Error('Health startup requires detached Compose');
|
||||||
|
if (wait && command === 'up' && !forwarded.includes('-d') && !forwarded.includes('--detach')) forwarded.push('-d');
|
||||||
|
return { wait, timeoutSeconds, stableSeconds, forwarded };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
import {parse as parseToml} from 'smol-toml';
|
||||||
|
import {parseDocument} from 'yaml';
|
||||||
|
import {build,validateConfig} from './build.mjs';
|
||||||
|
import {verifyRelease,absolute} from './runtime.mjs';
|
||||||
|
|
||||||
|
export const validVersion=value=>typeof value==='string'&&/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/.test(value);
|
||||||
|
export function release({root,version,configFile='platform/config.yaml',buildRelease=build,log=console.log}){
|
||||||
|
root=fs.realpathSync(root);
|
||||||
|
const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
||||||
|
if(!validVersion(version))throw new Error('Release version must be SemVer, for example 0.0.6');
|
||||||
|
if(fs.realpathSync(git(['rev-parse','--show-toplevel']))!==root)throw new Error('Release requires the repository root');
|
||||||
|
if(git(['status','--porcelain','--untracked-files=all']))throw new Error('Release requires a clean Git worktree, including untracked files');
|
||||||
|
if(git(['rev-parse','--is-shallow-repository'])==='true')throw new Error('Release requires complete local tag history; use a full clone');
|
||||||
|
git(['symbolic-ref','--quiet','HEAD']);
|
||||||
|
git(['var','GIT_AUTHOR_IDENT']);git(['var','GIT_COMMITTER_IDENT']);
|
||||||
|
const configuration=absolute(path.resolve(root,configFile),'release config');
|
||||||
|
if(!configuration.startsWith(root+path.sep))throw new Error('Config must be inside repository');
|
||||||
|
const doc=parseDocument(fs.readFileSync(configuration,'utf8'));
|
||||||
|
if(doc.errors.length||doc.warnings.length)throw new Error('Invalid release config');
|
||||||
|
const config=validateConfig(doc.toJS());
|
||||||
|
const inside=value=>{const file=absolute(path.resolve(root,value),'release path');if(!file.startsWith(root+path.sep))throw new Error('Release path must be inside repository');return file;};
|
||||||
|
const packFile=inside(path.join(config.pack.directory,'pack.toml')),output=inside(config.build.output_directory);
|
||||||
|
const relative=path.relative(root,packFile),original=fs.readFileSync(packFile,'utf8'),pack=parseToml(original);
|
||||||
|
if(!git(['ls-files','--',relative]))throw new Error('pack.toml must be tracked');
|
||||||
|
if(!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack.name))throw new Error('Unsafe pack name');
|
||||||
|
if(version===pack.version)throw new Error('Choose a new release version');
|
||||||
|
const tag='v'+version,destination=path.join(output,pack.name+'-'+version);
|
||||||
|
if(fs.existsSync(destination)||git(['tag','--list',tag]))throw new Error('Release version or tag already exists');
|
||||||
|
const matches=[...original.matchAll(/^version\s*=\s*(["']).*?\1[^\r\n]*$/gm)];
|
||||||
|
if(matches.length!==1||parseToml(original.replace(matches[0][0],`version = "${version}"`)).version!==version)throw new Error('Expected one top-level version assignment in pack.toml');
|
||||||
|
const start=git(['rev-parse','HEAD']);
|
||||||
|
const lock=path.join(root,'.tools/release.lock');fs.mkdirSync(path.dirname(lock),{recursive:true});
|
||||||
|
try{fs.mkdirSync(lock);}catch{throw new Error('Release is locked; inspect .tools/release.lock');}
|
||||||
|
const candidate=path.join(output,'.release-check-'+crypto.randomUUID());
|
||||||
|
const updated=original.replace(matches[0][0],`version = "${version}"`);
|
||||||
|
let committed=false,staged=false,artifact;
|
||||||
|
try{
|
||||||
|
if(git(['rev-parse','HEAD'])!==start||git(['status','--porcelain','--untracked-files=all']))throw new Error('Git state changed before preparation');
|
||||||
|
fs.writeFileSync(packFile,updated);
|
||||||
|
log('Validating candidate build before committing version.');
|
||||||
|
buildRelease({root,configFile,outputDirectory:candidate,log});
|
||||||
|
if(git(['rev-parse','HEAD'])!==start||git(['diff','--cached','--name-only'])||git(['status','--porcelain','--untracked-files=all','--','.',`:(exclude)${relative}`]))throw new Error('Git state changed during release preparation');
|
||||||
|
git(['add','--',relative]);staged=true;
|
||||||
|
git(['commit','-m',`Release ${version}`]);committed=true;
|
||||||
|
log('Building final release from version commit.');
|
||||||
|
artifact=buildRelease({root,configFile,releaseOnly:true,log});
|
||||||
|
const verified=verifyRelease(artifact),commit=git(['rev-parse','HEAD']);
|
||||||
|
if(verified.manifest.git.dirty!==false||verified.manifest.git.commit!==commit||verified.manifest.pack.version!==version||path.resolve(artifact)!==destination||git(['status','--porcelain','--untracked-files=all']))throw new Error('Final release provenance differs from clean version commit');
|
||||||
|
git(['tag','-a',tag,'-m',`Release ${version}`,commit]);
|
||||||
|
return {directory:artifact,version,tag,commit};
|
||||||
|
}catch(error){
|
||||||
|
const headChanged=git(['rev-parse','HEAD'])!==start;
|
||||||
|
const canRestore=!committed&&!headChanged&&fs.readFileSync(packFile,'utf8')===updated;
|
||||||
|
if(canRestore){
|
||||||
|
fs.writeFileSync(packFile,original);if(staged)git(['reset','--quiet','HEAD','--',relative]);
|
||||||
|
}
|
||||||
|
throw new Error(`${error.message}. ${committed?'Version commit retained; inspect artifact and Git state before recovery. No tag was created by the failed operation.':canRestore?'Version restored; no release commit or tag created.':'Concurrent changes retained; inspect Git and source before recovery.'}`);
|
||||||
|
}finally{fs.rmSync(candidate,{recursive:true,force:true});fs.rmdirSync(lock);}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { absolute, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
|
||||||
|
import { verifySnapshot, restoreSnapshot, runtimeInventory, backupBeforeApply } from './backups.mjs';
|
||||||
|
import { deployableRelease, releaseName, atomicJson, pointer, writePointer, managed, configConflicts, space, runtimeSize } from './deployment.mjs';
|
||||||
|
|
||||||
|
const fail=message=>{throw new Error(message);};
|
||||||
|
const safe=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)&&!['.','..'].includes(value);
|
||||||
|
|
||||||
|
// This function is self-contained: SSH can execute it read-only without uploading
|
||||||
|
// an agent, creating directories, obtaining a lock, stopping, or building.
|
||||||
|
export function inspectRollback(root, request={}, fsModule=fs, pathModule=path){
|
||||||
|
const safe=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)&&!['.','..'].includes(value);
|
||||||
|
if(!pathModule.isAbsolute(root)||root==='/'||/[\\\r\n\0]/.test(root))throw new Error('Invalid rollback root');
|
||||||
|
let current=pathModule.parse(root).root;
|
||||||
|
for(const part of root.slice(current.length).split('/').filter(Boolean)){current=pathModule.join(current,part);if(fsModule.lstatSync(current).isSymbolicLink())throw new Error('Rollback root contains symlink');}
|
||||||
|
const json=file=>{const target=pathModule.join(root,file);if(!fsModule.existsSync(target))return null;if(fsModule.lstatSync(target).isSymbolicLink())throw new Error('Unsafe state link');return JSON.parse(fsModule.readFileSync(target,'utf8'));};
|
||||||
|
const pointer=name=>{const file=pathModule.join(root,name);if(!fsModule.existsSync(file))return null;if(fsModule.lstatSync(file).isSymbolicLink())throw new Error('Unsafe pointer link');const value=fsModule.readFileSync(file,'utf8').trim();if(!safe(value))throw new Error('Invalid release pointer');return value;};
|
||||||
|
const active=pointer('active-release'),previous=pointer('previous-release');
|
||||||
|
if(request.backupId&&!safe(request.backupId))throw new Error('Use a snapshot ID, not a path');
|
||||||
|
if(request.version&&!safe(request.version))throw new Error('Unsafe target version');
|
||||||
|
const backups=pathModule.join(root,'backups');
|
||||||
|
if(fsModule.existsSync(backups)&&fsModule.lstatSync(backups).isSymbolicLink())throw new Error('Unsafe backups directory');
|
||||||
|
const snapshots=fsModule.existsSync(backups)?fsModule.readdirSync(backups).filter(safe).sort().map(id=>{
|
||||||
|
const directory=pathModule.join(backups,id),file=pathModule.join(directory,'metadata.json');
|
||||||
|
if(!fsModule.lstatSync(directory).isDirectory()||fsModule.lstatSync(directory).isSymbolicLink()||!fsModule.existsSync(file)||fsModule.lstatSync(file).isSymbolicLink())return null;
|
||||||
|
const data=JSON.parse(fsModule.readFileSync(file,'utf8'));
|
||||||
|
return{id,created_at:data.created_at,source_release:data.source_release?.name};
|
||||||
|
}).filter(Boolean):[];
|
||||||
|
const deployment=json('deployment-state.json'),rollback=json('rollback-state.json'),binding=json('.mcpack-runtime.json');
|
||||||
|
return{schema_version:1,action:'plan-only',root,active,previous,actual_manifest_sha256:binding?.release_manifest_sha256??null,deployment_phase:deployment?.phase??null,rollback_phase:rollback?.phase??null,snapshots,requested:request,target_checks:'full validation occurs only on explicit apply',notice:request.backupId?'Snapshot recovery replaces all runtime data; progress since its timestamp will be lost':'Pack-only rollback does not restore the world; compatibility requires explicit human confirmation'};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollbackOnHost({repository,root,mode,backupId,version,confirmCompatible=false,acceptProgressLoss=false,timeout=120,env=process.env,execute=runtime}){
|
||||||
|
root=absolute(root,'server root');
|
||||||
|
if(!['pack','snapshot'].includes(mode)||!Number.isSafeInteger(timeout)||timeout<=10)fail('Invalid rollback request');
|
||||||
|
if(mode==='pack'&&(!safe(version)||!confirmCompatible||backupId))fail('Pack-only rollback requires a version and --confirm-compatible');
|
||||||
|
if(mode==='snapshot'&&(!safe(backupId)||!acceptProgressLoss||version))fail('Snapshot rollback requires an ID and --accept-progress-loss');
|
||||||
|
const settings=readSettings(absolute(path.join(root,'.env'),'host settings'));
|
||||||
|
if(settings.MC_EULA!=='TRUE')fail('Host EULA must already be accepted');
|
||||||
|
return withRuntimeLock(root,lockToken=>{
|
||||||
|
const rollbackFile=absolute(path.join(root,'rollback-state.json'),'rollback state');
|
||||||
|
if(fs.existsSync(rollbackFile)&&JSON.parse(fs.readFileSync(rollbackFile)).phase!=='complete')fail('Unresolved rollback state; inspect/recover before another rollback');
|
||||||
|
const deploymentFile=absolute(path.join(root,'deployment-state.json'),'deployment state');
|
||||||
|
const priorDeployment=fs.existsSync(deploymentFile)?JSON.parse(fs.readFileSync(deploymentFile)):null;
|
||||||
|
if(mode==='pack'&&priorDeployment&&priorDeployment.phase!=='complete')fail('Failed/incomplete deployment requires snapshot recovery, not pack-only rollback');
|
||||||
|
const active=pointer(root,'active-release'),previous=pointer(root,'previous-release');
|
||||||
|
const bindingFile=absolute(path.join(root,'.mcpack-runtime.json'),'binding');
|
||||||
|
const binding=JSON.parse(fs.readFileSync(bindingFile));
|
||||||
|
if(binding.schema_version!==1||!/^[a-z0-9][a-z0-9_-]*$/.test(binding.project)||!/^[a-f0-9]{64}$/.test(binding.release_manifest_sha256))fail('Invalid runtime binding');
|
||||||
|
const releases=absolute(path.join(root,'releases'),'releases');
|
||||||
|
let source,sourcePath,sourceName;
|
||||||
|
// Recover the actually applied release, even if deployment failed before
|
||||||
|
// advancing active-release. Never guess it from the success pointer.
|
||||||
|
const preferred=[priorDeployment?.target_release,active,...fs.readdirSync(releases)].filter(safe);
|
||||||
|
for(const name of [...new Set(preferred)]){
|
||||||
|
const directory=absolute(path.join(releases,name),'release');
|
||||||
|
if(!fs.existsSync(directory)||!fs.statSync(directory).isDirectory())continue;
|
||||||
|
const release=deployableRelease(directory);
|
||||||
|
if(release.identity===binding.release_manifest_sha256){source=release;sourcePath=directory;sourceName=name;break;}
|
||||||
|
}
|
||||||
|
if(!source||releaseName(source.manifest)!==sourceName)fail('No verified retained release matches the actual runtime binding');
|
||||||
|
if(mode==='pack'&&sourceName!==active)fail('Pack-only rollback requires actual binding to match active release');
|
||||||
|
const project=binding.project;
|
||||||
|
if(settings.MC_PROJECT_NAME&&settings.MC_PROJECT_NAME!==project)fail('Host project differs from runtime binding');
|
||||||
|
const lifecycle=(release,args)=>execute({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:release,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},args,lockToken});
|
||||||
|
if(lifecycle(sourcePath,['config','--quiet'])!==0)fail('Current Compose validation failed');
|
||||||
|
let snapshot,metadata,target,targetPath,targetName;
|
||||||
|
if(mode==='snapshot'){
|
||||||
|
snapshot=absolute(path.join(root,'backups',backupId),'snapshot');metadata=verifySnapshot(snapshot);
|
||||||
|
if(metadata.id!==backupId||metadata.runtime_binding.project!==project)fail('Snapshot identity/project differs from this server');
|
||||||
|
target=deployableRelease(path.join(snapshot,'release'));targetName=releaseName(target.manifest);
|
||||||
|
if(target.manifest.pack.name!==source.manifest.pack.name)fail('Snapshot belongs to another modpack');
|
||||||
|
targetPath=path.join(releases,targetName);
|
||||||
|
if(fs.existsSync(targetPath)&&deployableRelease(targetPath).identity!==target.identity)fail('Retained target version has different contents');
|
||||||
|
}else{
|
||||||
|
targetName=version.startsWith(source.manifest.pack.name+'-')?version:source.manifest.pack.name+'-'+version;
|
||||||
|
if(!safe(targetName))fail('Unsafe rollback release name');
|
||||||
|
targetPath=absolute(path.join(releases,targetName),'rollback release');target=deployableRelease(targetPath);
|
||||||
|
if(releaseName(target.manifest)!==targetName||target.manifest.pack.name!==source.manifest.pack.name)fail('Target belongs to another modpack');
|
||||||
|
const conflicts=configConflicts(root,source,target,sourcePath);
|
||||||
|
if(conflicts.length)fail(`Managed config conflicts: ${JSON.stringify(conflicts)}`);
|
||||||
|
}
|
||||||
|
space(root,runtimeSize(root)*3+(metadata?.runtime_inventory.reduce((sum,file)=>sum+(file.size??0),0)??0)+64*1024*1024);
|
||||||
|
const state={schema_version:1,id:crypto.randomUUID(),operation:'rollback',mode,created_at:new Date().toISOString(),phase:'preflight',source_release:sourceName,source_manifest_sha256:source.identity,target_release:targetName,target_manifest_sha256:target.identity,requested_snapshot:snapshot??null,snapshot_created_at:metadata?.created_at??null,progress_loss_accepted:mode==='snapshot',compatibility_confirmed:mode==='pack',backup_snapshot:null,applied:false,runtime_replaced:false,pointers_before:{active,previous}};
|
||||||
|
const save=phase=>{state.phase=phase;state.updated_at=new Date().toISOString();atomicJson(rollbackFile,state);};
|
||||||
|
let restored;
|
||||||
|
try{
|
||||||
|
save('validating-target');
|
||||||
|
if(mode==='snapshot'){
|
||||||
|
restored=path.join(root,'.rollback-restore-'+state.id);state.restore_directory=restored;
|
||||||
|
save('restoring-stage');restoreSnapshot({backupDirectory:snapshot,destination:restored});
|
||||||
|
if(!fs.existsSync(targetPath)){
|
||||||
|
const stage=fs.mkdtempSync(path.join(releases,'.rollback-release-'));
|
||||||
|
try{fs.cpSync(path.join(restored,'release'),stage,{recursive:true});if(deployableRelease(stage).identity!==target.identity)fail('Target changed while copying');fs.chmodSync(stage,0o755);fs.renameSync(stage,targetPath);}
|
||||||
|
finally{if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true,force:true});}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const validation=fs.mkdtempSync(path.join(root,'.validate-rollback-'));
|
||||||
|
try{if(execute({repository,env:{...env,MC_ROOT:validation,MC_RELEASE_DIR:targetPath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},args:['config','--quiet']})!==0)fail('Target Compose validation failed');}
|
||||||
|
finally{
|
||||||
|
const overlay=path.join(validation,'.mcpack-host-ports.yaml');if(fs.existsSync(overlay))fs.unlinkSync(overlay);
|
||||||
|
fs.rmdirSync(validation);
|
||||||
|
}
|
||||||
|
save('backing-up-current');
|
||||||
|
return backupBeforeApply({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:sourcePath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},targetRelease:targetPath,waitTimeout:timeout,lockToken},({snapshot:currentBackup})=>{
|
||||||
|
state.backup_snapshot=currentBackup;save('applying');
|
||||||
|
if(mode==='pack'){
|
||||||
|
const conflicts=configConflicts(root,source,target,sourcePath);if(conflicts.length)fail(`Configs changed while backing up: ${JSON.stringify(conflicts)}`);
|
||||||
|
}
|
||||||
|
if(lifecycle(sourcePath,['down'])!==0)fail('Could not remove stopped source container');
|
||||||
|
state.container_removed=true;state.applied=true;save('applying');
|
||||||
|
if(mode==='snapshot'){
|
||||||
|
const retiredParent=absolute(path.join(root,'retired-runtimes',state.id),'retired runtime');fs.mkdirSync(retiredParent,{recursive:true,mode:0o700});
|
||||||
|
state.retired_runtime=path.join(retiredParent,'runtime');save('moving-current-runtime');
|
||||||
|
fs.renameSync(path.join(root,'runtime'),state.retired_runtime);state.current_runtime_moved=true;save('attaching-snapshot');
|
||||||
|
fs.renameSync(path.join(restored,'runtime'),path.join(root,'runtime'));state.runtime_replaced=true;save('verifying-restored');
|
||||||
|
assert.deepStrictEqual(runtimeInventory(path.join(root,'runtime')),metadata.runtime_inventory,'Attached runtime differs from snapshot');
|
||||||
|
}else{
|
||||||
|
const next=managed(target);for(const [relative,file]of managed(source))if(file.policy==='replace'&&!next.has(relative))fs.unlinkSync(absolute(path.join(root,'runtime',relative),'removed managed file'));
|
||||||
|
}
|
||||||
|
atomicJson(bindingFile,{schema_version:1,project,release_manifest_sha256:target.identity});
|
||||||
|
save('starting');
|
||||||
|
if(lifecycle(targetPath,['up','-d','--wait','--wait-timeout',String(timeout)])!==0)fail('Rollback startup/health failed; inspect diagnostics');
|
||||||
|
save('committing');
|
||||||
|
if(active&&active!==targetName)writePointer(root,'previous-release',active);
|
||||||
|
writePointer(root,'active-release',targetName);
|
||||||
|
const history=absolute(path.join(root,'operation-history'),'operation history');fs.mkdirSync(history,{recursive:true,mode:0o700});
|
||||||
|
if(priorDeployment){state.prior_deployment_state=path.join(history,'deployment-before-'+state.id+'.json');atomicJson(state.prior_deployment_state,priorDeployment);}
|
||||||
|
atomicJson(deploymentFile,{...state,phase:'complete',resolved_deployment:priorDeployment?.phase!=='complete'?priorDeployment?.id??null:null});
|
||||||
|
save('complete');return state;
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
if(state.applied){
|
||||||
|
try{
|
||||||
|
const actual=JSON.parse(fs.readFileSync(bindingFile)).release_manifest_sha256;
|
||||||
|
const stopPath=actual===target.identity?targetPath:sourcePath;
|
||||||
|
state.stop_exit=lifecycle(stopPath,['stop']);state.stopped_confirmed=state.stop_exit===0;
|
||||||
|
}catch(stopError){state.stop_error=stopError.message;}
|
||||||
|
}
|
||||||
|
if(!state.backup_snapshot&&state.phase==='backing-up-current')try{state.backup_snapshot=JSON.parse(fs.readFileSync(path.join(root,'.mcpack-backup-operation.json'))).snapshot;}catch{}
|
||||||
|
state.error=error.message;state.recovery=state.applied?'No automatic restart/rollback. Preserve retired runtime and current backup; inspect binding, pointers and diagnostics before explicit recovery':'Current runtime was not replaced. Inspect stop/backup state before resuming source';
|
||||||
|
save('failed');throw new Error(`${error.message}. State: ${rollbackFile}. ${state.recovery}`);
|
||||||
|
}finally{
|
||||||
|
// The displaced world is never removed. A restored stage with its runtime
|
||||||
|
// still present is retained on failure for diagnosis/recovery.
|
||||||
|
if(restored&&state.phase==='complete')fs.rmSync(restored,{recursive:true,force:true});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
|
import { parseDocument } from 'yaml';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { healthOptions, waitForHealth, saveHealthReport } from './health.mjs';
|
||||||
|
|
||||||
|
const fail = message => { throw new Error(message); };
|
||||||
|
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
||||||
|
const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep);
|
||||||
|
|
||||||
|
export function absolute(value, label) {
|
||||||
|
if (!value || !path.isAbsolute(value) || /[\0\r\n]/.test(value)) fail(`${label} must be an absolute path`);
|
||||||
|
const result = path.resolve(value);
|
||||||
|
let current = path.parse(result).root;
|
||||||
|
for (const part of result.slice(current.length).split(path.sep).filter(Boolean)) {
|
||||||
|
current = path.join(current, part);
|
||||||
|
try { if (fs.lstatSync(current).isSymbolicLink()) fail(`${label} must not contain symlinks`); }
|
||||||
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tree(directory) {
|
||||||
|
const result = [];
|
||||||
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const file = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) result.push(...tree(file).map(child => `${entry.name}/${child}`));
|
||||||
|
else if (entry.isFile()) result.push(entry.name);
|
||||||
|
else fail('Release must contain only regular files/directories');
|
||||||
|
}
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRelease(directory) {
|
||||||
|
directory = absolute(directory, 'MC_RELEASE_DIR');
|
||||||
|
const actual = tree(directory);
|
||||||
|
const checksums = new Map();
|
||||||
|
for (const line of fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8').trim().split('\n')) {
|
||||||
|
const match = /^([a-f0-9]{64}) (.+)$/.exec(line);
|
||||||
|
if (!match || !safe(match[2]) || match[2] === 'SHA256SUMS' || checksums.has(match[2])) fail('Malformed or duplicate release checksum');
|
||||||
|
checksums.set(match[2], match[1]);
|
||||||
|
}
|
||||||
|
if (JSON.stringify([...checksums.keys()].sort()) !== JSON.stringify(actual.filter(file => file !== 'SHA256SUMS'))) fail('Release checksum coverage is incomplete');
|
||||||
|
for (const [file, expected] of checksums) if (sha256(path.join(directory, file)) !== expected) fail(`Release checksum mismatch: ${file}`);
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
||||||
|
if (manifest.schema_version !== 1 || !safe(manifest.artifact) || manifest.artifact.includes('/') || !checksums.has(manifest.artifact)) fail('Unsupported or invalid release manifest');
|
||||||
|
if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(manifest.server?.image)) fail('Release image must be pinned by digest');
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(manifest.tools?.compose?.sha256)) fail('Missing pinned Compose checksum');
|
||||||
|
const payload = new Map();
|
||||||
|
for (const file of manifest.release_files ?? []) {
|
||||||
|
if (!safe(file.path) || payload.has(file.path) || checksums.get(file.path) !== file.sha256) fail('Manifest payload does not match checksums');
|
||||||
|
payload.set(file.path, file.sha256);
|
||||||
|
}
|
||||||
|
if (JSON.stringify([...payload.keys()].sort()) !== JSON.stringify(actual.filter(file => !['manifest.json', 'SHA256SUMS'].includes(file)))) fail('Manifest payload coverage is incomplete');
|
||||||
|
for (const required of ['server/compose.yaml', 'server/.env.example', manifest.artifact]) if (!checksums.has(required)) fail(`Release file missing: ${required}`);
|
||||||
|
if (!fs.statSync(path.join(directory, 'server/config')).isDirectory()) fail('Server config directory missing');
|
||||||
|
return { manifest, identity: checksums.get('manifest.json') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSettings(file) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||||
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||||
|
const match = /^([A-Z][A-Z0-9_]*)=([A-Za-z0-9_.-]+)$/.exec(line.trim());
|
||||||
|
if (!match || !['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS'].includes(match[1]) || match[1] in result) fail('Invalid local settings: use simple KEY=value entries from .env.example');
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
if (!['TRUE', 'FALSE'].includes(result.MC_EULA)) fail('Set MC_EULA explicitly to TRUE or FALSE in the local settings file');
|
||||||
|
for (const key of ['MC_UID', 'MC_GID']) if (!/^(0|[1-9]\d*)$/.test(result[key])) fail(`Set numeric ${key} explicitly`);
|
||||||
|
if (result.MC_MEMORY && !/^[1-9]\d*[MG]$/.test(result.MC_MEMORY)) fail('Invalid MC_MEMORY');
|
||||||
|
if (result.MC_PORT && (!/^\d+$/.test(result.MC_PORT) || Number(result.MC_PORT) < 1 || Number(result.MC_PORT) > 65535)) fail('Invalid MC_PORT');
|
||||||
|
if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('MC_BIND_ADDRESS must be an IPv4 address');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockTokens = new WeakSet();
|
||||||
|
|
||||||
|
export function withRuntimeLock(root, operation) {
|
||||||
|
root = absolute(root, 'MC_ROOT');
|
||||||
|
const directory = path.join(root, '.mcpack-runtime.lock');
|
||||||
|
try { fs.mkdirSync(directory); } catch { fail('Another runtime operation is running (or a stale .mcpack-runtime.lock remains)'); }
|
||||||
|
const token = { root };
|
||||||
|
lockTokens.add(token);
|
||||||
|
const release = () => { lockTokens.delete(token); fs.rmdirSync(directory); };
|
||||||
|
try {
|
||||||
|
const result = operation(token);
|
||||||
|
if (result && typeof result.then === 'function') return Promise.resolve(result).finally(release);
|
||||||
|
release();
|
||||||
|
return result;
|
||||||
|
} catch (error) { if (lockTokens.has(token)) release(); throw error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtime({ repository, args, env = process.env, lockToken, capture = false }) {
|
||||||
|
const commands = ['config', 'up', 'start', 'stop', 'restart', 'ps', 'logs', 'down', 'create', 'exec', 'health'];
|
||||||
|
if (!args.length || args[0] === '--help') {
|
||||||
|
console.log('MC_ROOT=/absolute/server-root MC_RELEASE_DIR=/absolute/release scripts/server.sh <config|up|start|stop|restart|ps|logs|down|create|exec|health> [compose options]\nReadiness: up/start/restart --wait or health [--wait-timeout SECONDS] [--stable-seconds SECONDS]. Settings: MC_ENV_FILE or MC_ROOT/.env.');
|
||||||
|
return args.length ? 0 : 1;
|
||||||
|
}
|
||||||
|
const command = args[0];
|
||||||
|
if (!commands.includes(command)) fail('Unsupported lifecycle command');
|
||||||
|
if (capture && !['ps', 'config'].includes(command)) fail('Capture is only allowed for read-only queries');
|
||||||
|
if (args.some(arg => /^(?:-[fp]|--(?:file|project-name|env-file|project-directory)(?:=|$))/.test(arg))) fail('Compose identity/file options are controlled by the wrapper');
|
||||||
|
repository = fs.realpathSync(repository);
|
||||||
|
let defaultTimeout = 120;
|
||||||
|
if (command === 'health' || args.includes('--wait')) {
|
||||||
|
const configFile = path.join(repository, 'platform/config.yaml');
|
||||||
|
if (fs.existsSync(configFile)) {
|
||||||
|
const document = parseDocument(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
if (document.errors.length) fail('Invalid platform config for health timeout');
|
||||||
|
defaultTimeout = document.toJS().deploy?.healthcheck_timeout;
|
||||||
|
if (!Number.isSafeInteger(defaultTimeout) || defaultTimeout < 1) fail('Invalid configured healthcheck_timeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const health = healthOptions(args, defaultTimeout);
|
||||||
|
const root = absolute(env.MC_ROOT, 'MC_ROOT');
|
||||||
|
if (lockToken && (!lockTokens.has(lockToken) || lockToken.root !== root)) fail('Invalid runtime operation lock token');
|
||||||
|
const release = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR');
|
||||||
|
if (!fs.statSync(root).isDirectory()) fail('Create MC_ROOT before running the server wrapper');
|
||||||
|
const data = absolute(path.join(root, 'runtime'), 'runtime path');
|
||||||
|
if (contained(data, release) || contained(release, data) || contained(release, root)) fail('Runtime/server root must not overlap immutable release');
|
||||||
|
const envFile = absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE');
|
||||||
|
if (contained(release, envFile)) fail('Local settings must be outside the immutable release');
|
||||||
|
const settings = readSettings(envFile);
|
||||||
|
const starts = ['up', 'start', 'restart', 'create'].includes(command);
|
||||||
|
if (starts && settings.MC_EULA !== 'TRUE') fail('Startup requires your EULA acceptance: set MC_EULA=TRUE in local settings');
|
||||||
|
const { manifest, identity } = verifyRelease(release);
|
||||||
|
const compatibility = JSON.parse(fs.readFileSync(path.join(repository, 'platform/compatibility.json'), 'utf8'));
|
||||||
|
if (!compatibility.verified.some(item => item.minecraft === manifest.pack?.minecraft && item.loader === manifest.pack?.loader?.id && item.loader_version === manifest.pack?.loader?.version && item.image === manifest.server.image && item.java_major === manifest.server.java_major)) fail('Release Minecraft/loader/image/Java combination is not verified');
|
||||||
|
const executable = path.join(repository, '.tools/docker-compose');
|
||||||
|
if (sha256(executable) !== manifest.tools.compose.sha256) fail('Pinned Compose checksum mismatch');
|
||||||
|
const project = env.MC_PROJECT_NAME ?? settings.MC_PROJECT_NAME ?? manifest.server.compose_project;
|
||||||
|
if (typeof project !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(project)) fail('Invalid stable Compose project name');
|
||||||
|
const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding');
|
||||||
|
const binding = { schema_version: 1, project, release_manifest_sha256: identity };
|
||||||
|
const checkBinding = () => {
|
||||||
|
if (fs.existsSync(stateFile)) {
|
||||||
|
const previous = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||||
|
if (previous.schema_version !== 1 || previous.project !== project || previous.release_manifest_sha256 !== identity) fail('Runtime is bound to another project/release; changing it requires the future backup/deploy workflow');
|
||||||
|
} else if (starts && fs.existsSync(data) && fs.readdirSync(data).some(file => file !== '.gitkeep')) fail('Nonempty runtime has no release binding; migrate through the backup/deploy workflow');
|
||||||
|
};
|
||||||
|
checkBinding();
|
||||||
|
// Host settings have one source of truth; ambient MC_* cannot override .env.
|
||||||
|
const composeEnv = { ...env, MC_ROOT: root, MC_RELEASE_DIR: release };
|
||||||
|
for (const key of ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS']) delete composeEnv[key];
|
||||||
|
Object.assign(composeEnv, settings);
|
||||||
|
const base = ['--project-name', project, '--env-file', envFile, '-f', path.join(release, 'server/compose.yaml')];
|
||||||
|
// Legacy releases hardcoded loopback. Keep immutable Compose intact while
|
||||||
|
// honoring the user's explicit host networking preference during rollback.
|
||||||
|
let portOverlay;
|
||||||
|
const sourceCompose = fs.readFileSync(path.join(release, 'server/compose.yaml'), 'utf8');
|
||||||
|
if (settings.MC_BIND_ADDRESS && !sourceCompose.includes('MC_BIND_ADDRESS') && Array.isArray(parseDocument(sourceCompose).toJS()?.services?.minecraft?.ports)) {
|
||||||
|
portOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-host-ports-'));
|
||||||
|
const file = path.join(portOverlay, 'ports.yaml');
|
||||||
|
const endpoint = `${settings.MC_BIND_ADDRESS}:${settings.MC_PORT ?? manifest.server.port ?? 25565}:25565`;
|
||||||
|
fs.writeFileSync(file, `services:\n minecraft:\n ports: !override\n - ${JSON.stringify(endpoint)}\n`, { mode: 0o600 });
|
||||||
|
base.push('-f', file);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const compiled = JSON.parse(execFileSync(executable, [...base, 'config', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||||
|
const service = compiled.services?.minecraft;
|
||||||
|
if (!service || service.image !== manifest.server.image || service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false' || service.environment?.MODRINTH_MODPACK !== `/modpacks/${manifest.artifact}`) fail('Compiled Compose does not match offline release contract');
|
||||||
|
for (const [target, source, readOnly] of [['/data', data, false], ['/modpacks', release, true], ['/config', path.join(release, 'server/config'), true]]) {
|
||||||
|
const mount = service.volumes?.find(volume => volume.target === target);
|
||||||
|
if (!mount || mount.type !== 'bind' || mount.source !== source || Boolean(mount.read_only) !== readOnly) fail(`Invalid absolute bind mount: ${target}`);
|
||||||
|
}
|
||||||
|
const mutations = ['up', 'start', 'stop', 'restart', 'down', 'create'].includes(command);
|
||||||
|
const checkProjectOwner = () => {
|
||||||
|
const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
const containers = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line));
|
||||||
|
if (!Array.isArray(containers)) fail('Unexpected Compose project status');
|
||||||
|
const ids = containers.map(container => container.ID);
|
||||||
|
if (ids.some(id => typeof id !== 'string' || !/^[a-f0-9]{12,64}$/.test(id))) fail('Unexpected Compose container identity');
|
||||||
|
if (!ids.length) return;
|
||||||
|
const inspected = JSON.parse(execFileSync('docker', ['inspect', ...ids], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||||
|
if (inspected.some(container => !container.Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data))) fail('Compose project belongs to another runtime root; choose a distinct stable MC_PROJECT_NAME');
|
||||||
|
};
|
||||||
|
const perform = () => {
|
||||||
|
if (mutations) checkBinding();
|
||||||
|
if (mutations || command === 'exec' || health.wait) checkProjectOwner();
|
||||||
|
if (health.wait && !fs.existsSync(stateFile) && !starts) fail('Health requires a bound runtime');
|
||||||
|
if (starts) {
|
||||||
|
fs.mkdirSync(data, { recursive: true });
|
||||||
|
fs.accessSync(data, fs.constants.W_OK);
|
||||||
|
if (!fs.existsSync(stateFile)) fs.writeFileSync(stateFile, JSON.stringify(binding, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||||
|
}
|
||||||
|
const diagnostic = readiness => {
|
||||||
|
const logResult = spawnSync(executable, [...base, 'logs', '--no-color', '--tail', '100'], { env: composeEnv, encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 });
|
||||||
|
const logs = (logResult.stdout ?? '') + (logResult.stderr ?? '') + (logResult.error ? `\nLog collection failed: ${logResult.error.message}` : '');
|
||||||
|
const files = saveHealthReport({ root, project, releaseIdentity: identity, result: readiness, logs });
|
||||||
|
console.error(`Health ${readiness.reason}. Report: ${files.reportPath}\nLogs: ${files.logsPath}\nRecovery: runtime and release binding preserved; no automatic rollback.`);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
const healthStarted = performance.now();
|
||||||
|
const result = command === 'health' ? { status: 0 } : spawnSync(executable, [...base, ...health.forwarded], { env: composeEnv, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: capture ? 'utf8' : undefined, timeout: health.wait ? health.timeoutSeconds * 1000 : undefined });
|
||||||
|
if (health.wait && (result.error || result.signal || result.status !== 0)) return diagnostic({ ok: false, reason: result.error?.code === 'ETIMEDOUT' ? 'timeout' : 'compose-start-failed', compose_exit: result.status, error: result.error?.message, signal: result.signal });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.signal) fail(`Compose terminated by ${result.signal}`);
|
||||||
|
if (capture) {
|
||||||
|
if (result.status !== 0) fail(`Compose query failed: ${result.stderr?.trim() || result.status}`);
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
if (health.wait) {
|
||||||
|
const readiness = waitForHealth({ ...health, startedAtMs: healthStarted,
|
||||||
|
inspect: timeout => {
|
||||||
|
const queryStarted = performance.now();
|
||||||
|
const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json', 'minecraft'], { env: composeEnv, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||||
|
const list = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line));
|
||||||
|
if (!Array.isArray(list) || list.length > 1) fail('Unexpected Minecraft container count');
|
||||||
|
if (!list.length) return null;
|
||||||
|
if (!/^[a-f0-9]{12,64}$/.test(list[0].ID)) fail('Unexpected Minecraft container identity');
|
||||||
|
const remaining = Math.ceil(timeout - (performance.now() - queryStarted));
|
||||||
|
if (remaining <= 0) fail('Health query deadline exceeded');
|
||||||
|
const inspected = JSON.parse(execFileSync('docker', ['inspect', list[0].ID], { env: composeEnv, encoding: 'utf8', timeout: remaining, stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||||
|
if (inspected.length !== 1 || !inspected[0].Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data)) fail('Health container belongs to another runtime');
|
||||||
|
return inspected[0];
|
||||||
|
},
|
||||||
|
probe: (id, timeout) => {
|
||||||
|
const probe = spawnSync('docker', ['exec', id, 'mc-health'], { env: composeEnv, encoding: 'utf8', timeout: Math.min(timeout, 10000), maxBuffer: 1024 * 1024 });
|
||||||
|
return { status: probe.status, stdout: probe.stdout?.trim(), stderr: probe.stderr?.trim(), error: probe.error?.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!readiness.ok) return diagnostic(readiness);
|
||||||
|
console.log(`Healthy: Minecraft status query passed; stable for ${health.stableSeconds}s (${project}).`);
|
||||||
|
}
|
||||||
|
return result.status ?? 1;
|
||||||
|
};
|
||||||
|
return (mutations || health.wait) && !lockToken ? withRuntimeLock(root, perform) : perform();
|
||||||
|
} finally { if (portOverlay) fs.rmSync(portOverlay, { recursive: true, force: true }); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Minecraft platform configuration v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schema_version", "pack", "build", "server", "deploy"],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "const": 1 },
|
||||||
|
"pack": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["directory"],
|
||||||
|
"properties": { "directory": { "type": "string", "minLength": 1 } }
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"type": "object", "additionalProperties": false, "required": ["output_directory"],
|
||||||
|
"properties": { "output_directory": { "type": "string", "minLength": 1 } }
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["host", "directory", "memory", "port"],
|
||||||
|
"properties": {
|
||||||
|
"host": { "type": ["string", "null"], "minLength": 1 },
|
||||||
|
"directory": { "type": ["string", "null"], "pattern": "^/" },
|
||||||
|
"memory": { "type": "string", "pattern": "^[1-9][0-9]*[MG]$" },
|
||||||
|
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
|
||||||
|
},
|
||||||
|
"oneOf": [
|
||||||
|
{ "properties": { "host": { "type": "null" }, "directory": { "type": "null" } } },
|
||||||
|
{ "properties": { "host": { "type": "string" }, "directory": { "type": "string" } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"deploy": {
|
||||||
|
"type": "object", "additionalProperties": false,
|
||||||
|
"required": ["transport", "healthcheck_timeout"],
|
||||||
|
"properties": {
|
||||||
|
"transport": { "const": "ssh" },
|
||||||
|
"healthcheck_timeout": { "type": "integer", "minimum": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import { parseDocument } from 'yaml';
|
||||||
|
import { validateConfig, build } from './build.mjs';
|
||||||
|
import { inspectRollback } from './rollback.mjs';
|
||||||
|
import { deployableRelease, releaseName, sha256 } from './deployment.mjs';
|
||||||
|
|
||||||
|
export const shellQuote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
|
||||||
|
export function sshTarget(host,directory){
|
||||||
|
if(typeof host!=='string'||!/^([A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host))throw new Error('Use a safe SSH host/alias or user@host (ports and keys belong in SSH config)');
|
||||||
|
if(typeof directory!=='string'||!directory.startsWith('/')||directory==='/'||/[\\\r\n\0:]/.test(directory)||directory.split('/').slice(1).some(part=>!part||part==='.'||part==='..'))throw new Error('Use an absolute normalized non-root remote directory');
|
||||||
|
return{host,directory};
|
||||||
|
}
|
||||||
|
const sshArgs=['-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=15','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3'];
|
||||||
|
function command(program,args,{input,env=process.env}={}){
|
||||||
|
try{return execFileSync(program,args,{input,env,encoding:'utf8',stdio:['pipe','pipe','pipe'],maxBuffer:32*1024*1024});}
|
||||||
|
catch(error){
|
||||||
|
let remoteMessage;
|
||||||
|
for(const line of error.stdout?.toString().split('\n')??[])if(line.startsWith('MCPACK:'))try{const message=JSON.parse(line.slice(7));if(message.type==='error')remoteMessage=message.message;}catch{}
|
||||||
|
throw new Error(`${program} failed: ${remoteMessage||error.stderr?.toString().trim()||error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function agentBundle(repository,config){
|
||||||
|
const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'mcpack-agent-'));
|
||||||
|
try{
|
||||||
|
for(const file of ['platform/runtime.mjs','platform/backups.mjs','platform/health.mjs','platform/deployment.mjs','platform/rollback.mjs','platform/compatibility.json','scripts/deploy-worker.mjs','scripts/server.mjs','scripts/server.sh','.tools/docker-compose','package.json']){
|
||||||
|
const destination=path.join(temporary,file);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.copyFileSync(path.join(repository,file),destination);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(temporary,'platform/config.yaml'),`deploy:\n healthcheck_timeout: ${config.deploy.healthcheck_timeout}\n`);
|
||||||
|
fs.mkdirSync(path.join(temporary,'node_modules'));
|
||||||
|
fs.cpSync(fs.realpathSync(path.join(repository,'node_modules/yaml')),path.join(temporary,'node_modules/yaml'),{recursive:true});
|
||||||
|
const entries=[];
|
||||||
|
const walk=(directory,relative='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,entry.name),name=relative?relative+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())entries.push({path:name,sha256:sha256(file)});else throw new Error('Agent contains links/special files');}};
|
||||||
|
walk(temporary);entries.sort((a,b)=>a.path.localeCompare(b.path));
|
||||||
|
fs.writeFileSync(path.join(temporary,'agent-files.json'),JSON.stringify(entries));
|
||||||
|
return{directory:temporary,identity:sha256(path.join(temporary,'agent-files.json'))};
|
||||||
|
}catch(error){fs.rmSync(temporary,{recursive:true,force:true});throw error;}
|
||||||
|
}
|
||||||
|
function upload(source,host,destination,connectionArgs=sshArgs){
|
||||||
|
// --secluded-args protects remote paths; SSH destination itself is validated.
|
||||||
|
return command('rsync',['--archive','--secluded-args','--delete','-e',`ssh ${connectionArgs.map(shellQuote).join(' ')}`,source+path.sep,host+':'+destination+'/']);
|
||||||
|
}
|
||||||
|
const bootstrap=`
|
||||||
|
const fs=require('fs'),path=require('path'),cp=require('child_process');
|
||||||
|
const root=process.argv[1];
|
||||||
|
if(Number(process.versions.node.split('.')[0])<22)throw Error('Host requires Node.js 22+');
|
||||||
|
let current='/';for(const part of root.split('/').filter(Boolean)){current=path.join(current,part);if(fs.lstatSync(current).isSymbolicLink())throw Error('Remote root contains symlink');}
|
||||||
|
if(!fs.statSync(root).isDirectory())throw Error('Prepare remote root');
|
||||||
|
fs.accessSync(root,fs.constants.W_OK);fs.accessSync(path.join(root,'.env'),fs.constants.R_OK);
|
||||||
|
for(const [cmd,args]of [['rsync',['--version']],['tar',['--version']],['zstd',['--version']],['docker',['info']]])cp.execFileSync(cmd,args,{stdio:'pipe'});
|
||||||
|
fs.mkdirSync(path.join(root,'.mcpack'),{recursive:true,mode:0o700});
|
||||||
|
if(fs.lstatSync(path.join(root,'.mcpack')).isSymbolicLink())throw Error('Unsafe agent directory');
|
||||||
|
`;
|
||||||
|
const verifyAgent=`
|
||||||
|
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
||||||
|
const [dir,expected]=process.argv.slice(1),hash=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
if(hash(path.join(dir,'agent-files.json'))!==expected)throw Error('Agent checksum manifest mismatch');
|
||||||
|
const entries=JSON.parse(fs.readFileSync(path.join(dir,'agent-files.json'))),actual=[];
|
||||||
|
const walk=(base,rel='')=>{for(const entry of fs.readdirSync(base,{withFileTypes:true})){const file=path.join(base,entry.name),name=rel?rel+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())actual.push(name);else throw Error('Unsafe agent file');}};
|
||||||
|
walk(dir);
|
||||||
|
if(JSON.stringify(actual.filter(n=>n!=='agent-files.json').sort())!==JSON.stringify(entries.map(e=>e.path).sort()))throw Error('Agent coverage mismatch');
|
||||||
|
for(const entry of entries){if(entry.path.split('/').some(p=>p==='..'||!p)||entry.path.startsWith('/'))throw Error('Unsafe agent path');if(hash(path.join(dir,entry.path))!==entry.sha256)throw Error('Agent file mismatch');}
|
||||||
|
fs.chmodSync(path.join(dir,'.tools/docker-compose'),0o755);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function deploy({root:repository,configFile='platform/config.yaml',artifact,sshConfig,remoteNode='node'}){
|
||||||
|
if (remoteNode !== 'node' && (!path.posix.isAbsolute(remoteNode) || /[\\\r\n\0]/.test(remoteNode))) throw new Error('remote-node must be node or an absolute executable path');
|
||||||
|
const connectionArgs = sshConfig ? ['-F', path.resolve(repository, sshConfig), ...sshArgs] : sshArgs;
|
||||||
|
const nodeCommand = shellQuote(remoteNode);
|
||||||
|
const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8'));
|
||||||
|
if(document.errors.length)throw new Error('Invalid deploy YAML');
|
||||||
|
const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory);
|
||||||
|
let releasePath=artifact?path.resolve(repository,artifact):null;
|
||||||
|
if(!releasePath){
|
||||||
|
// Reuse the ready immutable artifact when present; build only when absent.
|
||||||
|
const {parse}=await import('smol-toml');
|
||||||
|
const pack=parse(fs.readFileSync(path.resolve(repository,config.pack.directory,'pack.toml'),'utf8'));
|
||||||
|
const expected=path.resolve(repository,config.build.output_directory,`${pack.name}-${pack.version}`);
|
||||||
|
releasePath=fs.existsSync(expected)?expected:build({root:repository,configFile,releaseOnly:true});
|
||||||
|
}
|
||||||
|
const release=deployableRelease(releasePath),name=releaseName(release.manifest);
|
||||||
|
const bundle=agentBundle(repository,config);
|
||||||
|
let agent;
|
||||||
|
try{
|
||||||
|
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]);
|
||||||
|
agent=directory+'/.mcpack/agent-'+crypto.randomUUID();
|
||||||
|
command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]);
|
||||||
|
upload(bundle.directory,host,agent,connectionArgs);
|
||||||
|
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]);
|
||||||
|
const releaseBytes=[...release.manifest.release_files.map(file=>file.path),'manifest.json','SHA256SUMS'].reduce((total,file)=>total+fs.statSync(path.join(releasePath,file)).size,0);
|
||||||
|
const request={root:directory,targetName:name,targetIdentity:release.identity,targetChecksums:sha256(path.join(releasePath,'SHA256SUMS')),releaseBytes,timeout:config.deploy.healthcheck_timeout};
|
||||||
|
const child=spawn('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{stdio:['pipe','pipe','inherit']});
|
||||||
|
child.stdin.on('error',()=>{});
|
||||||
|
const completion=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));});
|
||||||
|
child.stdin.write(JSON.stringify(request)+'\n');
|
||||||
|
let completed,remoteError;
|
||||||
|
for await(const line of createInterface({input:child.stdout,terminal:false})){
|
||||||
|
if(!line.startsWith('MCPACK:')){console.log(line);continue;}
|
||||||
|
const message=JSON.parse(line.slice(7));
|
||||||
|
if(message.type==='ready'){
|
||||||
|
if(typeof message.stage!=='string'||path.posix.dirname(message.stage)!==directory+'/releases'||!/^\.incoming-[A-Za-z0-9]+$/.test(path.posix.basename(message.stage))){child.stdin.end();throw new Error('Remote requested unsafe upload path');}
|
||||||
|
try{upload(releasePath,host,message.stage,connectionArgs);child.stdin.write('{"type":"uploaded"}\n');}
|
||||||
|
catch(error){remoteError=error.message;child.stdin.end('{"type":"abort"}\n');}
|
||||||
|
}else if(message.type==='complete'){completed=message.state;child.stdin.end();}
|
||||||
|
else if(message.type==='error'){remoteError=message.message;child.stdin.end();}
|
||||||
|
}
|
||||||
|
const exit=await completion;
|
||||||
|
if(remoteError||!completed||exit.code!==0)throw new Error(remoteError||`SSH interrupted (exit ${exit.code}, signal ${exit.signal}); inspect ${directory}/deployment-state.json before retry`);
|
||||||
|
console.log(`Deployment ${completed.noop?'already healthy':'complete'}: ${host}:${directory}/releases/${name}`);
|
||||||
|
return completed;
|
||||||
|
}finally{
|
||||||
|
// Keep remote agent for recovery. No secrets or host env are uploaded.
|
||||||
|
fs.rmSync(bundle.directory,{recursive:true,force:true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollback({root:repository,command:operation='rollback',configFile='platform/config.yaml',sshConfig,remoteNode='node',backupId,version,confirmCompatible=false,acceptProgressLoss=false}){
|
||||||
|
if(remoteNode!=='node'&&(!path.posix.isAbsolute(remoteNode)||/[\\\r\n\0]/.test(remoteNode)))throw new Error('Invalid remote Node path');
|
||||||
|
const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8'));
|
||||||
|
if(document.errors.length)throw new Error('Invalid rollback config');
|
||||||
|
const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory);
|
||||||
|
const connectionArgs=sshConfig?['-F',path.resolve(repository,sshConfig),...sshArgs]:sshArgs,nodeCommand=shellQuote(remoteNode);
|
||||||
|
const request={root:directory,operation:'rollback',mode:operation==='rollback-pack'?'pack':'snapshot',backupId,version,confirmCompatible,acceptProgressLoss,timeout:config.deploy.healthcheck_timeout};
|
||||||
|
const apply=request.mode==='pack'?confirmCompatible:Boolean(backupId&&acceptProgressLoss);
|
||||||
|
if(!apply){
|
||||||
|
const script=`const fs=require('fs'),path=require('path');console.log(JSON.stringify((${inspectRollback.toString()})(process.argv[1],JSON.parse(process.argv[2]),fs,path),null,2));`;
|
||||||
|
const output=command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(script)} ${shellQuote(directory)} ${shellQuote(JSON.stringify({backupId,version}))}`]);
|
||||||
|
console.log(output.trim());
|
||||||
|
console.log(request.mode==='pack'?'Plan only. Applying requires --confirm-compatible; SemVer does not prove world compatibility.':'Plan only. Applying a snapshot requires --backup ID --accept-progress-loss; all runtime progress since its timestamp will be replaced.');
|
||||||
|
return JSON.parse(output);
|
||||||
|
}
|
||||||
|
if(request.mode==='snapshot')console.log(`Snapshot ${backupId}: replacing runtime will lose progress after its timestamp. Current state will be backed up first.`);
|
||||||
|
else console.log(`Pack-only rollback to ${version}: world compatibility explicitly confirmed; current runtime will not be restored.`);
|
||||||
|
const bundle=agentBundle(repository,config);
|
||||||
|
try{
|
||||||
|
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]);
|
||||||
|
const agent=directory+'/.mcpack/agent-'+crypto.randomUUID();
|
||||||
|
command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]);
|
||||||
|
upload(bundle.directory,host,agent,connectionArgs);
|
||||||
|
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]);
|
||||||
|
const output=command('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{input:JSON.stringify(request)+'\n'});
|
||||||
|
let result,error;
|
||||||
|
for(const line of output.split('\n')){
|
||||||
|
if(!line.startsWith('MCPACK:')){if(line)console.log(line);continue;}
|
||||||
|
const message=JSON.parse(line.slice(7));if(message.type==='complete')result=message.state;else if(message.type==='error')error=message.message;
|
||||||
|
}
|
||||||
|
if(error||!result)throw new Error(error||'Rollback SSH interrupted; inspect rollback-state.json before recovery');
|
||||||
|
console.log(`Rollback complete: ${result.target_release}. Current-state backup: ${result.backup_snapshot}`);
|
||||||
|
return result;
|
||||||
|
}finally{fs.rmSync(bundle.directory,{recursive:true,force:true});}
|
||||||
|
}
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Snapshot current source into an isolated clean Git checkout; never commit root.
|
||||||
|
set -euo pipefail
|
||||||
|
repository=$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$repository"
|
||||||
|
check=$(mktemp -d "$repository/.tools/clean-release.XXXXXX")
|
||||||
|
printf '%s\n' "$check" > .tools/latest-clean-release-path
|
||||||
|
mkdir "$check/source"
|
||||||
|
git ls-files --cached --others --exclude-standard -z > "$check/files.list"
|
||||||
|
tar --null -T "$check/files.list" -cf "$check/source.tar"
|
||||||
|
tar --no-same-owner -xf "$check/source.tar" -C "$check/source"
|
||||||
|
git -C "$check/source" init -q
|
||||||
|
git -C "$check/source" add -A
|
||||||
|
git -C "$check/source" -c user.name='Local release snapshot' -c user.email='release@localhost' commit -qm 'Local source snapshot for clean release'
|
||||||
|
git clone -q "$check/source" "$check/clone"
|
||||||
|
mkdir -p "$check/clone/node_modules" "$check/clone/.tools/packwiz"
|
||||||
|
cp -a "$repository/node_modules/yaml" "$repository/node_modules/smol-toml" "$check/clone/node_modules/"
|
||||||
|
cp "$repository/.tools/packwiz/packwiz" "$check/clone/.tools/packwiz/packwiz"
|
||||||
|
cp "$repository/.tools/docker-compose" "$check/clone/.tools/docker-compose"
|
||||||
|
cd "$check/clone"
|
||||||
|
./mcpack build --release
|
||||||
|
node --input-type=module - "$repository" <<'JS'
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {parse} from 'smol-toml';
|
||||||
|
import {verifyRelease} from './platform/runtime.mjs';
|
||||||
|
const repository=process.argv[2],pack=parse(fs.readFileSync('pack/pack.toml','utf8'));
|
||||||
|
const name=pack.name+'-'+pack.version,source=path.resolve('dist',name);
|
||||||
|
const release=verifyRelease(source),destination=path.join(repository,'dist',name);
|
||||||
|
if(fs.existsSync(destination))throw new Error('Immutable release already exists: '+destination);
|
||||||
|
const stage=fs.mkdtempSync(path.join(repository,'dist','.clean-release-'));
|
||||||
|
try{
|
||||||
|
fs.cpSync(source,stage,{recursive:true});
|
||||||
|
if(verifyRelease(stage).identity!==release.identity)throw new Error('Copied release identity differs');
|
||||||
|
fs.renameSync(stage,destination);
|
||||||
|
}catch(error){fs.rmSync(stage,{recursive:true,force:true});throw error;}
|
||||||
|
console.log('Published clean release: '+destination);
|
||||||
|
JS
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { deployOnHost } from '../platform/deployment.mjs';
|
||||||
|
import { rollbackOnHost } from '../platform/rollback.mjs';
|
||||||
|
const input=createInterface({input:process.stdin,terminal:false});
|
||||||
|
const messages=input[Symbol.asyncIterator]();
|
||||||
|
const send=value=>process.stdout.write('MCPACK:'+JSON.stringify(value)+'\n');
|
||||||
|
try{
|
||||||
|
const first=await messages.next();
|
||||||
|
if(first.done)throw new Error('Missing deploy request');
|
||||||
|
const request=JSON.parse(first.value);
|
||||||
|
const repository=fileURLToPath(new URL('../',import.meta.url));
|
||||||
|
const state=request.operation==='rollback' ? rollbackOnHost({...request,repository}) : await deployOnHost({...request,repository,receive:async stage=>{
|
||||||
|
send({type:'ready',stage});
|
||||||
|
const next=await messages.next();
|
||||||
|
if(next.done)throw new Error('SSH upload/session interrupted');
|
||||||
|
const message=JSON.parse(next.value);
|
||||||
|
if(message.type!=='uploaded')throw new Error('Release transfer failed or aborted');
|
||||||
|
}});
|
||||||
|
send({type:'complete',state});
|
||||||
|
}catch(error){send({type:'error',message:error.message});process.exitCode=1;}
|
||||||
|
finally{input.close();}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { build } from '../platform/build.mjs';
|
||||||
|
import { release } from '../platform/release.mjs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseArgs, manageMod, usage } from '../platform/commands.mjs';
|
||||||
|
import { deploy, rollback } from '../platform/ssh-deploy.mjs';
|
||||||
|
import { backup, restoreSnapshot } from '../platform/backups.mjs';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0 || args.includes('--help')) {
|
||||||
|
console.log(usage);
|
||||||
|
process.exit(args.length === 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const options = parseArgs(args);
|
||||||
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
if (options.command === 'build') console.log(`Release: ${build({ root, configFile: options.configFile, releaseOnly: options.releaseOnly })}`);
|
||||||
|
else if(options.command==='release')console.log(JSON.stringify(release({root,version:options.version,configFile:options.configFile}),null,2));
|
||||||
|
else if (options.command === 'deploy') await deploy({ root, configFile: options.configFile, artifact: options.artifact, sshConfig: options.sshConfig, remoteNode: options.remoteNode });
|
||||||
|
else if (options.command === 'rollback' || options.command === 'rollback-pack') await rollback({ root, ...options });
|
||||||
|
else if (options.command === 'backup') console.log(`Backup: ${backup({ repository: root, targetRelease: options.targetRelease, waitTimeout: options.waitTimeout })}`);
|
||||||
|
else if (options.command === 'restore') console.log(`Restored: ${restoreSnapshot({ backupDirectory: options.backupDirectory, destination: options.destination })}`);
|
||||||
|
else process.exitCode = manageMod({ root, ...options });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`mcpack: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Small Minecraft status-protocol query, used to verify real network access.
|
||||||
|
import net from 'node:net';
|
||||||
|
const host=process.argv[2],port=Number(process.argv[3]??25565);
|
||||||
|
if(!host||!Number.isInteger(port)||port<1||port>65535){console.error('Usage: node scripts/query-minecraft.mjs HOST [PORT]');process.exit(1);}
|
||||||
|
const encode=value=>{const bytes=[];let number=value>>>0;do{let byte=number&127;number>>>=7;if(number)byte|=128;bytes.push(byte);}while(number);return Buffer.from(bytes);};
|
||||||
|
const read=(buffer,offset=0)=>{let value=0,shift=0;for(let i=offset;i<buffer.length&&i<offset+5;i++){const byte=buffer[i];value|=(byte&127)<<shift;if(!(byte&128))return{value:value>>>0,end:i+1};shift+=7;}return null;};
|
||||||
|
const address=Buffer.from(host),portBytes=Buffer.alloc(2);portBytes.writeUInt16BE(port);
|
||||||
|
const handshake=Buffer.concat([encode(0),encode(-1),encode(address.length),address,portBytes,encode(1)]);
|
||||||
|
const socket=net.createConnection({host,port});let data=Buffer.alloc(0),complete=false;
|
||||||
|
socket.setTimeout(10000,()=>socket.destroy(new Error('Minecraft status timeout')));
|
||||||
|
socket.on('connect',()=>socket.write(Buffer.concat([encode(handshake.length),handshake,Buffer.from([1,0])])));
|
||||||
|
socket.on('data',chunk=>{
|
||||||
|
data=Buffer.concat([data,chunk]);
|
||||||
|
if(data.length>1024*1024){socket.destroy(new Error('Oversized status packet'));return;}
|
||||||
|
const length=read(data);if(!length||data.length<length.end+length.value)return;
|
||||||
|
try{
|
||||||
|
const packet=read(data,length.end);if(!packet||packet.value!==0)throw new Error('Unexpected status packet');
|
||||||
|
const text=read(data,packet.end);if(!text||text.end+text.value!==length.end+length.value)throw new Error('Invalid status JSON length');
|
||||||
|
const status=JSON.parse(data.subarray(text.end,text.end+text.value).toString('utf8'));
|
||||||
|
if(!status.version?.name||!status.players)throw new Error('Invalid Minecraft status response');
|
||||||
|
complete=true;console.log(JSON.stringify({host,port,version:status.version,players:status.players,description:status.description},null,2));socket.end();
|
||||||
|
}catch(error){socket.destroy(error);}
|
||||||
|
});
|
||||||
|
socket.on('error',error=>{console.error(error.message);process.exitCode=1;});
|
||||||
|
socket.on('close',()=>{if(!complete&&!process.exitCode){console.error('Connection closed before Minecraft status');process.exitCode=1;}});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { runtime } from '../platform/runtime.mjs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.exitCode = runtime({ repository: fileURLToPath(new URL('../', import.meta.url)), args: process.argv.slice(2) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`server: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local lifecycle wrapper for a complete immutable release.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
exec node "$task_root/scripts/server.mjs" "$@"
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integration spike only: no release CLI or deployment state machine.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
task_source=${1:-"$task_root/pack"}
|
||||||
|
task_source=$(cd -- "$task_source" && pwd)
|
||||||
|
source "$task_root/toolchain.lock.env"
|
||||||
|
printf '%s %s\n' "$PACKWIZ_BINARY_SHA256" "$task_root/.tools/packwiz/packwiz" | sha256sum -c -
|
||||||
|
task_name=$(sed -n 's/^name = "\([a-zA-Z0-9._-]*\)"$/\1/p' "$task_source/pack.toml")
|
||||||
|
task_version=$(sed -n 's/^version = "\([a-zA-Z0-9._-]*\)"$/\1/p' "$task_source/pack.toml")
|
||||||
|
[[ -n "$task_name" && -n "$task_version" ]] || { echo 'Invalid pack name/version.' >&2; exit 1; }
|
||||||
|
task_release="$task_root/dist/$task_name-$task_version"
|
||||||
|
[[ ! -e "$task_release" ]] || { echo "Release already exists: $task_release; use a new pack version." >&2; exit 1; }
|
||||||
|
mkdir -p "$task_root/dist"
|
||||||
|
task_tmp=$(mktemp -d "$task_root/.tools/spike-build.XXXXXX")
|
||||||
|
trap 'rm -rf -- "$task_tmp"' EXIT
|
||||||
|
cp -a "$task_source" "$task_tmp/source"
|
||||||
|
mkdir "$task_tmp/release"
|
||||||
|
cd "$task_tmp/source"
|
||||||
|
task_packwiz=("$task_root/.tools/packwiz/packwiz" --cache "$task_root/.tools/packwiz/cache" --config "$task_root/.tools/packwiz/config.toml")
|
||||||
|
"${task_packwiz[@]}" refresh
|
||||||
|
for task_file in index.toml pack.toml; do
|
||||||
|
task_original=$(sha256sum "$task_source/$task_file")
|
||||||
|
task_refreshed=$(sha256sum "$task_file")
|
||||||
|
[[ ${task_original%% *} == "${task_refreshed%% *}" ]] || {
|
||||||
|
echo "Stale source $task_file: run packwiz refresh explicitly before building." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
"${task_packwiz[@]}" modrinth export --output "$task_tmp/release/$task_name-$task_version.mrpack"
|
||||||
|
cd "$task_tmp/release"
|
||||||
|
sha256sum "$task_name-$task_version.mrpack" > SHA256SUMS
|
||||||
|
mv -- "$task_tmp/release" "$task_release"
|
||||||
|
printf 'Artifact: %s/%s-%s.mrpack\n' "$task_release" "$task_name" "$task_version"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Disposable integration fixtures; no runtime changes or deployment.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
task_dir="$task_root/.tools/repro-neoforge-variants"
|
||||||
|
mkdir "$task_dir"
|
||||||
|
task_packwiz=("$task_root/.tools/packwiz/packwiz" --cache "$task_root/.tools/packwiz/cache" --config "$task_root/.tools/packwiz/config.toml")
|
||||||
|
cp -a "$task_root/pack" "$task_dir/update-pack"
|
||||||
|
cd "$task_dir/update-pack"
|
||||||
|
sed -i 's/^version = ".*"$/version = "0.0.2"/' pack.toml
|
||||||
|
printf 'neoforge-pack-v2\n' > config/spike-marker.txt
|
||||||
|
mkdir -p defaultconfigs world/datapacks
|
||||||
|
cp -a "$task_root/testdata/spike-datapack" world/datapacks/phase0
|
||||||
|
printf 'removeErroringEntities = true\n' > defaultconfigs/neoforge-server.toml
|
||||||
|
"${task_packwiz[@]}" modrinth add --project-id TyCTlI4b --version-id pJxNzk4X -y
|
||||||
|
"${task_packwiz[@]}" refresh
|
||||||
|
bash "$task_root/scripts/spike-build.sh" "$PWD"
|
||||||
|
cp -a "$task_root/pack" "$task_dir/loader-only-pack"
|
||||||
|
cd "$task_dir/loader-only-pack"
|
||||||
|
sed -i 's/^version = ".*"$/version = "0.0.3"/' pack.toml
|
||||||
|
printf 'neoforge-loader-only-v3\n' > config/spike-marker.txt
|
||||||
|
"${task_packwiz[@]}" remove sophisticated-backpacks -y
|
||||||
|
"${task_packwiz[@]}" remove sophisticated-core -y
|
||||||
|
"${task_packwiz[@]}" remove mouse-tweaks -y
|
||||||
|
"${task_packwiz[@]}" refresh
|
||||||
|
bash "$task_root/scripts/spike-build.sh" "$PWD"
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Thin local Compose wrapper for Phase 0. No automatic backup/deploy/rollback.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
set -a
|
||||||
|
source "$task_root/toolchain.lock.env"
|
||||||
|
set +a
|
||||||
|
export MC_ROOT="$task_root"
|
||||||
|
export MC_PACK=${MC_PACK:-minecraft-lab-neoforge-spike-0.0.1/minecraft-lab-neoforge-spike-0.0.1.mrpack}
|
||||||
|
task_env="$task_root/server/.env"
|
||||||
|
[[ -f "$task_env" ]] || task_env="$task_root/server/.env.example"
|
||||||
|
exec "$task_root/.tools/docker-compose" --project-name minecraft-lab-spike --env-file "$task_env" --file "$task_root/server/compose.yaml" "$@"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
source "$task_root/toolchain.lock.env"
|
||||||
|
[[ $(uname -sm) == 'Linux x86_64' ]] || { echo 'Phase 0 tools require Linux x86_64.' >&2; exit 1; }
|
||||||
|
for task_tool in curl bsdtar sha256sum; do command -v "$task_tool" >/dev/null; done
|
||||||
|
mkdir -p "$task_root/.tools/packwiz"
|
||||||
|
if [[ ! -f "$task_root/.tools/packwiz/packwiz" ]]; then
|
||||||
|
curl -fsSL --retry 2 "$PACKWIZ_URL" -o "$task_root/.tools/packwiz/packwiz.zip"
|
||||||
|
printf '%s %s\n' "$PACKWIZ_ARCHIVE_SHA256" "$task_root/.tools/packwiz/packwiz.zip" | sha256sum -c -
|
||||||
|
bsdtar -xf "$task_root/.tools/packwiz/packwiz.zip" -C "$task_root/.tools/packwiz"
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$task_root/.tools/docker-compose" ]]; then
|
||||||
|
curl -fsSL --retry 2 "$COMPOSE_URL" -o "$task_root/.tools/docker-compose"
|
||||||
|
fi
|
||||||
|
printf '%s %s\n' "$PACKWIZ_BINARY_SHA256" "$task_root/.tools/packwiz/packwiz" | sha256sum -c -
|
||||||
|
printf '%s %s\n' "$COMPOSE_SHA256" "$task_root/.tools/docker-compose" | sha256sum -c -
|
||||||
|
chmod +x "$task_root/.tools/packwiz/packwiz" "$task_root/.tools/docker-compose"
|
||||||
|
"$task_root/.tools/docker-compose" version
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Real Modrinth integration in a disposable Git clone. Never changes runtime.
|
||||||
|
set -euo pipefail
|
||||||
|
task_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$task_root"
|
||||||
|
task_check=$(mktemp -d "$task_root/.tools/phase3-check.XXXXXX")
|
||||||
|
printf '%s\n' "$task_check" > .tools/phase3-check-path
|
||||||
|
mkdir "$task_check/source"
|
||||||
|
git ls-files --cached --others --exclude-standard -z > "$task_check/files.list"
|
||||||
|
tar --null -T "$task_check/files.list" -cf "$task_check/source.tar"
|
||||||
|
tar --no-same-owner -xf "$task_check/source.tar" -C "$task_check/source"
|
||||||
|
git -C "$task_check/source" init -q
|
||||||
|
git -C "$task_check/source" add -A
|
||||||
|
git -C "$task_check/source" -c user.name='Phase 3 verification' -c user.email='phase3@localhost' commit -qm 'Temporary integration snapshot'
|
||||||
|
git clone -q "$task_check/source" "$task_check/clone"
|
||||||
|
cd "$task_check/clone"
|
||||||
|
ln -s "$task_root/node_modules" node_modules
|
||||||
|
mkdir -p .tools/packwiz
|
||||||
|
cp "$task_root/.tools/packwiz/packwiz" .tools/packwiz/packwiz
|
||||||
|
cp "$task_root/.tools/docker-compose" .tools/docker-compose
|
||||||
|
|
||||||
|
task_version() {
|
||||||
|
sed -i "s/^version = \".*\"$/version = \"$1\"/" pack/pack.toml
|
||||||
|
(cd pack && ../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
./mcpack remove sophisticated-backpacks --yes
|
||||||
|
test ! -e pack/mods/sophisticated-backpacks.pw.toml
|
||||||
|
task_version phase3-remove
|
||||||
|
./mcpack build
|
||||||
|
|
||||||
|
./mcpack add https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X --yes
|
||||||
|
rg -q 'version = "pJxNzk4X"' pack/mods/sophisticated-backpacks.pw.toml
|
||||||
|
task_version phase3-add
|
||||||
|
./mcpack build
|
||||||
|
|
||||||
|
./mcpack update sophisticated-backpacks --yes
|
||||||
|
if rg -q 'version = "pJxNzk4X"' pack/mods/sophisticated-backpacks.pw.toml; then
|
||||||
|
echo 'Expected update away from older Backpacks version' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
task_version phase3-update
|
||||||
|
./mcpack build
|
||||||
|
|
||||||
|
# Seed the older version again so --all proves an actual update, not just no-op.
|
||||||
|
./mcpack add https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X --yes
|
||||||
|
rg -q 'version = "pJxNzk4X"' pack/mods/sophisticated-backpacks.pw.toml
|
||||||
|
./mcpack update --all --yes
|
||||||
|
if rg -q 'version = "pJxNzk4X"' pack/mods/sophisticated-backpacks.pw.toml; then
|
||||||
|
echo 'Expected update --all to replace older Backpacks version' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
task_version phase3-all
|
||||||
|
./mcpack build
|
||||||
|
git diff -- pack > "$task_check/source-changes.diff"
|
||||||
|
printf 'PASS: remove, add pinned version, update one, update all; four full builds.\nClone: %s\n' "$task_check/clone"
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Dedicated local integration fixture; never uses the primary Phase 0 runtime.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
export MC_ROOT="$PWD/.tools/phase4-server"
|
||||||
|
export MC_RELEASE_DIR="$PWD/dist/minecraft-lab-neoforge-spike-0.0.4"
|
||||||
|
evidence="$PWD/.tools/phase5-evidence"
|
||||||
|
restored="$PWD/.tools/phase5-restored-server"
|
||||||
|
mkdir -p "$evidence"
|
||||||
|
if [[ -e "$restored" ]]; then
|
||||||
|
echo "Restore fixture already exists; choose a new destination before repeating." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
mkdir -p "$MC_ROOT/runtime/mod-state"
|
||||||
|
printf '{"phase":5,"progress":42}\n' > "$MC_ROOT/runtime/mod-state/phase5.json"
|
||||||
|
printf 'preserve-unmanaged-phase5\n' > "$MC_ROOT/runtime/config/phase5-unmanaged.txt"
|
||||||
|
./mcpack backup > "$evidence/backup.log" 2>&1
|
||||||
|
node --input-type=module <<'JS' > "$evidence/snapshot-path.txt"
|
||||||
|
import fs from 'node:fs';
|
||||||
|
const operation=JSON.parse(fs.readFileSync(process.env.MC_ROOT+'/.mcpack-backup-operation.json'));
|
||||||
|
if(operation.phase!=='complete'||!operation.snapshot)throw new Error('Backup did not complete');
|
||||||
|
console.log(operation.snapshot);
|
||||||
|
JS
|
||||||
|
snapshot=$(cat "$evidence/snapshot-path.txt")
|
||||||
|
scripts/server.sh exec -T minecraft mc-health > "$evidence/resumed-health.txt"
|
||||||
|
scripts/server.sh logs --tail 150 > "$evidence/resumed-start.log"
|
||||||
|
mkdir -p "$evidence/failure-bin"
|
||||||
|
cat > "$evidence/failure-bin/tar" <<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
echo 'intentional Phase 5 archive failure' >&2
|
||||||
|
exit 42
|
||||||
|
SH
|
||||||
|
chmod +x "$evidence/failure-bin/tar"
|
||||||
|
if PATH="$evidence/failure-bin:$PATH" ./mcpack backup > "$evidence/failed-backup.log" 2>&1; then
|
||||||
|
echo 'Invalid archive unexpectedly succeeded' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node --input-type=module <<'JS' > "$evidence/failure-verification.txt"
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
const root=process.env.MC_ROOT;
|
||||||
|
const operation=JSON.parse(fs.readFileSync(root+'/.mcpack-backup-operation.json'));
|
||||||
|
if(operation.phase!=='failed'||!operation.stopped_confirmed||operation.snapshot!==null)throw new Error('Incorrect backup failure state');
|
||||||
|
const state=JSON.parse(execFileSync('docker',['inspect','minecraft-lab-phase4-minecraft-1']))[0].State;
|
||||||
|
if(state.Running)throw new Error('Failed backup unexpectedly restarted server');
|
||||||
|
const snapshots=fs.readdirSync(root+'/backups');
|
||||||
|
if(snapshots.length!==1||snapshots.some(name=>name.startsWith('.')))throw new Error('Incomplete snapshot published or left behind');
|
||||||
|
console.log('Failed backup: non-zero, server stopped, no additional snapshot, no staging directories.');
|
||||||
|
JS
|
||||||
|
scripts/server.sh down
|
||||||
|
./mcpack restore --backup "$snapshot" --destination "$restored" > "$evidence/restore.log" 2>&1
|
||||||
|
SNAPSHOT="$snapshot" RESTORED="$restored" node --input-type=module <<'JS' > "$evidence/restore-verification.txt"
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {verifySnapshot,runtimeInventory} from './platform/backups.mjs';
|
||||||
|
import {verifyRelease} from './platform/runtime.mjs';
|
||||||
|
const metadata=verifySnapshot(process.env.SNAPSHOT);
|
||||||
|
assert.deepEqual(runtimeInventory(process.env.RESTORED+'/runtime'),metadata.runtime_inventory);
|
||||||
|
assert.equal(verifyRelease(process.env.RESTORED+'/release').identity,metadata.source_release.manifest_sha256);
|
||||||
|
assert.equal(fs.readFileSync(process.env.RESTORED+'/runtime/mod-state/phase5.json','utf8'),'{"phase":5,"progress":42}\n');
|
||||||
|
assert.equal(fs.readFileSync(process.env.RESTORED+'/runtime/config/phase5-unmanaged.txt','utf8'),'preserve-unmanaged-phase5\n');
|
||||||
|
assert(!fs.existsSync(process.env.RESTORED+'/.env'));
|
||||||
|
console.log(`Verified ${metadata.runtime_inventory.length} runtime entries including bytes/modes/UID/GID; retained release ${metadata.source_release.name}.`);
|
||||||
|
console.log('Mod data outside world, unmanaged config, server.properties, operators and whitelist preserved; host settings not copied.');
|
||||||
|
JS
|
||||||
|
export MC_ENV_FILE="$MC_ROOT/.env"
|
||||||
|
export MC_ROOT="$restored"
|
||||||
|
export MC_RELEASE_DIR="$restored/release"
|
||||||
|
scripts/server.sh up -d --wait --wait-timeout 120
|
||||||
|
scripts/server.sh exec -T minecraft mc-health > "$evidence/restored-health.txt"
|
||||||
|
scripts/server.sh logs --tail 150 > "$evidence/restored-start.log"
|
||||||
|
scripts/server.sh down
|
||||||
|
scripts/spike-server.sh ps > "$evidence/primary-server-status.txt"
|
||||||
|
printf 'Phase 5 backup, failure and restored server health checks passed.\n'
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Disposable runtime restored from Phase 5; primary server is never selected.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
evidence="$PWD/.tools/phase6-evidence"
|
||||||
|
root="$PWD/.tools/phase6-server"
|
||||||
|
loop_root="$PWD/.tools/phase6-loop-server"
|
||||||
|
mkdir -p "$evidence"
|
||||||
|
[[ ! -e "$root" && ! -e "$loop_root" ]] || { echo 'Phase 6 fixtures already exist; use new roots to repeat.' >&2; exit 1; }
|
||||||
|
snapshot=$(cat .tools/phase5-evidence/snapshot-path.txt)
|
||||||
|
./mcpack restore --backup "$snapshot" --destination "$root"
|
||||||
|
export MC_ROOT="$root" MC_RELEASE_DIR="$root/release"
|
||||||
|
cp .tools/phase4-server/.env "$root/.env"
|
||||||
|
chmod 600 "$root/.env"
|
||||||
|
# A real cold JVM start cannot finish within two seconds.
|
||||||
|
if scripts/server.sh up --wait --wait-timeout 2 --stable-seconds 1 > "$evidence/timeout.txt" 2>&1; then
|
||||||
|
echo 'Short startup timeout unexpectedly passed' >&2; exit 1
|
||||||
|
fi
|
||||||
|
scripts/server.sh health --wait-timeout 120 --stable-seconds 3 > "$evidence/healthy.txt" 2>&1
|
||||||
|
scripts/server.sh logs --tail 100 > "$evidence/healthy-start.log"
|
||||||
|
# Abrupt termination only affects this disposable copied world.
|
||||||
|
docker kill --signal KILL minecraft-lab-phase4-minecraft-1 > "$evidence/kill.txt"
|
||||||
|
if scripts/server.sh health --wait-timeout 30 --stable-seconds 2 > "$evidence/crash.txt" 2>&1; then
|
||||||
|
echo 'Crashed container unexpectedly healthy' >&2; exit 1
|
||||||
|
fi
|
||||||
|
scripts/server.sh up --wait --wait-timeout 120 --stable-seconds 3 > "$evidence/recovered.txt" 2>&1
|
||||||
|
scripts/server.sh down
|
||||||
|
# Deliberately broken Compose fixture, separate from immutable public release.
|
||||||
|
mkdir -p "$loop_root/release"
|
||||||
|
cp -a "$MC_RELEASE_DIR/." "$loop_root/release/"
|
||||||
|
cp "$root/.env" "$loop_root/.env"
|
||||||
|
LOOP_ROOT="$loop_root" node --input-type=module <<'JS'
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import {parse,stringify} from 'yaml';
|
||||||
|
const release=process.env.LOOP_ROOT+'/release';
|
||||||
|
const composePath=release+'/server/compose.yaml';
|
||||||
|
const compose=parse(fs.readFileSync(composePath,'utf8'));
|
||||||
|
compose.services.minecraft.restart='on-failure:5';
|
||||||
|
compose.services.minecraft.entrypoint=['/bin/sh','-c','echo phase6-intentional-restart-loop; sleep 1; exit 42'];
|
||||||
|
fs.writeFileSync(composePath,stringify(compose));
|
||||||
|
const hash=file=>crypto.createHash('sha256').update(fs.readFileSync(release+'/'+file)).digest('hex');
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(release+'/manifest.json'));
|
||||||
|
manifest.pack.version='phase6-failure-fixture';
|
||||||
|
for(const file of manifest.release_files)file.sha256=hash(file.path);
|
||||||
|
fs.writeFileSync(release+'/manifest.json',JSON.stringify(manifest,null,2)+'\n');
|
||||||
|
const files=[...manifest.release_files.map(file=>file.path),'manifest.json'].sort();
|
||||||
|
fs.writeFileSync(release+'/SHA256SUMS',files.map(file=>`${hash(file)} ${file}\n`).join(''));
|
||||||
|
JS
|
||||||
|
export MC_ROOT="$loop_root" MC_RELEASE_DIR="$loop_root/release"
|
||||||
|
scripts/server.sh up -d
|
||||||
|
# Wait until the real Docker container has restarted at least once.
|
||||||
|
for attempt in {1..20}; do
|
||||||
|
count=$(docker inspect minecraft-lab-phase4-minecraft-1 --format '{{.RestartCount}}')
|
||||||
|
if [[ "$count" -ge 1 ]]; then break; fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
docker inspect minecraft-lab-phase4-minecraft-1 --format '{{json .State}} {{.RestartCount}}' > "$evidence/loop-docker-state.txt"
|
||||||
|
if scripts/server.sh health --wait-timeout 15 --stable-seconds 2 > "$evidence/restart-loop.txt" 2>&1; then
|
||||||
|
echo 'Restart loop unexpectedly healthy' >&2; exit 1
|
||||||
|
fi
|
||||||
|
scripts/server.sh down
|
||||||
|
ROOT="$root" LOOP_ROOT="$loop_root" node --input-type=module <<'JS' > "$evidence/assertions.txt"
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
const reports=root=>fs.readdirSync(root+'/diagnostics').filter(name=>name.endsWith('.json')).map(name=>JSON.parse(fs.readFileSync(root+'/diagnostics/'+name)));
|
||||||
|
const normal=reports(process.env.ROOT),loop=reports(process.env.LOOP_ROOT);
|
||||||
|
assert(normal.some(report=>report.reason==='timeout'));
|
||||||
|
assert(normal.some(report=>report.reason==='crash'&&report.state.exit_code===137));
|
||||||
|
assert(loop.some(report=>report.reason==='restart-loop'));
|
||||||
|
for(const root of [process.env.ROOT,process.env.LOOP_ROOT]){
|
||||||
|
assert(!fs.existsSync(root+'/.mcpack-runtime.lock'));
|
||||||
|
assert(fs.existsSync(root+'/.mcpack-runtime.json'));
|
||||||
|
for(const report of reports(root)){
|
||||||
|
assert(fs.existsSync(report.logs));
|
||||||
|
assert.equal(report.recovery.automatic_rollback,false);
|
||||||
|
assert.equal(report.recovery.runtime_preserved,true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert(fs.existsSync(process.env.ROOT+'/runtime/mod-state/phase5.json'));
|
||||||
|
console.log('Real success, timeout, crash (exit 137), restart-loop, diagnostics and runtime/binding preservation passed.');
|
||||||
|
JS
|
||||||
|
scripts/spike-server.sh ps > "$evidence/primary-server-status.txt"
|
||||||
|
printf 'Phase 6 integration passed.\n'
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Real SSH/rsync + Docker on an isolated loopback endpoint and a disposable root.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
repository="$PWD"
|
||||||
|
node_binary=$(command -v node)
|
||||||
|
check=$(mktemp -d "$PWD/.tools/phase7-check.XXXXXX")
|
||||||
|
printf '%s\n' "$check" > .tools/phase7-check-path
|
||||||
|
mkdir -p "$check/evidence" "$check/ssh" "$check/bin" "$check/host" "$check/source"
|
||||||
|
git ls-files --cached --others --exclude-standard -z > "$check/files.list"
|
||||||
|
tar --null -T "$check/files.list" -cf "$check/source.tar"
|
||||||
|
tar --no-same-owner -xf "$check/source.tar" -C "$check/source"
|
||||||
|
git -C "$check/source" init -q
|
||||||
|
git -C "$check/source" add -A
|
||||||
|
git -C "$check/source" -c user.name='Phase 7 verification' -c user.email='phase7@localhost' commit -qm 'Temporary integration snapshot'
|
||||||
|
git clone -q "$check/source" "$check/clone"
|
||||||
|
clone="$check/clone"
|
||||||
|
mkdir "$clone/node_modules"
|
||||||
|
cp -a "$repository/node_modules/yaml" "$repository/node_modules/smol-toml" "$clone/node_modules/"
|
||||||
|
mkdir -p "$clone/.tools/packwiz"
|
||||||
|
cp "$repository/.tools/packwiz/packwiz" "$clone/.tools/packwiz/packwiz"
|
||||||
|
cp "$repository/.tools/docker-compose" "$clone/.tools/docker-compose"
|
||||||
|
(cd "$clone" && ./mcpack build --release) > "$check/evidence/build-first.log" 2>&1
|
||||||
|
sed -i 's/^version = ".*"$/version = "0.0.5"/' "$clone/pack/pack.toml"
|
||||||
|
printf 'server-override-phase7-v2\n' > "$clone/server/config/spike-marker.txt"
|
||||||
|
(cd "$clone/pack" && ../.tools/packwiz/packwiz refresh)
|
||||||
|
git -C "$clone" add pack server/config
|
||||||
|
git -C "$clone" -c user.name='Phase 7 verification' -c user.email='phase7@localhost' commit -qm 'Second test release'
|
||||||
|
(cd "$clone" && ./mcpack build --release) > "$check/evidence/build-second.log" 2>&1
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$check/ssh/host-key"
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$check/ssh/client-key"
|
||||||
|
cp "$check/ssh/client-key.pub" "$check/ssh/authorized_keys"
|
||||||
|
cat > "$check/ssh/sshd_config" <<CONFIG
|
||||||
|
Port 22227
|
||||||
|
ListenAddress 127.0.0.1
|
||||||
|
HostKey $check/ssh/host-key
|
||||||
|
PidFile $check/ssh/sshd.pid
|
||||||
|
AuthorizedKeysFile $check/ssh/authorized_keys
|
||||||
|
StrictModes no
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
UsePAM yes
|
||||||
|
AllowUsers root
|
||||||
|
CONFIG
|
||||||
|
# Pin the generated host key directly; no trust-on-first-use.
|
||||||
|
printf '[127.0.0.1]:22227 ' > "$check/ssh/known_hosts"
|
||||||
|
cut -d ' ' -f 1-2 "$check/ssh/host-key.pub" >> "$check/ssh/known_hosts"
|
||||||
|
cat > "$check/ssh/client_config" <<CONFIG
|
||||||
|
Host phase7-fixture
|
||||||
|
HostName 127.0.0.1
|
||||||
|
Port 22227
|
||||||
|
User root
|
||||||
|
IdentityFile $check/ssh/client-key
|
||||||
|
IdentitiesOnly yes
|
||||||
|
UserKnownHostsFile $check/ssh/known_hosts
|
||||||
|
CONFIG
|
||||||
|
cat > "$check/bin/ssh" <<SCRIPT
|
||||||
|
#!/bin/sh
|
||||||
|
exec /usr/bin/ssh -F '$check/ssh/client_config' "\$@"
|
||||||
|
SCRIPT
|
||||||
|
chmod +x "$check/bin/ssh"
|
||||||
|
export PATH="$check/bin:$PATH"
|
||||||
|
/usr/sbin/sshd -D -e -f "$check/ssh/sshd_config" > "$check/evidence/sshd.log" 2>&1 &
|
||||||
|
sshd_pid=$!
|
||||||
|
cleanup(){
|
||||||
|
kill "$sshd_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
sleep 0.3
|
||||||
|
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes phase7-fixture "'$node_binary' --version; docker info --format "{{.ServerVersion}}"" > "$check/evidence/preflight.txt"
|
||||||
|
cat > "$check/host/.env" <<CONFIG
|
||||||
|
MC_EULA=TRUE
|
||||||
|
MC_MEMORY=2G
|
||||||
|
MC_PORT=25566
|
||||||
|
MC_UID=1000
|
||||||
|
MC_GID=1000
|
||||||
|
MC_PROJECT_NAME=minecraft-lab-phase7
|
||||||
|
CONFIG
|
||||||
|
chmod 600 "$check/host/.env"
|
||||||
|
REPOSITORY="$clone" TARGET_ROOT="$check/host" node --input-type=module <<'JS'
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {parse,stringify} from 'yaml';
|
||||||
|
const config=parse(fs.readFileSync(process.env.REPOSITORY+'/platform/config.yaml','utf8'));
|
||||||
|
config.server.host='phase7-fixture';config.server.directory=process.env.TARGET_ROOT;
|
||||||
|
fs.writeFileSync(process.env.REPOSITORY+'/deploy-fixture.yaml',stringify(config));
|
||||||
|
JS
|
||||||
|
first="$clone/dist/minecraft-lab-neoforge-spike-0.0.4"
|
||||||
|
second="$clone/dist/minecraft-lab-neoforge-spike-0.0.5"
|
||||||
|
deploy(){ (cd "$clone" && ./mcpack deploy --config deploy-fixture.yaml --artifact "$1" --remote-node "$node_binary"); }
|
||||||
|
deploy "$first" > "$check/evidence/deploy-first.log" 2>&1
|
||||||
|
before=$(docker inspect minecraft-lab-phase7-minecraft-1 --format '{{.Id}} {{.State.StartedAt}}')
|
||||||
|
deploy "$first" > "$check/evidence/deploy-noop.log" 2>&1
|
||||||
|
after=$(docker inspect minecraft-lab-phase7-minecraft-1 --format '{{.Id}} {{.State.StartedAt}}')
|
||||||
|
[[ "$before" == "$after" ]] || { echo 'No-op restarted server' >&2; exit 1; }
|
||||||
|
printf '%s\n' "$before" > "$check/evidence/noop-container.txt"
|
||||||
|
mkdir -p "$check/host/runtime/mod-state"
|
||||||
|
printf 'phase7-persistent-progress\n' > "$check/host/runtime/mod-state/phase7.txt"
|
||||||
|
# Test lock before updating. A rejected parallel operation must not overwrite state.
|
||||||
|
mkdir "$check/host/.mcpack-runtime.lock"
|
||||||
|
if deploy "$second" > "$check/evidence/locked.log" 2>&1; then echo 'Parallel deploy unexpectedly passed' >&2; exit 1; fi
|
||||||
|
rmdir "$check/host/.mcpack-runtime.lock"
|
||||||
|
deploy "$second" > "$check/evidence/deploy-second.log" 2>&1
|
||||||
|
HOST_ROOT="$check/host" node --input-type=module <<'JS' > "$check/evidence/update-assertions.txt"
|
||||||
|
import fs from 'node:fs';import assert from 'node:assert/strict';
|
||||||
|
const root=process.env.HOST_ROOT,state=JSON.parse(fs.readFileSync(root+'/deployment-state.json'));
|
||||||
|
assert.equal(state.phase,'complete');assert(state.backup_snapshot);
|
||||||
|
assert.equal(fs.readFileSync(root+'/active-release','utf8').trim(),'minecraft-lab-neoforge-spike-0.0.5');
|
||||||
|
assert.equal(fs.readFileSync(root+'/previous-release','utf8').trim(),'minecraft-lab-neoforge-spike-0.0.4');
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/mod-state/phase7.txt','utf8'),'phase7-persistent-progress\n');
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/config/spike-marker.txt','utf8'),'server-override-phase7-v2\n');
|
||||||
|
assert(fs.existsSync(root+'/runtime/world/level.dat'));
|
||||||
|
assert(fs.existsSync(state.backup_snapshot+'/runtime.tar.zst'));
|
||||||
|
console.log('First deploy, no-op, lock, backup-gated update, pointers, world and mod-state preserved.');
|
||||||
|
JS
|
||||||
|
# A manual config edit must be detected before stop/replacement, even for same release.
|
||||||
|
printf 'manual-phase7-edit\n' > "$check/host/runtime/config/spike-marker.txt"
|
||||||
|
if deploy "$second" > "$check/evidence/manual-conflict.log" 2>&1; then echo 'Manual conflict unexpectedly passed' >&2; exit 1; fi
|
||||||
|
HOST_ROOT="$check/host" node --input-type=module <<'JS' > "$check/evidence/conflict-assertions.txt"
|
||||||
|
import fs from 'node:fs';import assert from 'node:assert/strict';
|
||||||
|
const root=process.env.HOST_ROOT,state=JSON.parse(fs.readFileSync(root+'/deployment-state.json'));
|
||||||
|
assert.equal(state.phase,'failed');assert.equal(state.applied,false);
|
||||||
|
assert.equal(state.conflicts[0].path,'config/spike-marker.txt');
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/config/spike-marker.txt','utf8'),'manual-phase7-edit\n');
|
||||||
|
console.log('Manual config conflict retained, rejected before applying/stopping.');
|
||||||
|
JS
|
||||||
|
MC_ROOT="$check/host" MC_RELEASE_DIR="$check/host/releases/minecraft-lab-neoforge-spike-0.0.5" scripts/server.sh down
|
||||||
|
scripts/spike-server.sh ps > "$check/evidence/primary-server-status.txt"
|
||||||
|
printf 'Phase 7 real SSH integration passed. Evidence: %s\n' "$check/evidence"
|
||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rollback an isolated copy over real SSH. Never selects the user's live root.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
repository="$PWD"
|
||||||
|
node_binary=$(command -v node)
|
||||||
|
check=$(mktemp -d "$PWD/.tools/phase8-check.XXXXXX")
|
||||||
|
printf '%s\n' "$check" > .tools/phase8-check-path
|
||||||
|
mkdir -p "$check/evidence" "$check/ssh" "$check/bin"
|
||||||
|
snapshot="$repository/.tools/phase7-check.6dbBak/host/backups/20261003T153844610Z-732a1d62-9bee-4a9c-a095-68bde6c8406f"
|
||||||
|
./mcpack restore --backup "$snapshot" --destination "$check/host" > "$check/evidence/initial-restore.log"
|
||||||
|
HOST_ROOT="$check/host" node --input-type=module <<'JS'
|
||||||
|
import fs from 'node:fs';import {releaseName,deployableRelease} from './platform/deployment.mjs';
|
||||||
|
const root=process.env.HOST_ROOT,release=deployableRelease(root+'/release'),name=releaseName(release.manifest);
|
||||||
|
fs.mkdirSync(root+'/releases');fs.renameSync(root+'/release',root+'/releases/'+name);
|
||||||
|
fs.writeFileSync(root+'/active-release',name+'\n');
|
||||||
|
fs.writeFileSync(root+'/deployment-state.json',JSON.stringify({schema_version:1,id:'restored-phase8-fixture',phase:'complete',target_release:name}));
|
||||||
|
fs.writeFileSync(root+'/.env','MC_EULA=TRUE\nMC_MEMORY=2G\nMC_PORT=25566\nMC_BIND_ADDRESS=0.0.0.0\nMC_UID=1000\nMC_GID=1000\nMC_PROJECT_NAME=minecraft-lab-phase7\n',{mode:0o600});
|
||||||
|
fs.writeFileSync(root+'/runtime/world/phase8-progress.txt','snapshot-progress\n');
|
||||||
|
fs.mkdirSync(root+'/runtime/mod-state',{recursive:true});fs.writeFileSync(root+'/runtime/mod-state/phase8-progress.txt','snapshot-progress\n');
|
||||||
|
JS
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$check/ssh/host-key"
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$check/ssh/client-key"
|
||||||
|
cp "$check/ssh/client-key.pub" "$check/ssh/authorized_keys"
|
||||||
|
cat > "$check/ssh/sshd_config" <<CONFIG
|
||||||
|
Port 22228
|
||||||
|
ListenAddress 127.0.0.1
|
||||||
|
HostKey $check/ssh/host-key
|
||||||
|
PidFile $check/ssh/sshd.pid
|
||||||
|
AuthorizedKeysFile $check/ssh/authorized_keys
|
||||||
|
StrictModes no
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
UsePAM yes
|
||||||
|
AllowUsers root
|
||||||
|
CONFIG
|
||||||
|
printf '[127.0.0.1]:22228 ' > "$check/ssh/known_hosts"
|
||||||
|
cut -d ' ' -f 1-2 "$check/ssh/host-key.pub" >> "$check/ssh/known_hosts"
|
||||||
|
cat > "$check/ssh/client_config" <<CONFIG
|
||||||
|
Host phase8-fixture
|
||||||
|
HostName 127.0.0.1
|
||||||
|
Port 22228
|
||||||
|
User root
|
||||||
|
IdentityFile $check/ssh/client-key
|
||||||
|
IdentitiesOnly yes
|
||||||
|
UserKnownHostsFile $check/ssh/known_hosts
|
||||||
|
CONFIG
|
||||||
|
cat > "$check/bin/ssh" <<SCRIPT
|
||||||
|
#!/bin/sh
|
||||||
|
exec /usr/bin/ssh -F '$check/ssh/client_config' "\$@"
|
||||||
|
SCRIPT
|
||||||
|
chmod +x "$check/bin/ssh"
|
||||||
|
export PATH="$check/bin:$PATH"
|
||||||
|
/usr/sbin/sshd -D -e -f "$check/ssh/sshd_config" > "$check/evidence/sshd.log" 2>&1 &
|
||||||
|
sshd_pid=$!
|
||||||
|
cleanup(){
|
||||||
|
if [[ -f "$check/host/.mcpack-runtime.json" ]]; then
|
||||||
|
for release in "$check/host/releases/"*; do
|
||||||
|
if MC_ROOT="$check/host" MC_RELEASE_DIR="$release" scripts/server.sh down > "$check/evidence/cleanup.log" 2>&1; then break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
kill "$sshd_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
sleep 0.3
|
||||||
|
FIXTURE_ROOT="$check/host" FIXTURE_CONFIG="$check/config.yaml" node --input-type=module <<'JS'
|
||||||
|
import fs from 'node:fs';import {parse,stringify} from 'yaml';
|
||||||
|
const config=parse(fs.readFileSync('platform/config.yaml','utf8'));config.server.host='phase8-fixture';config.server.directory=process.env.FIXTURE_ROOT;
|
||||||
|
fs.writeFileSync(process.env.FIXTURE_CONFIG,stringify(config));
|
||||||
|
JS
|
||||||
|
./mcpack deploy --config "$check/config.yaml" --artifact dist/minecraft-lab-neoforge-spike-0.0.5 --remote-node "$node_binary" > "$check/evidence/update-to-five.log" 2>&1
|
||||||
|
rollback_snapshot=$(HOST_ROOT="$check/host" node --input-type=module <<'JS'
|
||||||
|
import fs from 'node:fs';import path from 'node:path';
|
||||||
|
console.log(path.basename(JSON.parse(fs.readFileSync(process.env.HOST_ROOT+'/deployment-state.json')).backup_snapshot));
|
||||||
|
JS
|
||||||
|
)
|
||||||
|
printf '%s\n' "$rollback_snapshot" > "$check/evidence/requested-snapshot-id.txt"
|
||||||
|
printf 'current-progress\n' > "$check/host/runtime/world/phase8-progress.txt"
|
||||||
|
printf 'current-progress\n' > "$check/host/runtime/mod-state/phase8-progress.txt"
|
||||||
|
printf 'only-after-snapshot\n' > "$check/host/runtime/phase8-after-only.txt"
|
||||||
|
ls -A "$check/host" > "$check/evidence/root-before-plan.txt"
|
||||||
|
before=$(docker inspect minecraft-lab-phase7-minecraft-1 --format '{{.Id}} {{.State.StartedAt}}')
|
||||||
|
./mcpack rollback --config "$check/config.yaml" --remote-node "$node_binary" > "$check/evidence/plan.txt"
|
||||||
|
./mcpack rollback-pack 0.0.4 --config "$check/config.yaml" --remote-node "$node_binary" > "$check/evidence/pack-plan.txt"
|
||||||
|
ls -A "$check/host" > "$check/evidence/root-after-plan.txt"
|
||||||
|
[[ "$before" == "$(docker inspect minecraft-lab-phase7-minecraft-1 --format '{{.Id}} {{.State.StartedAt}}')" ]]
|
||||||
|
node -e 'const fs=require("fs"),assert=require("assert/strict");assert.equal(fs.readFileSync(process.argv[1],"utf8"),fs.readFileSync(process.argv[2],"utf8"))' "$check/evidence/root-before-plan.txt" "$check/evidence/root-after-plan.txt"
|
||||||
|
./mcpack rollback-pack 0.0.4 --confirm-compatible --config "$check/config.yaml" --remote-node "$node_binary" > "$check/evidence/pack-rollback.log" 2>&1
|
||||||
|
HOST_ROOT="$check/host" node --input-type=module <<'JS' > "$check/evidence/pack-assertions.txt"
|
||||||
|
import fs from 'node:fs';import assert from 'node:assert/strict';import {execFileSync} from 'node:child_process';
|
||||||
|
const root=process.env.HOST_ROOT,state=JSON.parse(fs.readFileSync(root+'/rollback-state.json'));
|
||||||
|
assert.equal(state.phase,'complete');assert.equal(state.mode,'pack');assert(state.backup_snapshot);
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/world/phase8-progress.txt','utf8'),'current-progress\n');
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/mod-state/phase8-progress.txt','utf8'),'current-progress\n');
|
||||||
|
assert(fs.existsSync(root+'/runtime/phase8-after-only.txt'));
|
||||||
|
const container=JSON.parse(execFileSync('docker',['inspect','minecraft-lab-phase7-minecraft-1']))[0];
|
||||||
|
assert.equal(container.State.Health.Status,'healthy');assert.equal(container.NetworkSettings.Ports['25565/tcp'][0].HostIp,'0.0.0.0');
|
||||||
|
console.log('Pack-only rollback: retained 0.0.4, current progress preserved, backup verified, LAN bind and health retained.');
|
||||||
|
JS
|
||||||
|
./mcpack rollback --backup "$rollback_snapshot" --accept-progress-loss --config "$check/config.yaml" --remote-node "$node_binary" > "$check/evidence/snapshot-rollback.log" 2>&1
|
||||||
|
HOST_ROOT="$check/host" node --input-type=module <<'JS' > "$check/evidence/snapshot-assertions.txt"
|
||||||
|
import fs from 'node:fs';import assert from 'node:assert/strict';import {execFileSync} from 'node:child_process';import {verifySnapshot} from './platform/backups.mjs';
|
||||||
|
const root=process.env.HOST_ROOT,state=JSON.parse(fs.readFileSync(root+'/rollback-state.json'));
|
||||||
|
assert.equal(state.phase,'complete');assert.equal(state.mode,'snapshot');assert.equal(state.runtime_replaced,true);
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/world/phase8-progress.txt','utf8'),'snapshot-progress\n');
|
||||||
|
assert.equal(fs.readFileSync(root+'/runtime/mod-state/phase8-progress.txt','utf8'),'snapshot-progress\n');
|
||||||
|
assert(!fs.existsSync(root+'/runtime/phase8-after-only.txt'));
|
||||||
|
assert.equal(fs.readFileSync(state.retired_runtime+'/world/phase8-progress.txt','utf8'),'current-progress\n');
|
||||||
|
assert(fs.existsSync(state.retired_runtime+'/phase8-after-only.txt'));
|
||||||
|
assert.equal(verifySnapshot(state.backup_snapshot).source_release.name,'minecraft-lab-neoforge-spike-0.0.4');
|
||||||
|
assert.equal(fs.readFileSync(root+'/.env','utf8').includes('MC_BIND_ADDRESS=0.0.0.0'),true);
|
||||||
|
const container=JSON.parse(execFileSync('docker',['inspect','minecraft-lab-phase7-minecraft-1']))[0];
|
||||||
|
assert.equal(container.State.Health.Status,'healthy');assert.equal(container.NetworkSettings.Ports['25565/tcp'][0].HostIp,'0.0.0.0');
|
||||||
|
assert(!fs.existsSync(root+'/.mcpack-runtime.lock'));
|
||||||
|
console.log('Snapshot rollback: all runtime restored, subsequent progress removed only from active runtime, retired progress and current backup retained; source release, host settings, LAN bind and health verified.');
|
||||||
|
JS
|
||||||
|
node scripts/query-minecraft.mjs 127.0.0.1 25566 > "$check/evidence/restored-status.json"
|
||||||
|
MC_ROOT="$check/host" MC_RELEASE_DIR="$check/host/releases/minecraft-lab-neoforge-spike-0.0.4" scripts/server.sh down
|
||||||
|
scripts/spike-server.sh ps > "$check/evidence/primary-server-status.txt"
|
||||||
|
printf 'Phase 8 real SSH/Docker rollback passed. Evidence: %s\n' "$check/evidence"
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Validate the real release workflow without committing or tagging the workspace.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
repository="$PWD"
|
||||||
|
check=$(mktemp -d "$repository/.tools/phase9-check.XXXXXX")
|
||||||
|
printf '%s\n' "$check" > .tools/phase9-check-path
|
||||||
|
mkdir -p "$check/source" "$check/evidence"
|
||||||
|
git rev-parse HEAD > "$check/evidence/workspace-head-before.txt"
|
||||||
|
git status --porcelain --untracked-files=all > "$check/evidence/workspace-status-before.txt"
|
||||||
|
git ls-files --cached --others --exclude-standard -z > "$check/files.list"
|
||||||
|
tar --null -T "$check/files.list" -cf "$check/source.tar"
|
||||||
|
tar --no-same-owner -xf "$check/source.tar" -C "$check/source"
|
||||||
|
git -C "$check/source" init -q
|
||||||
|
git -C "$check/source" add -A
|
||||||
|
git -C "$check/source" -c user.name='Phase 9 fixture' -c user.email='phase9@localhost' commit -qm 'Isolated source snapshot'
|
||||||
|
git clone -q "$check/source" "$check/clone"
|
||||||
|
git -C "$check/clone" config user.name 'Phase 9 fixture'
|
||||||
|
git -C "$check/clone" config user.email phase9@localhost
|
||||||
|
mkdir -p "$check/clone/node_modules" "$check/clone/.tools/packwiz"
|
||||||
|
cp -a node_modules/yaml node_modules/smol-toml "$check/clone/node_modules/"
|
||||||
|
cp .tools/packwiz/packwiz "$check/clone/.tools/packwiz/packwiz"
|
||||||
|
cp .tools/docker-compose "$check/clone/.tools/docker-compose"
|
||||||
|
cd "$check/clone"
|
||||||
|
./mcpack release 0.0.6 > "$check/evidence/release.log" 2>&1
|
||||||
|
node --input-type=module <<'JS' > "$check/evidence/assertions.txt"
|
||||||
|
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import {execFileSync} from 'node:child_process';
|
||||||
|
import {parse} from 'smol-toml';import {verifyRelease} from './platform/runtime.mjs';
|
||||||
|
const git=args=>execFileSync('git',args,{encoding:'utf8'}).trim(),pack=parse(fs.readFileSync('pack/pack.toml','utf8'));
|
||||||
|
const release=verifyRelease(path.resolve('dist/'+pack.name+'-0.0.6'));
|
||||||
|
assert.equal(pack.version,'0.0.6');assert.equal(release.manifest.git.dirty,false);
|
||||||
|
assert.equal(release.manifest.git.commit,git(['rev-parse','HEAD']));
|
||||||
|
assert.equal(git(['rev-parse','v0.0.6^{}']),release.manifest.git.commit);
|
||||||
|
assert.equal(git(['cat-file','-t','v0.0.6']),'tag');
|
||||||
|
assert.equal(git(['diff-tree','--no-commit-id','--name-only','-r','HEAD']),'pack/pack.toml');
|
||||||
|
assert.equal(git(['status','--porcelain','--untracked-files=all']),'');
|
||||||
|
assert(!fs.existsSync('.tools/release.lock'));
|
||||||
|
assert(fs.readdirSync('dist').every(name=>!name.startsWith('.release-check-')));
|
||||||
|
console.log('Real pinned packwiz and Compose: candidate build, version commit, clean final build, complete checksums and annotated tag passed.');
|
||||||
|
JS
|
||||||
|
if ./mcpack release 0.0.6 > "$check/evidence/duplicate-refusal.log" 2>&1; then exit 1; fi
|
||||||
|
cd "$repository"
|
||||||
|
git rev-parse HEAD > "$check/evidence/workspace-head-after.txt"
|
||||||
|
git status --porcelain --untracked-files=all > "$check/evidence/workspace-status-after.txt"
|
||||||
|
node -e 'const fs=require("fs"),assert=require("assert/strict"),root=process.argv[1];for(const field of ["head","status"])assert.equal(fs.readFileSync(root+"/workspace-"+field+"-before.txt","utf8"),fs.readFileSync(root+"/workspace-"+field+"-after.txt","utf8"))' "$check/evidence"
|
||||||
|
printf 'Phase 9 real release workflow passed. Evidence: %s\n' "$check/evidence"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Set TRUE only after accepting https://www.minecraft.net/en-us/eula
|
||||||
|
MC_EULA=FALSE
|
||||||
|
MC_MEMORY=2G
|
||||||
|
MC_PORT=25565
|
||||||
|
MC_UID=1000
|
||||||
|
MC_GID=1000
|
||||||
|
# IPv4 interface for game port: 127.0.0.1 locally, 0.0.0.0 for remote LAN access
|
||||||
|
MC_BIND_ADDRESS=127.0.0.1
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Local server
|
||||||
|
|
||||||
|
Этот каталог — source server configuration и прежний Phase 0 Compose.
|
||||||
|
Lifecycle полного релиза использует `scripts/server.sh` и Compose из самого
|
||||||
|
релиза; настройка описана в [docs/runtime.md](../docs/runtime.md).
|
||||||
|
|
||||||
|
`compose.yaml` — проверенный runtime NeoForge через локальный `.mrpack`.
|
||||||
|
Image закреплён в `toolchain.lock.env`, который загружает wrapper
|
||||||
|
`scripts/spike-server.sh`. Данные находятся в `runtime/`, артефакты
|
||||||
|
монтируются read-only из `dist/`. Порт публикуется только на IPv4 loopback.
|
||||||
|
Онлайн-проверка аккаунтов отключена согласно требованиям проекта.
|
||||||
|
|
||||||
|
`server.properties` создаётся image из Compose environment. Отдельный
|
||||||
|
source-файл не используется: иначе параметры получили бы двух владельцев.
|
||||||
|
Секреты и локальные настройки — в игнорируемом `.env`; публичные defaults —
|
||||||
|
в `.env.example`. Самостоятельное принятие EULA обязательно на новом host.
|
||||||
|
|
||||||
|
Для overrides скопировать `compose.override.yaml.example` в
|
||||||
|
`compose.override.yaml`, добавить нужные настройки в `server/config/` и
|
||||||
|
передать слой явно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/spike-server.sh -f server/compose.override.yaml config --quiet
|
||||||
|
scripts/spike-server.sh -f server/compose.override.yaml up -d --wait --wait-timeout 600
|
||||||
|
```
|
||||||
|
|
||||||
|
Override заменяет соответствующие runtime-конфиги при запуске. Текущий
|
||||||
|
`server/config/spike-marker.txt` — тестовый пример; основной Compose его
|
||||||
|
не применяет. `compose.spike-override.yaml` сохранён для воспроизведения
|
||||||
|
Phase 0. Автоматические backup/deploy/rollback появятся в следующих фазах.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Copy to compose.override.yaml and pass explicitly with -f.
|
||||||
|
# This optional layer gives server/config precedence over pack config.
|
||||||
|
services:
|
||||||
|
minecraft:
|
||||||
|
environment:
|
||||||
|
SYNC_SKIP_NEWER_IN_DESTINATION: "false"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${MC_ROOT:?Set project root}/server/config
|
||||||
|
target: /config
|
||||||
|
read_only: true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Optional Phase 0 probe, not a production config policy.
|
||||||
|
services:
|
||||||
|
minecraft:
|
||||||
|
environment:
|
||||||
|
SYNC_SKIP_NEWER_IN_DESTINATION: "false"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${MC_ROOT}/server/config
|
||||||
|
target: /config
|
||||||
|
read_only: true
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
minecraft:
|
||||||
|
image: ${MC_IMAGE:?Run scripts/spike-server.sh}
|
||||||
|
restart: "no"
|
||||||
|
stop_grace_period: 2m
|
||||||
|
ports:
|
||||||
|
- "${MC_BIND_ADDRESS:-127.0.0.1}:${MC_PORT:-25565}:25565"
|
||||||
|
environment:
|
||||||
|
EULA: ${MC_EULA:-FALSE}
|
||||||
|
TYPE: MODRINTH
|
||||||
|
MODRINTH_MODPACK: /modpacks/${MC_PACK:?Select a built mrpack}
|
||||||
|
# Disable implicit image-maintained mod filters: test source side metadata.
|
||||||
|
MODRINTH_DEFAULT_EXCLUDE_INCLUDES: ""
|
||||||
|
MEMORY: ${MC_MEMORY:-2G}
|
||||||
|
UID: ${MC_UID:-1000}
|
||||||
|
GID: ${MC_GID:-1000}
|
||||||
|
ENABLE_RCON: "false"
|
||||||
|
ONLINE_MODE: "false"
|
||||||
|
ENFORCE_SECURE_PROFILE: "false"
|
||||||
|
MOTD: minecraft-lab Phase 0
|
||||||
|
VIEW_DISTANCE: "6"
|
||||||
|
SIMULATION_DISTANCE: "4"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${MC_ROOT:?Set project root}/runtime
|
||||||
|
target: /data
|
||||||
|
- type: bind
|
||||||
|
source: ${MC_ROOT:?Set project root}/dist
|
||||||
|
target: /modpacks
|
||||||
|
read_only: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc-health"]
|
||||||
|
start_period: 2m
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
server-override-v1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"values":["phase0:load"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
say [Phase0] datapack-v2-loaded
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"pack": {
|
||||||
|
"pack_format": 48,
|
||||||
|
"description": "Phase 0 existing-world probe"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fixture } from './helpers/runtime-fixture.mjs';
|
||||||
|
import { backup, backupBeforeApply, restoreSnapshot, verifySnapshot, runtimeInventory } from '../platform/backups.mjs';
|
||||||
|
import { verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
|
||||||
|
|
||||||
|
function seeded(t) {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.equal(f.run(['up', '-d']), 0);
|
||||||
|
for (const name of ['world', 'mod-state', 'config']) fs.mkdirSync(path.join(f.host, 'runtime', name));
|
||||||
|
for (const [file, value] of Object.entries({ 'world/level.dat': 'world-data', 'mod-state/progression.json': '{"progress":42}', 'config/unmanaged.txt': 'keep-config', 'server.properties': 'online-mode=false', 'ops.json': '[]', 'whitelist.json': '[]' })) fs.writeFileSync(path.join(f.host, 'runtime', file), value, { mode: 0o600 });
|
||||||
|
f.options = { repository: f.root, env: f.env };
|
||||||
|
f.state = () => JSON.parse(fs.readFileSync(path.join(f.root, '.tools/docker-state.json')));
|
||||||
|
f.events = () => JSON.parse(fs.readFileSync(path.join(f.root, '.tools/events.json')));
|
||||||
|
f.operation = () => JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-backup-operation.json')));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brokenTar(f) {
|
||||||
|
const tar = path.join(f.root, '.tools/tar');
|
||||||
|
fs.writeFileSync(tar, '#!/bin/sh\necho intentional-archive-failure >&2\nexit 23\n', { mode: 0o755 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('running backup stops under shared lock, verifies full data and resumes same release', t => {
|
||||||
|
const f = seeded(t), inventory = runtimeInventory(path.join(f.host, 'runtime'));
|
||||||
|
const snapshot = backup(f.options), metadata = verifySnapshot(snapshot);
|
||||||
|
assert.deepEqual(metadata.runtime_inventory, inventory);
|
||||||
|
assert.equal(metadata.prior_state, 'running');
|
||||||
|
assert.deepEqual(metadata.exclusions, []);
|
||||||
|
assert.equal(metadata.source_release.manifest_sha256, verifyRelease(f.release).identity);
|
||||||
|
assert.equal(verifyRelease(path.join(snapshot, 'release')).identity, metadata.source_release.manifest_sha256);
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
assert.deepEqual(f.events().slice(-2), [{ command: 'stop', locked: true }, { command: 'up', locked: true }]);
|
||||||
|
assert.equal(f.operation().phase, 'complete');
|
||||||
|
assert(!fs.existsSync(path.join(snapshot, '.env')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopped and absent servers stay stopped; snapshot IDs are unique', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
f.run(['stop']);
|
||||||
|
const first = backup(f.options);
|
||||||
|
assert.equal(verifySnapshot(first).prior_state, 'stopped');
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
f.run(['down']);
|
||||||
|
const second = backup(f.options);
|
||||||
|
assert.equal(verifySnapshot(second).prior_state, 'absent');
|
||||||
|
assert.equal(f.state().present, false);
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore verifies bytes, ownership/modes and retained release in new root only', t => {
|
||||||
|
const f = seeded(t), snapshot = backup(f.options), destination = path.join(f.root, 'restored');
|
||||||
|
assert.equal(restoreSnapshot({ backupDirectory: snapshot, destination }), destination);
|
||||||
|
assert.deepEqual(runtimeInventory(path.join(destination, 'runtime')), verifySnapshot(snapshot).runtime_inventory);
|
||||||
|
assert.equal(fs.readFileSync(path.join(destination, 'runtime/mod-state/progression.json'), 'utf8'), '{"progress":42}');
|
||||||
|
assert.equal(verifyRelease(path.join(destination, 'release')).identity, verifyRelease(f.release).identity);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(destination, '.mcpack-runtime.json'))), JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-runtime.json'))));
|
||||||
|
assert(!fs.existsSync(path.join(destination, '.env')));
|
||||||
|
assert.throws(() => restoreSnapshot({ backupDirectory: snapshot, destination }), /must not exist/);
|
||||||
|
assert.equal(fs.readFileSync(path.join(destination, 'runtime/world/level.dat'), 'utf8'), 'world-data');
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt or incomplete snapshot fails before creating restoration destination', t => {
|
||||||
|
const f = seeded(t), snapshot = backup(f.options), destination = path.join(f.root, 'restored');
|
||||||
|
fs.appendFileSync(path.join(snapshot, 'runtime.tar.zst'), 'corrupt');
|
||||||
|
assert.throws(() => restoreSnapshot({ backupDirectory: snapshot, destination }), /checksum mismatch/);
|
||||||
|
assert(!fs.existsSync(destination));
|
||||||
|
const second = backup(f.options);
|
||||||
|
fs.writeFileSync(path.join(second, 'unexpected'), 'uncovered');
|
||||||
|
assert.throws(() => restoreSnapshot({ backupDirectory: second, destination }), /coverage differs/);
|
||||||
|
assert(!fs.existsSync(destination));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archive failure blocks apply, leaves stopped runtime and publishes nothing', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
brokenTar(f);
|
||||||
|
let applied = false;
|
||||||
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: f.release }, () => { applied = true; }), /intentional-archive-failure.*No snapshot published.*Runtime remains stopped/);
|
||||||
|
assert.equal(applied, false);
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
assert.equal(f.operation().phase, 'failed');
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(f.host, 'backups')), []);
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apply starts only after verified snapshot, under same lock, without automatic restart', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
const result = backupBeforeApply({ ...f.options, targetRelease: f.release }, ({ snapshot, metadata, lockToken }) => {
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
assert(fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
assert(lockToken);
|
||||||
|
assert.equal(verifySnapshot(snapshot).target_release.manifest_sha256, verifyRelease(f.release).identity);
|
||||||
|
assert.deepEqual(metadata.exclusions, []);
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /lock/);
|
||||||
|
return 'applied';
|
||||||
|
});
|
||||||
|
assert.equal(result, 'applied');
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
assert.equal(f.operation().phase, 'complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid target or existing lifecycle lock fails before server stop', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: path.join(f.root, 'missing') }, () => {}));
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
withRuntimeLock(f.host, () => assert.throws(() => backup(f.options), /lock/));
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
assert.equal(f.events().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume failure retains verified snapshot and returns server to stopped state', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
fs.writeFileSync(path.join(f.root, '.tools/fail-start'), 'fail');
|
||||||
|
assert.throws(() => backup(f.options), /Verified snapshot retained:.*Runtime remains stopped/);
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
assert.equal(f.operation().phase, 'failed');
|
||||||
|
assert.equal(verifySnapshot(f.operation().snapshot).prior_state, 'running');
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop failure publishes nothing and does not claim runtime is stopped', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
fs.writeFileSync(path.join(f.root, '.tools/fail-stop'), 'fail');
|
||||||
|
assert.throws(() => backup(f.options), /Compose stop failed.*Inspect server state/);
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
assert.equal(f.operation().snapshot, null);
|
||||||
|
assert.equal(f.operation().stopped_confirmed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime links fail closed before archive publication', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
fs.symlinkSync('world/level.dat', path.join(f.host, 'runtime/link'));
|
||||||
|
assert.throws(() => backup(f.options), /without links/);
|
||||||
|
assert.equal(f.state().running, false);
|
||||||
|
assert.equal(f.operation().snapshot, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apply failure reports current state without restarting an older release', t => {
|
||||||
|
const f = seeded(t);
|
||||||
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: f.release }, ({ lockToken }) => {
|
||||||
|
// Simulate a deployment that started a server and then failed.
|
||||||
|
const stateFile = path.join(f.root, '.tools/docker-state.json');
|
||||||
|
fs.writeFileSync(stateFile, JSON.stringify({ present: true, running: true }));
|
||||||
|
assert(lockToken);
|
||||||
|
throw new Error('apply failed after start');
|
||||||
|
}), /Verified snapshot retained:.*Inspect server state/);
|
||||||
|
assert.equal(f.operation().stopped_confirmed, false);
|
||||||
|
assert.equal(f.state().running, true);
|
||||||
|
assert.equal(f.events().filter(event => event.command === 'up').length, 1);
|
||||||
|
verifySnapshot(f.operation().snapshot);
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parse, stringify } from 'smol-toml';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { build } from '../platform/build.mjs';
|
||||||
|
|
||||||
|
import {fixture,commit} from './helpers/build-fixture.mjs';
|
||||||
|
const repository=fileURLToPath(new URL('../',import.meta.url));
|
||||||
|
const sha=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
const git=(root,args)=>execFileSync('git',args,{cwd:root,stdio:'pipe'});
|
||||||
|
function refresh(root) {
|
||||||
|
const directory = path.join(root, 'pack');
|
||||||
|
const pack = parse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8'));
|
||||||
|
const index = parse(fs.readFileSync(path.join(directory, pack.index.file), 'utf8'));
|
||||||
|
for (const entry of index.files) entry.hash = sha(path.join(directory, entry.file));
|
||||||
|
fs.writeFileSync(path.join(directory, pack.index.file), stringify(index));
|
||||||
|
pack.index.hash = sha(path.join(directory, pack.index.file));
|
||||||
|
fs.writeFileSync(path.join(directory, 'pack.toml'), stringify(pack));
|
||||||
|
}
|
||||||
|
|
||||||
|
const perform = (root, options = {}) => build({ root, log: () => {}, ...options });
|
||||||
|
const leftovers = root => fs.existsSync(path.join(root, 'dist')) ? fs.readdirSync(path.join(root, 'dist')).filter(file => file.startsWith('.build-')) : [];
|
||||||
|
|
||||||
|
test('full clean release has portable Compose, complete checksums, ownership and no secrets/target', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
const configFile = path.join(root, 'platform/config.yaml');
|
||||||
|
const config = parseYaml(fs.readFileSync(configFile, 'utf8'));
|
||||||
|
config.server.memory = '3G'; config.server.port = 25570;
|
||||||
|
config.server.host = 'private-deployment-host'; config.server.directory = '/private/remote/path';
|
||||||
|
config.build.output_directory = './releases';
|
||||||
|
fs.writeFileSync(configFile, stringifyYaml(config));
|
||||||
|
fs.writeFileSync(path.join(root, 'server/.env'), 'PRIVATE_TOKEN=secret-local-value\nMC_EULA=TRUE\n');
|
||||||
|
commit(root);
|
||||||
|
const release = perform(root, { releaseOnly: true });
|
||||||
|
assert.equal(path.basename(path.dirname(release)), 'releases');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(release, 'manifest.json')));
|
||||||
|
assert.equal(manifest.git.dirty, false);
|
||||||
|
assert.equal(manifest.git.commit, git(root, ['rev-parse', 'HEAD']).toString().trim());
|
||||||
|
assert.equal(manifest.server.memory, '3G');
|
||||||
|
assert.equal(manifest.server.port, 25570);
|
||||||
|
assert(manifest.managed_files.client.some(file => /MouseTweaks/.test(file.path)));
|
||||||
|
assert(!manifest.managed_files.server.some(file => /MouseTweaks/.test(file.path)));
|
||||||
|
const serverMarker = manifest.managed_files.server.find(file => file.path === 'config/spike-marker.txt');
|
||||||
|
assert.equal(serverMarker.owner, 'server');
|
||||||
|
assert.equal(serverMarker.overrides, 'pack');
|
||||||
|
assert.equal(serverMarker.hashes.sha256, sha(path.join(root, 'server/config/spike-marker.txt')));
|
||||||
|
for (const file of manifest.release_files) assert.equal(file.sha256, sha(path.join(release, file.path)));
|
||||||
|
const checksumLines = fs.readFileSync(path.join(release, 'SHA256SUMS'), 'utf8').trim().split('\n');
|
||||||
|
assert.equal(checksumLines.length, manifest.release_files.length + 1);
|
||||||
|
assert(checksumLines.some(line => line.endsWith(' manifest.json')));
|
||||||
|
execFileSync('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release });
|
||||||
|
const composeText = fs.readFileSync(path.join(release, 'server/compose.yaml'), 'utf8');
|
||||||
|
const compose = parseYaml(composeText).services.minecraft;
|
||||||
|
assert.equal(compose.image, manifest.server.image);
|
||||||
|
assert.equal(compose.environment.MEMORY, '${MC_MEMORY:-3G}');
|
||||||
|
assert.equal(compose.ports[0], '${MC_BIND_ADDRESS:-127.0.0.1}:${MC_PORT:-25570}:25565');
|
||||||
|
assert.equal(compose.environment.MODRINTH_MODPACK, `/modpacks/${manifest.artifact}`);
|
||||||
|
assert(compose.volumes.every(volume => volume.source.startsWith('${MC_')));
|
||||||
|
for (const file of ['manifest.json', 'server/compose.yaml', 'server/.env.example']) {
|
||||||
|
const text = fs.readFileSync(path.join(release, file), 'utf8');
|
||||||
|
for (const secret of ['secret-local-value', 'private-deployment-host', '/private/remote/path', root]) assert(!text.includes(secret));
|
||||||
|
}
|
||||||
|
assert.match(fs.readFileSync(path.join(release, 'server/.env.example'), 'utf8'), /MC_EULA=FALSE/);
|
||||||
|
assert.deepEqual(leftovers(root), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('development build records dirty state and leaves source unchanged', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(root, 'local-note.txt'), 'dirty\n');
|
||||||
|
const before = sha(path.join(root, 'pack/pack.toml'));
|
||||||
|
const release = perform(root);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(release, 'manifest.json'))).git.dirty, true);
|
||||||
|
assert.equal(sha(path.join(root, 'pack/pack.toml')), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing immutable release is never overwritten', t => {
|
||||||
|
const root = fixture(t), release = perform(root);
|
||||||
|
const before = sha(path.join(release, 'SHA256SUMS'));
|
||||||
|
assert.throws(() => perform(root), /already exists/);
|
||||||
|
assert.equal(sha(path.join(release, 'SHA256SUMS')), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale metadata is rejected before export', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
fs.appendFileSync(path.join(root, 'pack/config/spike-marker.txt'), 'stale\n');
|
||||||
|
assert.throws(() => perform(root), /stale indexed file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh changes in temporary copy fail safely without changing source', t => {
|
||||||
|
const root = fixture(t), index = path.join(root, 'pack/index.toml'), before = sha(index);
|
||||||
|
fs.writeFileSync(path.join(root, '.tools/state.json'), '{"stale":true}');
|
||||||
|
assert.throws(() => perform(root), /Stale source/);
|
||||||
|
assert.equal(sha(index), before);
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(root, 'dist')), ['.gitkeep']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed export leaves no partial release or build lock', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(root, '.tools/state.json'), '{"exportFailure":true}');
|
||||||
|
assert.throws(() => perform(root), /Simulated export failure/);
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(root, 'dist')), ['.gitkeep']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unverified loader/image combination is rejected', t => {
|
||||||
|
const root = fixture(t), file = path.join(root, 'pack/pack.toml');
|
||||||
|
const pack = parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
pack.versions.neoforge = '21.1.251';
|
||||||
|
fs.writeFileSync(file, stringify(pack));
|
||||||
|
assert.throws(() => perform(root), /not been verified/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate mod definitions fail despite a consistent index', t => {
|
||||||
|
const root = fixture(t), directory = path.join(root, 'pack');
|
||||||
|
const indexFile = path.join(directory, 'index.toml'), index = parse(fs.readFileSync(indexFile, 'utf8'));
|
||||||
|
const entry = index.files.find(file => file.file.includes('mouse-tweaks'));
|
||||||
|
fs.copyFileSync(path.join(directory, entry.file), path.join(directory, 'mods/duplicate.pw.toml'));
|
||||||
|
index.files.push({ ...entry, file: 'mods/duplicate.pw.toml' });
|
||||||
|
fs.writeFileSync(indexFile, stringify(index));
|
||||||
|
refresh(root);
|
||||||
|
assert.throws(() => perform(root), /Duplicate mod target/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid/duplicate config keys and protected output paths are rejected', t => {
|
||||||
|
const root = fixture(t), file = path.join(root, 'platform/config.yaml');
|
||||||
|
fs.appendFileSync(file, '\nschema_version: 1\n');
|
||||||
|
assert.throws(() => perform(root), /unique/);
|
||||||
|
fs.copyFileSync(path.join(repository, 'platform/config.yaml'), file);
|
||||||
|
const config = parseYaml(fs.readFileSync(file, 'utf8'));
|
||||||
|
config.build.output_directory = './pack/output';
|
||||||
|
fs.writeFileSync(file, stringifyYaml(config));
|
||||||
|
assert.throws(() => perform(root), /overlaps/);
|
||||||
|
config.build.output_directory = '../outside';
|
||||||
|
fs.writeFileSync(file, stringifyYaml(config));
|
||||||
|
assert.throws(() => perform(root), /inside repository/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('release mode rejects dirty worktree', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(root, 'untracked.txt'), 'dirty');
|
||||||
|
assert.throws(() => perform(root, { releaseOnly: true }), /clean Git worktree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracked known secrets are rejected', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(root, 'server/.env'), 'PRIVATE_TOKEN=x');
|
||||||
|
git(root, ['add', '-f', 'server/.env']);
|
||||||
|
commit(root);
|
||||||
|
assert.throws(() => perform(root), /secret path is tracked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tampered tool and source symlinks fail validation', t => {
|
||||||
|
const root = fixture(t);
|
||||||
|
const marker = path.join(root, 'pack/config/spike-marker.txt');
|
||||||
|
fs.unlinkSync(marker); fs.symlinkSync('/tmp/nonexistent-mcpack-marker', marker);
|
||||||
|
assert.throws(() => perform(root), /regular files/);
|
||||||
|
fs.unlinkSync(marker); fs.copyFileSync(path.join(repository, 'pack/config/spike-marker.txt'), marker);
|
||||||
|
fs.appendFileSync(path.join(root, '.tools/packwiz/packwiz'), '\n// tampered\n');
|
||||||
|
assert.throws(() => perform(root), /checksum mismatch/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseArgs, packwizArguments } from '../platform/commands.mjs';
|
||||||
|
|
||||||
|
const repository = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
|
||||||
|
test('mod commands map to packwiz without automatic yes', () => {
|
||||||
|
assert.deepEqual(packwizArguments(parseArgs(['add', 'mouse-tweaks'])), ['modrinth', 'add', 'mouse-tweaks']);
|
||||||
|
assert.deepEqual(packwizArguments(parseArgs(['remove', 'mouse-tweaks'])), ['remove', 'mouse-tweaks']);
|
||||||
|
assert.deepEqual(packwizArguments(parseArgs(['update', 'mouse-tweaks'])), ['update', 'mouse-tweaks']);
|
||||||
|
assert.deepEqual(packwizArguments(parseArgs(['update', '--all'])), ['update', '--all']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add forwards literal search and yes while config selects project only', () => {
|
||||||
|
const options = parseArgs(['add', 'some search $(literal)', '-y', '--config', 'platform/other.yaml']);
|
||||||
|
assert.equal(options.configFile, 'platform/other.yaml');
|
||||||
|
assert.deepEqual(packwizArguments(options), ['--yes', 'modrinth', 'add', 'some search $(literal)']);
|
||||||
|
const versionUrl = 'https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X';
|
||||||
|
assert.deepEqual(packwizArguments(parseArgs(['add', versionUrl])), ['modrinth', 'add', versionUrl]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid argument combinations fail before any tool invocation', () => {
|
||||||
|
for (const args of [[], ['add'], ['remove'], ['update'], ['update', '--all', 'mod'], ['add', 'mod', '--all'], ['remove', 'mod', '--version-id', 'v'], ['build', '--yes'], ['add', 'mod', '--release'], ['add', 'mod', '--config'], ['update', '--all', '--all'], ['add', 'a', 'b'], ['build', '--config', '--release']]) {
|
||||||
|
assert.throws(() => parseArgs(args));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI preserves packwiz stdout/stderr/exit and configured cwd with separate argv', t => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-command-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
for (const file of ['platform', 'scripts', 'package.json', 'toolchain.lock.env']) fs.cpSync(path.join(repository, file), path.join(root, file), { recursive: true });
|
||||||
|
fs.symlinkSync(path.join(repository, 'node_modules'), path.join(root, 'node_modules'), 'dir');
|
||||||
|
fs.mkdirSync(path.join(root, 'custom-pack'));
|
||||||
|
fs.writeFileSync(path.join(root, 'custom-pack/pack.toml'), 'name = "fixture"\n');
|
||||||
|
const configuration = path.join(root, 'platform/config.yaml');
|
||||||
|
fs.writeFileSync(configuration, fs.readFileSync(configuration, 'utf8').replace('./pack', './custom-pack'));
|
||||||
|
fs.mkdirSync(path.join(root, '.tools/packwiz'), { recursive: true });
|
||||||
|
const executable = path.join(root, '.tools/packwiz/packwiz');
|
||||||
|
const record = path.join(root, '.tools/record.json');
|
||||||
|
fs.writeFileSync(executable, `#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
fs.writeFileSync(${JSON.stringify(record)},JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}));
|
||||||
|
console.log('packwiz original stdout');
|
||||||
|
console.error('packwiz original stderr');
|
||||||
|
process.exit(17);
|
||||||
|
`);
|
||||||
|
fs.chmodSync(executable, 0o755);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(executable)).digest('hex');
|
||||||
|
const lock = path.join(root, 'toolchain.lock.env');
|
||||||
|
fs.writeFileSync(lock, fs.readFileSync(lock, 'utf8').replace(/^PACKWIZ_BINARY_SHA256=.*$/m, `PACKWIZ_BINARY_SHA256=${sha256}`));
|
||||||
|
const result = spawnSync(process.execPath, [path.join(root, 'scripts/mcpack.mjs'), 'add', 'some search $(literal)', '--yes'], { cwd: os.tmpdir(), encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 17);
|
||||||
|
assert.match(result.stdout, /packwiz original stdout/);
|
||||||
|
assert.match(result.stderr, /packwiz original stderr/);
|
||||||
|
assert(!result.stderr.includes('mcpack:'));
|
||||||
|
const invocation = JSON.parse(fs.readFileSync(record));
|
||||||
|
assert.equal(invocation.cwd, path.join(root, 'custom-pack'));
|
||||||
|
assert.deepEqual(invocation.args.slice(4), ['--yes', 'modrinth', 'add', 'some search $(literal)']);
|
||||||
|
fs.appendFileSync(executable, '\n// tampered\n');
|
||||||
|
const rejected = spawnSync(process.execPath, [path.join(root, 'scripts/mcpack.mjs'), 'remove', 'fixture'], { encoding: 'utf8' });
|
||||||
|
assert.equal(rejected.status, 1);
|
||||||
|
assert.match(rejected.stderr, /checksum mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup and restore parse only their own complete arguments', () => {
|
||||||
|
assert.equal(parseArgs(['backup']).waitTimeout, 120);
|
||||||
|
assert.equal(parseArgs(['backup', '--target-release', '/release', '--wait-timeout', '30']).waitTimeout, 30);
|
||||||
|
assert.equal(parseArgs(['restore', '--backup', '/snapshot', '--destination', '/new']).destination, '/new');
|
||||||
|
for (const args of [['backup', '--wait-timeout', '0'], ['backup', '--wait-timeout', 'NaN'], ['backup', '--release'], ['backup', '--destination', '/new'], ['restore'], ['restore', '--backup', '/snap'], ['restore', '--backup', '/snap', '--destination', '/new', '--wait-timeout', '3'], ['build', '--target-release', '/release']]) assert.throws(() => parseArgs(args));
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fixture, seal } from './helpers/runtime-fixture.mjs';
|
||||||
|
import { runtime, verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
|
||||||
|
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../platform/deployment.mjs';
|
||||||
|
import { sshTarget, shellQuote } from '../platform/ssh-deploy.mjs';
|
||||||
|
import { parseArgs } from '../platform/commands.mjs';
|
||||||
|
|
||||||
|
import { setup, addConfig } from './helpers/deployment-fixture.mjs';
|
||||||
|
|
||||||
|
test('first deployment publishes checked release, starts under lock and commits active only after success',async t=>{
|
||||||
|
const f=setup(t),state=await f.deploy();
|
||||||
|
assert.equal(state.first_deploy,true);assert.equal(state.backup_snapshot,null);assert.equal(state.phase,'complete');
|
||||||
|
assert.equal(fs.statSync(path.join(f.host,'releases',state.target_release)).mode&0o777,0o755);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'previous-release')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||||
|
assert(f.events().filter(e=>e.command==='up').every(e=>e.locked));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeat same healthy release does not upload or restart',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
const result=await f.deploy(f.release,()=>{throw new Error('should not upload');});
|
||||||
|
assert.equal(result.noop,true);assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same name/version with another manifest is rejected before stopping',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
fs.appendFileSync(path.join(f.release,'fixture.mrpack'),'changed');seal(f.release);
|
||||||
|
await assert.rejects(f.deploy(),/immutable release collision/);
|
||||||
|
assert.equal(f.state().phase,'complete');assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update requires verified backup, preserves unmanaged world and advances previous/active',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/level.dat'),'valuable');
|
||||||
|
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'42');
|
||||||
|
const result=await f.deploy(f.next());
|
||||||
|
assert(result.backup_snapshot);assert(fs.existsSync(path.join(result.backup_snapshot,'runtime.tar.zst')));
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/level.dat'),'utf8'),'valuable');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'42');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-1.0.0');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual managed edit fails before stop/uploaded config replacement, untracked configs retained',async t=>{
|
||||||
|
const f=setup(t);addConfig(f.release);await f.deploy();
|
||||||
|
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
||||||
|
fs.writeFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'keep');
|
||||||
|
await assert.rejects(f.deploy(f.next()),/Managed config conflicts/);
|
||||||
|
assert.equal(f.state().conflicts[0].reason,'managed-file-edited');
|
||||||
|
assert.match(f.state().conflicts[0].diff,/\+manual-edit/);
|
||||||
|
assert.match(f.state().conflicts[0].diff,/-original/);
|
||||||
|
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'utf8'),'keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transfer interruption does not stop source and records failed recoverable state',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
await assert.rejects(f.deploy(f.next(),()=>{throw new Error('transfer failed');}),/transfer failed/);
|
||||||
|
assert.equal(f.state().phase,'failed');assert.equal(f.state().applied,false);
|
||||||
|
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||||
|
assert(!fs.readdirSync(path.join(f.host,'releases')).some(n=>n.startsWith('.incoming')));
|
||||||
|
await assert.rejects(f.deploy(f.next('3.0.0')),/Unresolved deployment state/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup failure prohibits target startup and preserves source pointer/binding',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
const binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
|
||||||
|
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
|
||||||
|
await assert.rejects(f.deploy(f.next()),/No snapshot published/);
|
||||||
|
assert.equal(f.state().applied,false);assert.equal(f.state().backup_snapshot,null);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||||
|
assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed target startup leaves stopped target binding, source pointers and source backup',async t=>{
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
const next=f.next();fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||||
|
await assert.rejects(f.deploy(next),/Target startup\/health failed/);
|
||||||
|
const state=f.state();assert.equal(state.phase,'failed');assert.equal(state.applied,true);assert(state.backup_snapshot);assert.equal(state.stopped_confirmed,true);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(next).identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('async shared lock remains held during upload and rejects concurrent lifecycle/deploy',async t=>{
|
||||||
|
const f=setup(t);let releaseUpload,entered;
|
||||||
|
const waiting=new Promise(resolve=>entered=resolve),block=new Promise(resolve=>releaseUpload=resolve);
|
||||||
|
const deployment=f.deploy(f.release,async stage=>{entered();await block;fs.cpSync(f.release,stage,{recursive:true});});
|
||||||
|
await waiting;
|
||||||
|
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||||
|
assert.throws(()=>f.run(['up','-d']),/runtime operation/);
|
||||||
|
await assert.rejects(f.deploy(),/runtime operation/);
|
||||||
|
releaseUpload();await deployment;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirty release and unsafe SSH arguments rejected; CLI deploy flags scoped',t=>{
|
||||||
|
const f=setup(t);const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));manifest.git.dirty=true;fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||||
|
assert.throws(()=>deployableRelease(f.release),/clean Git/);
|
||||||
|
for(const [host,dir]of [['-oProxyCommand=bad','/srv/mc'],['user@host;touch bad','/srv/mc'],['alias','/'],['alias','/srv/../mc'],['alias','relative']])assert.throws(()=>sshTarget(host,dir));
|
||||||
|
assert.deepEqual(sshTarget('user@mc-host','/srv/minecraft'),{host:'user@mc-host',directory:'/srv/minecraft'});
|
||||||
|
assert.equal(shellQuote("a'b"),"'a'\\''b'");
|
||||||
|
assert.equal(parseArgs(['deploy','--artifact','/release']).artifact,'/release');
|
||||||
|
for(const args of [['deploy','--yes'],['deploy','--release'],['build','--artifact','/release']])assert.throws(()=>parseArgs(args));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual edit incorporated exactly in clean target is accepted',async t=>{
|
||||||
|
const f=setup(t);addConfig(f.release);await f.deploy();
|
||||||
|
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
||||||
|
const next=f.next('2.0.0',release=>{
|
||||||
|
fs.writeFileSync(path.join(release,'server/config/marker.txt'),'manual-edit');
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
|
||||||
|
manifest.managed_files.server[0].hashes.sha256=sha256(path.join(release,'server/config/marker.txt'));
|
||||||
|
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));
|
||||||
|
});
|
||||||
|
assert.equal((await f.deploy(next)).phase,'complete');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { waitForHealth, healthOptions } from '../platform/health.mjs';
|
||||||
|
import { fixture } from './helpers/runtime-fixture.mjs';
|
||||||
|
|
||||||
|
const healthy = () => ({ Id:'a'.repeat(64), RestartCount:0, State:{Status:'running',Running:true,StartedAt:'start',Health:{Status:'healthy'}} });
|
||||||
|
function check(inspect, probe=()=>({status:0,stdout:'Minecraft 1.21.1'}), options={}) {
|
||||||
|
let time=0;
|
||||||
|
return waitForHealth({inspect,probe,timeoutSeconds:6,stableSeconds:2,now:()=>time,pause:ms=>{time+=ms;},...options});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('readiness requires repeated successful Minecraft queries and stable container', () => {
|
||||||
|
let queries=0;
|
||||||
|
const result=check(healthy,()=>{queries++;return{status:0};});
|
||||||
|
assert.equal(result.ok,true);
|
||||||
|
assert.equal(result.elapsed_ms,2000);
|
||||||
|
assert.equal(queries,3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('running TCP-ready/healthy container with failed Minecraft query times out', () => {
|
||||||
|
const result=check(healthy,()=>({status:1,stderr:'protocol unavailable'}));
|
||||||
|
assert.equal(result.reason,'timeout');
|
||||||
|
assert.equal(result.probe.status,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starting or unhealthy cannot pass and transient failures reset stability window', () => {
|
||||||
|
for(const status of ['starting','unhealthy'])assert.equal(check(()=>({...healthy(),State:{...healthy().State,Health:{Status:status}}})).reason,'timeout');
|
||||||
|
let calls=0;
|
||||||
|
assert.equal(check(healthy,()=>({status:++calls===2?1:0})).elapsed_ms,4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crash/OOM and paused fail immediately with diagnostic state', () => {
|
||||||
|
const dead=healthy();Object.assign(dead.State,{Running:false,Status:'exited',ExitCode:137,OOMKilled:true});
|
||||||
|
const result=check(()=>dead);
|
||||||
|
assert.equal(result.reason,'crash');assert.equal(result.state.oom_killed,true);assert.equal(result.state.exit_code,137);
|
||||||
|
const paused=healthy();paused.State.Paused=true;
|
||||||
|
assert.equal(check(()=>paused).reason,'paused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restarting, increasing restart count, replacement and start time changes fail', () => {
|
||||||
|
const restart=healthy();restart.State.Restarting=true;
|
||||||
|
assert.equal(check(()=>restart).reason,'restart-loop');
|
||||||
|
for(const change of [c=>c.RestartCount++,c=>c.State.StartedAt='new',c=>c.Id='b'.repeat(64)]){
|
||||||
|
let calls=0;assert.match(check(()=>{const c=healthy();if(calls++)change(c);return c;}).reason,/restart-loop|container-replaced/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing container/healthcheck and inspection errors never report success', () => {
|
||||||
|
assert.equal(check(()=>null).reason,'missing-container');
|
||||||
|
assert.equal(check(()=>{const c=healthy();delete c.State.Health;return c;}).reason,'missing-healthcheck');
|
||||||
|
assert.equal(check(()=>{throw new Error('Docker denied');}).reason,'inspection-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health flags require positive bounded options and detached startup', () => {
|
||||||
|
assert.deepEqual(healthOptions(['up','--wait','--wait-timeout','30','--stable-seconds','2']).forwarded,['up','-d']);
|
||||||
|
assert.equal(healthOptions(['health','--wait-timeout','60']).timeoutSeconds,60);
|
||||||
|
for(const args of [['up','--wait-timeout','30'],['health','--wait-timeout','NaN'],['health','--wait-timeout','3'],['health','--stable-seconds','0'],['stop','--wait'],['health','--wait-timeout','20','--wait-timeout','30'],['up','--wait','--abort-on-container-exit']])assert.throws(()=>healthOptions(args));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed waited startup writes report/logs, preserves binding, releases lifecycle lock', t => {
|
||||||
|
const f=fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'');
|
||||||
|
assert.equal(f.run(['up','--wait','--wait-timeout','30']),1);
|
||||||
|
const files=fs.readdirSync(path.join(f.host,'diagnostics'));
|
||||||
|
const report=JSON.parse(fs.readFileSync(path.join(f.host,'diagnostics',files.find(name=>name.endsWith('.json')))));
|
||||||
|
assert.equal(report.reason,'compose-start-failed');assert.equal(report.compose_exit,19);
|
||||||
|
assert.equal(report.recovery.automatic_rollback,false);
|
||||||
|
assert(fs.existsSync(report.logs));
|
||||||
|
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.json')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deadline includes launch time and a late successful query cannot pass', () => {
|
||||||
|
assert.equal(check(healthy,()=>({status:0}),{startedAtMs:-5000}).reason,'timeout');
|
||||||
|
let time=0;
|
||||||
|
const result=waitForHealth({inspect:healthy,probe:()=>{time+=6000;return{status:0};},timeoutSeconds:6,stableSeconds:2,now:()=>time,pause:ms=>{time+=ms;}});
|
||||||
|
assert.equal(result.reason,'timeout');
|
||||||
|
assert.equal(healthOptions(['up','--wait','--wait-timeout=30']).timeoutSeconds,30);
|
||||||
|
assert.throws(()=>healthOptions(['up','--wait=true']));
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parse, stringify } from 'smol-toml';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { build } from '../../platform/build.mjs';
|
||||||
|
|
||||||
|
const repository = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
|
const sha = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
const git = (root, args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
||||||
|
|
||||||
|
export function fixture(t) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-test-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
for (const file of ['pack', 'server', 'platform', 'scripts', '.gitignore', 'package.json', 'package-lock.json', 'toolchain.lock.env']) {
|
||||||
|
fs.cpSync(path.join(repository, file), path.join(root, file), { recursive: true, filter: file => path.basename(file) !== '.env' && path.basename(file) !== 'compose.override.yaml' });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.join(root, '.tools/packwiz'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, 'dist'));
|
||||||
|
fs.writeFileSync(path.join(root, 'dist/.gitkeep'), '');
|
||||||
|
fs.copyFileSync(path.join(repository, '.tools/docker-compose'), path.join(root, '.tools/docker-compose'));
|
||||||
|
const mock = path.join(root, '.tools/packwiz/packwiz');
|
||||||
|
fs.writeFileSync(mock, `#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { parse } from ${JSON.stringify(import.meta.resolve('smol-toml'))};
|
||||||
|
const state = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(root, '.tools/state.json'))}, 'utf8'));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('refresh')) {
|
||||||
|
if (state.stale) fs.appendFileSync('index.toml', '\\n# mock refresh changed index\\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (state.exportFailure) { console.error('Simulated export failure'); process.exit(7); }
|
||||||
|
const pack = parse(fs.readFileSync('pack.toml', 'utf8'));
|
||||||
|
const index = parse(fs.readFileSync(pack.index.file, 'utf8'));
|
||||||
|
const archive = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-mock-'));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(archive, 'overrides'));
|
||||||
|
const metadata = [];
|
||||||
|
for (const entry of index.files) {
|
||||||
|
if (entry.metafile) {
|
||||||
|
const mod = parse(fs.readFileSync(entry.file, 'utf8'));
|
||||||
|
metadata.push({path:path.posix.join(path.posix.dirname(entry.file),mod.filename),hashes:{sha512:mod.download.hash,sha1:'1'.repeat(40)},env:{client:mod.side==='server'?'unsupported':'required',server:mod.side==='client'?'unsupported':'required'},downloads:[mod.download.url]});
|
||||||
|
} else {
|
||||||
|
const destination = path.join(archive, 'overrides', entry.file);
|
||||||
|
fs.mkdirSync(path.dirname(destination), {recursive:true});
|
||||||
|
fs.copyFileSync(entry.file, destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(archive,'modrinth.index.json'),JSON.stringify({formatVersion:1,game:'minecraft',name:pack.name,versionId:pack.version,dependencies:pack.versions,files:metadata}));
|
||||||
|
execFileSync('bsdtar',['--format','zip','-cf',args[args.indexOf('--output')+1],'-C',archive,'modrinth.index.json','overrides']);
|
||||||
|
} finally { fs.rmSync(archive,{recursive:true,force:true}); }
|
||||||
|
`);
|
||||||
|
fs.chmodSync(mock, 0o755);
|
||||||
|
fs.writeFileSync(path.join(root, '.tools/state.json'), '{}');
|
||||||
|
let lock = fs.readFileSync(path.join(root, 'toolchain.lock.env'), 'utf8');
|
||||||
|
lock = lock.replace(/^PACKWIZ_BINARY_SHA256=.*$/m, `PACKWIZ_BINARY_SHA256=${sha(mock)}`);
|
||||||
|
fs.writeFileSync(path.join(root, 'toolchain.lock.env'), lock);
|
||||||
|
git(root, ['init', '-q']);
|
||||||
|
commit(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commit(root) {
|
||||||
|
git(root, ['add', '-A']);
|
||||||
|
git(root, ['-c', 'user.name=Test', '-c', 'user.email=test@localhost', 'commit', '--allow-empty', '-qm', 'Fixture']);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fixture, seal } from './runtime-fixture.mjs';
|
||||||
|
import { runtime } from '../../platform/runtime.mjs';
|
||||||
|
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../../platform/deployment.mjs';
|
||||||
|
|
||||||
|
export function setup(t){
|
||||||
|
const f=fixture(t);
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));
|
||||||
|
manifest.git={dirty:false,commit:'1'.repeat(40)};manifest.managed_files={server:[]};
|
||||||
|
fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||||
|
// Health's actual waiting/state machine is tested separately. This adapter
|
||||||
|
// substitutes readiness, while retaining real binding/lock/Compose checks.
|
||||||
|
f.execute=options=>{
|
||||||
|
const args=options.args[0]==='health'?['ps']:options.args.filter((item,index,array)=>item!=='--wait'&&item!=='--wait-timeout'&&array[index-1]!=='--wait-timeout');
|
||||||
|
const result=runtime({...options,args});
|
||||||
|
if(result===0&&args[0]==='up'){
|
||||||
|
const config=path.join(options.env.MC_RELEASE_DIR,'server/config');
|
||||||
|
for(const file of fs.readdirSync(config)){
|
||||||
|
fs.mkdirSync(path.join(options.env.MC_ROOT,'runtime/config'),{recursive:true});fs.copyFileSync(path.join(config,file),path.join(options.env.MC_ROOT,'runtime/config',file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
f.deploy=(release=f.release,receive)=>{
|
||||||
|
const verified=deployableRelease(release);
|
||||||
|
return deployOnHost({repository:f.root,root:f.host,targetName:releaseName(verified.manifest),targetIdentity:verified.identity,targetChecksums:sha256(path.join(release,'SHA256SUMS')),releaseBytes:1024,timeout:30,env:f.env,execute:f.execute,receive:receive??(stage=>fs.cpSync(release,stage,{recursive:true}))});
|
||||||
|
};
|
||||||
|
f.next=(version='2.0.0',edit=()=>{})=>{
|
||||||
|
const release=path.join(f.root,'release-'+version);fs.cpSync(f.release,release,{recursive:true});
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));manifest.pack.version=version;
|
||||||
|
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));edit(release);seal(release);return release;
|
||||||
|
};
|
||||||
|
f.state=()=>JSON.parse(fs.readFileSync(path.join(f.host,'deployment-state.json')));
|
||||||
|
f.events=()=>JSON.parse(fs.readFileSync(path.join(f.root,'.tools/events.json')));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
export function addConfig(release,value='original'){
|
||||||
|
fs.writeFileSync(path.join(release,'server/config/marker.txt'),value);
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
|
||||||
|
manifest.release_files.push({path:'server/config/marker.txt'});
|
||||||
|
manifest.managed_files.server.push({path:'config/marker.txt',owner:'server',policy:'replace',hashes:{sha256:sha256(path.join(release,'server/config/marker.txt'))}});
|
||||||
|
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));seal(release);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { runtime } from '../../platform/runtime.mjs';
|
||||||
|
|
||||||
|
const repository = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
|
const sha = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||||
|
|
||||||
|
export function seal(release) {
|
||||||
|
const manifestFile = path.join(release, 'manifest.json');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||||
|
for (const entry of manifest.release_files) entry.sha256 = sha(path.join(release, entry.path));
|
||||||
|
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
const files = [...manifest.release_files.map(entry => entry.path), 'manifest.json'].sort();
|
||||||
|
fs.writeFileSync(path.join(release, 'SHA256SUMS'), files.map(file => `${sha(path.join(release, file))} ${file}\n`).join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fixture(t) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-runtime-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
fs.mkdirSync(path.join(root, 'platform'));
|
||||||
|
fs.copyFileSync(path.join(repository, 'platform/compatibility.json'), path.join(root, 'platform/compatibility.json'));
|
||||||
|
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
|
||||||
|
fs.mkdirSync(path.join(root, '.tools'));
|
||||||
|
// Build a minimal sealed release so tests work in a clean clone without dist.
|
||||||
|
const release = path.join(root, 'release');
|
||||||
|
fs.mkdirSync(path.join(release, 'server/config'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(release, 'fixture.mrpack'), 'runtime fixture payload');
|
||||||
|
fs.writeFileSync(path.join(release, 'server/compose.yaml'), 'services: {}\n');
|
||||||
|
fs.copyFileSync(path.join(repository, 'server/.env.example'), path.join(release, 'server/.env.example'));
|
||||||
|
const executable = path.join(root, '.tools/docker-compose');
|
||||||
|
fs.writeFileSync(executable, `#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
const args=process.argv.slice(2),env=process.env;
|
||||||
|
const m=JSON.parse(fs.readFileSync(path.join(env.MC_RELEASE_DIR,'manifest.json')));
|
||||||
|
const stateFile=${JSON.stringify(path.join(root, '.tools/docker-state.json'))};
|
||||||
|
const state=fs.existsSync(stateFile)?JSON.parse(fs.readFileSync(stateFile)):{present:false,running:false};
|
||||||
|
if(args.includes('config')){
|
||||||
|
const wrong=fs.existsSync(${JSON.stringify(path.join(root, '.tools/wrong-contract'))});
|
||||||
|
console.log(JSON.stringify({services:{minecraft:{image:m.server.image,environment:{TYPE:'MODRINTH',ONLINE_MODE:wrong?'true':'false',ENFORCE_SECURE_PROFILE:'false',MODRINTH_MODPACK:'/modpacks/'+m.artifact},volumes:[{type:'bind',source:path.join(env.MC_ROOT,'runtime'),target:'/data'},{type:'bind',source:env.MC_RELEASE_DIR,target:'/modpacks',read_only:true},{type:'bind',source:path.join(env.MC_RELEASE_DIR,'server/config'),target:'/config',read_only:true}]}}}));
|
||||||
|
}else if(args.includes('ps')){
|
||||||
|
console.log(JSON.stringify(state.present||fs.existsSync(${JSON.stringify(path.join(root, '.tools/existing-project'))})?[{ID:'a'.repeat(64)}]:[]));
|
||||||
|
}else{
|
||||||
|
fs.writeFileSync(${JSON.stringify(path.join(root, '.tools/invocation.json'))},JSON.stringify({args,uid:env.MC_UID,gid:env.MC_GID,bind:env.MC_BIND_ADDRESS,root:env.MC_ROOT,release:env.MC_RELEASE_DIR}));
|
||||||
|
const command=args[6];
|
||||||
|
const eventsFile=${JSON.stringify(path.join(root, '.tools/events.json'))};
|
||||||
|
const events=fs.existsSync(eventsFile)?JSON.parse(fs.readFileSync(eventsFile)):[];
|
||||||
|
events.push({command,locked:fs.existsSync(path.join(env.MC_ROOT,'.mcpack-runtime.lock'))});
|
||||||
|
fs.writeFileSync(eventsFile,JSON.stringify(events));
|
||||||
|
if(command==='up'&&fs.existsSync(${JSON.stringify(path.join(root, '.tools/fail-start'))}))process.exit(19);
|
||||||
|
if(command==='stop'&&fs.existsSync(${JSON.stringify(path.join(root, '.tools/fail-stop'))}))process.exit(18);
|
||||||
|
if(command==='up'||command==='start')Object.assign(state,{present:true,running:true});
|
||||||
|
if(command==='stop')state.running=false;
|
||||||
|
if(command==='down')Object.assign(state,{present:false,running:false});
|
||||||
|
fs.writeFileSync(stateFile,JSON.stringify(state));
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
fs.chmodSync(executable, 0o755);
|
||||||
|
const docker = path.join(root, '.tools/docker');
|
||||||
|
fs.writeFileSync(docker, `#!/usr/bin/env node
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
const stateFile=${JSON.stringify(path.join(root, '.tools/docker-state.json'))};
|
||||||
|
const state=fs.existsSync(stateFile)?JSON.parse(fs.readFileSync(stateFile)):{present:false,running:false};
|
||||||
|
const other=fs.existsSync(${JSON.stringify(path.join(root, '.tools/existing-project'))});
|
||||||
|
if(process.argv[2]==='exec'){console.log('Minecraft status fixture');process.exit(0);}
|
||||||
|
console.log(JSON.stringify([{Id:'a'.repeat(64),RestartCount:0,Mounts:[{Type:'bind',Destination:'/data',Source:other?'/another/runtime':path.join(process.env.MC_ROOT,'runtime')}],State:{Status:state.running?'running':'exited',Running:state.running,Restarting:false,Paused:false,Dead:false,StartedAt:'fixture-start',ExitCode:0,Health:{Status:'healthy'}}}]));
|
||||||
|
`);
|
||||||
|
fs.chmodSync(docker, 0o755);
|
||||||
|
const verified = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'))).verified[0];
|
||||||
|
fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify({ schema_version: 1, artifact: 'fixture.mrpack', pack: { name: 'fixture', version: '1.0.0', minecraft: verified.minecraft, loader: { id: verified.loader, version: verified.loader_version } }, server: { image: verified.image, java_major: verified.java_major, compose_project: 'fixture-server' }, tools: { compose: { sha256: sha(executable) } }, release_files: ['fixture.mrpack', 'server/compose.yaml', 'server/.env.example'].map(file => ({ path: file })) }));
|
||||||
|
seal(release);
|
||||||
|
const host = path.join(root, 'host');
|
||||||
|
fs.mkdirSync(host);
|
||||||
|
fs.copyFileSync(path.join(repository, 'server/.env.example'), path.join(host, '.env'));
|
||||||
|
fs.writeFileSync(path.join(host, '.env'), fs.readFileSync(path.join(host, '.env'), 'utf8').replace('MC_EULA=FALSE', 'MC_EULA=TRUE'));
|
||||||
|
const env = { ...process.env, PATH: `${path.join(root, '.tools')}${path.delimiter}${process.env.PATH}`, MC_ROOT: host, MC_RELEASE_DIR: release, MC_PROJECT_NAME: 'fixture-server' };
|
||||||
|
return { root, host, release, env, run: args => runtime({ repository: root, args, env }) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {execFileSync} from 'node:child_process';
|
||||||
|
import {release,validVersion} from '../platform/release.mjs';
|
||||||
|
import {parseArgs} from '../platform/commands.mjs';
|
||||||
|
import {fixture,commit} from './helpers/build-fixture.mjs';
|
||||||
|
import {build} from '../platform/build.mjs';
|
||||||
|
import {verifyRelease} from '../platform/runtime.mjs';
|
||||||
|
const git=(root,args)=>execFileSync('git',args,{cwd:root,encoding:'utf8'}).trim();
|
||||||
|
|
||||||
|
test('release CLI and supported SemVer reject ambiguous flags and versions',()=>{
|
||||||
|
assert.equal(parseArgs(['release','0.0.6']).version,'0.0.6');
|
||||||
|
for(const args of [['release'],['release','0.0.6','--release'],['release','0.0.6','--yes'],['release','0.0.6','--backup','x']])assert.throws(()=>parseArgs(args));
|
||||||
|
for(const version of ['0.0.6','1.0.0-rc.1'])assert(validVersion(version));
|
||||||
|
for(const version of ['01.0.0','1.0','1.0.0-01','v1.0.0','../1','1.0.0+metadata'])assert(!validVersion(version));
|
||||||
|
});
|
||||||
|
test('release builds twice, commits only version, verifies checksums and tags final commit',t=>{
|
||||||
|
const root=fixture(t),start=git(root,['rev-parse','HEAD']),original=fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8');
|
||||||
|
const result=release({root,version:'0.0.6',log:()=>{}});
|
||||||
|
assert.notEqual(result.commit,start);assert.equal(git(root,['rev-parse','v0.0.6^{}']),result.commit);
|
||||||
|
assert.equal(git(root,['diff-tree','--no-commit-id','--name-only','-r',result.commit]),'pack/pack.toml');
|
||||||
|
assert.equal(git(root,['status','--porcelain']),'');assert.equal(verifyRelease(result.directory).manifest.git.dirty,false);
|
||||||
|
assert(fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8').includes('version = "0.0.6"'));
|
||||||
|
assert(!fs.existsSync(path.join(root,'.tools/release.lock')));
|
||||||
|
assert(fs.readdirSync(path.join(root,'dist')).every(name=>!name.startsWith('.release-check-')));
|
||||||
|
assert.notEqual(original,fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8'));
|
||||||
|
});
|
||||||
|
test('dirty tree, existing tag and existing artifact refuse before changing source',t=>{
|
||||||
|
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8');
|
||||||
|
fs.writeFileSync(path.join(root,'untracked.txt'),'dirty');assert.throws(()=>release({root,version:'0.0.6'}),/clean/);fs.unlinkSync(path.join(root,'untracked.txt'));
|
||||||
|
git(root,['tag','v0.0.6']);assert.throws(()=>release({root,version:'0.0.6'}),/already exists/);
|
||||||
|
fs.mkdirSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.7'));assert.throws(()=>release({root,version:'0.0.7'}),/already exists/);
|
||||||
|
assert.equal(fs.readFileSync(file,'utf8'),original);
|
||||||
|
});
|
||||||
|
test('candidate export failure restores exact source and leaves HEAD/tag/output untouched',t=>{
|
||||||
|
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']);
|
||||||
|
fs.writeFileSync(path.join(root,'.tools/state.json'),JSON.stringify({exportFailure:true}));
|
||||||
|
assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/);
|
||||||
|
assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),'');
|
||||||
|
assert(!fs.existsSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.6')));
|
||||||
|
});
|
||||||
|
test('final build failure retains clean version commit but never creates tag',t=>{
|
||||||
|
const root=fixture(t),head=git(root,['rev-parse','HEAD']);let calls=0;
|
||||||
|
assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:options=>{if(++calls===2)throw new Error('final failure');return build(options);}}),/Version commit retained/);
|
||||||
|
assert.notEqual(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),'');
|
||||||
|
});
|
||||||
|
test('release shared preparation lock refuses concurrent operation',t=>{
|
||||||
|
const root=fixture(t);fs.mkdirSync(path.join(root,'.tools/release.lock'));
|
||||||
|
assert.throws(()=>release({root,version:'0.0.6'}),/locked/);
|
||||||
|
});
|
||||||
|
test('commit hook rejection restores source and unstages only the release version',t=>{
|
||||||
|
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']);
|
||||||
|
const hook=path.join(root,'.git/hooks/pre-commit');fs.writeFileSync(hook,'#!/bin/sh\nexit 1\n',{mode:0o755});
|
||||||
|
assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/);
|
||||||
|
assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['status','--porcelain']),'');assert.equal(git(root,['rev-parse','HEAD']),head);
|
||||||
|
});
|
||||||
|
test('concurrent source edit during candidate validation is preserved on failure',t=>{
|
||||||
|
const root=fixture(t),file=path.join(root,'pack/pack.toml');
|
||||||
|
assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:()=>{fs.appendFileSync(file,'\n# concurrent operator edit\n');throw new Error('candidate failed');}}),/Concurrent changes retained/);
|
||||||
|
assert(fs.readFileSync(file,'utf8').endsWith('# concurrent operator edit\n'));assert.equal(git(root,['tag','--list']),'');
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {setup} from './helpers/deployment-fixture.mjs';
|
||||||
|
import {rollbackOnHost,inspectRollback} from '../platform/rollback.mjs';
|
||||||
|
import {verifySnapshot} from '../platform/backups.mjs';
|
||||||
|
import {verifyRelease} from '../platform/runtime.mjs';
|
||||||
|
import {parseArgs} from '../platform/commands.mjs';
|
||||||
|
|
||||||
|
async function seeded(t){
|
||||||
|
const f=setup(t);await f.deploy();
|
||||||
|
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'before');
|
||||||
|
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'before');
|
||||||
|
f.second=f.next();const update=await f.deploy(f.second);f.snapshot=update.backup_snapshot;f.backupId=path.basename(f.snapshot);
|
||||||
|
fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'after');fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'after');
|
||||||
|
fs.writeFileSync(path.join(f.host,'runtime/new-file'),'new');
|
||||||
|
f.rollback=options=>rollbackOnHost({repository:f.root,root:f.host,env:f.env,execute:f.execute,timeout:30,...options});
|
||||||
|
f.rollbackState=()=>JSON.parse(fs.readFileSync(path.join(f.host,'rollback-state.json')));
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no-mode plan is read-only and explicit modes require separate confirmations',async t=>{
|
||||||
|
const f=await seeded(t),files=fs.readdirSync(f.host),events=f.events().length;
|
||||||
|
const plan=inspectRollback(f.host);
|
||||||
|
assert.equal(plan.action,'plan-only');assert.equal(plan.active,'fixture-2.0.0');assert.equal(plan.previous,'fixture-1.0.0');assert.equal(plan.snapshots[0].id,f.backupId);
|
||||||
|
assert.deepEqual(fs.readdirSync(f.host),files);assert.equal(f.events().length,events);
|
||||||
|
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0'}),/confirm-compatible/);
|
||||||
|
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId}),/accept-progress-loss/);
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'rollback-state.json')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot rollback replaces full runtime, retains displaced progress/current backup and env',async t=>{
|
||||||
|
const f=await seeded(t),settings=fs.readFileSync(path.join(f.host,'.env'),'utf8');
|
||||||
|
const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true});
|
||||||
|
assert.equal(state.phase,'complete');assert.equal(state.runtime_replaced,true);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'before');
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'runtime/new-file')));
|
||||||
|
assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(state.retired_runtime,'new-file'),'utf8'),'new');
|
||||||
|
assert.equal(verifySnapshot(state.backup_snapshot).source_release.name,'fixture-2.0.0');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'.env'),'utf8'),settings);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-2.0.0');
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity);
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pack-only uses retained release without restore/build and preserves current world',async t=>{
|
||||||
|
const f=await seeded(t);
|
||||||
|
const state=f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true});
|
||||||
|
assert.equal(state.mode,'pack');assert.equal(state.runtime_replaced,false);assert(state.backup_snapshot);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'after');
|
||||||
|
assert(fs.existsSync(path.join(f.host,'runtime/new-file')));assert.equal(state.target_release,'fixture-1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt snapshot or release is rejected before stopping/current backup',async t=>{
|
||||||
|
const f=await seeded(t),events=f.events().length;
|
||||||
|
fs.appendFileSync(path.join(f.snapshot,'runtime.tar.zst'),'corrupt');
|
||||||
|
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/checksum mismatch/);
|
||||||
|
assert.equal(f.events().length,events);assert(!fs.existsSync(path.join(f.host,'rollback-state.json')));
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current-state backup failure blocks runtime swap and pointer/binding changes',async t=>{
|
||||||
|
const f=await seeded(t),binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
|
||||||
|
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
|
||||||
|
// Pack-only avoids snapshot validation's tar and reaches backup failure.
|
||||||
|
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/No snapshot published/);
|
||||||
|
assert.equal(f.rollbackState().applied,false);assert.equal(f.rollbackState().phase,'failed');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed snapshot startup retains both worlds and source pointers, never auto starts previous pack',async t=>{
|
||||||
|
const f=await seeded(t);fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||||
|
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/Rollback startup\/health failed/);
|
||||||
|
const state=f.rollbackState();assert.equal(state.phase,'failed');assert.equal(state.stopped_confirmed,true);assert(state.backup_snapshot);
|
||||||
|
assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity);
|
||||||
|
const up=f.events().filter(e=>e.command==='up');assert.equal(up.length,3);
|
||||||
|
assert.throws(()=>f.rollback({mode:'pack',version:'2.0.0',confirmCompatible:true}),/Unresolved rollback state/);
|
||||||
|
await assert.rejects(f.deploy(f.second),/Unresolved rollback state/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit snapshot recovery resolves a failed deployment using actual applied binding',async t=>{
|
||||||
|
const f=await seeded(t),third=f.next('3.0.0');fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||||
|
await assert.rejects(f.deploy(third),/Target startup\/health failed/);
|
||||||
|
const failure=f.state();assert.equal(failure.phase,'failed');
|
||||||
|
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/snapshot recovery/);
|
||||||
|
fs.unlinkSync(path.join(f.root,'.tools/fail-start'));
|
||||||
|
const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true});
|
||||||
|
assert.equal(state.source_release,'fixture-3.0.0');assert.equal(state.target_release,'fixture-1.0.0');assert.equal(state.phase,'complete');
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(state.prior_deployment_state)).phase,'failed');assert.equal(f.state().phase,'complete');
|
||||||
|
assert.equal(f.state().resolved_deployment,failure.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lock and snapshot ID/path validation precede mutation',async t=>{
|
||||||
|
const f=await seeded(t);fs.mkdirSync(path.join(f.host,'.mcpack-runtime.lock'));
|
||||||
|
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/runtime operation/);
|
||||||
|
fs.rmdirSync(path.join(f.host,'.mcpack-runtime.lock'));
|
||||||
|
assert.throws(()=>f.rollback({mode:'snapshot',backupId:'../escape',acceptProgressLoss:true}),/Snapshot rollback requires/);
|
||||||
|
assert.throws(()=>inspectRollback(f.host,{backupId:'../escape'}),/snapshot ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI confirmation flags cannot substitute for each other or leak to other commands',()=>{
|
||||||
|
assert.equal(parseArgs(['rollback']).acceptProgressLoss,false);
|
||||||
|
assert.equal(parseArgs(['rollback','--backup','id','--accept-progress-loss']).acceptProgressLoss,true);
|
||||||
|
assert.equal(parseArgs(['rollback-pack','0.0.4','--confirm-compatible']).confirmCompatible,true);
|
||||||
|
for(const args of [['rollback','--yes'],['rollback','--accept-progress-loss'],['rollback','--confirm-compatible'],['rollback-pack','0.0.4','--accept-progress-loss'],['rollback-pack'],['deploy','--confirm-compatible'],['restore','--backup','id','--destination','/new','--accept-progress-loss']])assert.throws(()=>parseArgs(args));
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import * as cryptoModule from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { verifyRelease, readSettings, runtime } from '../platform/runtime.mjs';
|
||||||
|
import { fixture, seal } from './helpers/runtime-fixture.mjs';
|
||||||
|
|
||||||
|
test('release validation rejects tampering, incomplete coverage, and extra files', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert(verifyRelease(f.release).identity);
|
||||||
|
fs.appendFileSync(path.join(f.release, 'fixture.mrpack'), 'tampered');
|
||||||
|
assert.throws(() => verifyRelease(f.release), /checksum mismatch/);
|
||||||
|
seal(f.release);
|
||||||
|
fs.writeFileSync(path.join(f.release, 'extra.txt'), 'unexpected');
|
||||||
|
assert.throws(() => verifyRelease(f.release), /coverage/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EULA/identity must be explicit and startup refusal leaves data/binding absent', t => {
|
||||||
|
const f = fixture(t), settings = path.join(f.host, '.env');
|
||||||
|
fs.writeFileSync(settings, fs.readFileSync(settings, 'utf8').replace('MC_EULA=TRUE', 'MC_EULA=FALSE'));
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /EULA acceptance/);
|
||||||
|
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||||
|
fs.appendFileSync(settings, 'MC_UID=123\n');
|
||||||
|
assert.throws(() => readSettings(settings), /Invalid local settings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh up binds identity, preserves local UID over ambient env, and same-release recreate works', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.env.MC_UID = '999';
|
||||||
|
assert.equal(f.run(['up', '-d']), 0);
|
||||||
|
const state = JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||||
|
assert.equal(state.project, 'fixture-server');
|
||||||
|
assert.equal(state.release_manifest_sha256, verifyRelease(f.release).identity);
|
||||||
|
const invocation = JSON.parse(fs.readFileSync(path.join(f.root, '.tools/invocation.json')));
|
||||||
|
assert.equal(invocation.uid, '1000');
|
||||||
|
assert.equal(invocation.gid, '1000');
|
||||||
|
assert.deepEqual(invocation.args.slice(0, 2), ['--project-name', 'fixture-server']);
|
||||||
|
fs.writeFileSync(path.join(f.host, 'runtime/world-probe'), 'preserve');
|
||||||
|
assert.equal(f.run(['create', '--force-recreate']), 0);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host, 'runtime/world-probe'), 'utf8'), 'preserve');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('another release/project cannot be applied to a bound runtime', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.run(['up', '-d']);
|
||||||
|
f.env.MC_PROJECT_NAME = 'another-server';
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /bound to another/);
|
||||||
|
f.env.MC_PROJECT_NAME = 'fixture-server';
|
||||||
|
fs.appendFileSync(path.join(f.release, 'fixture.mrpack'), 'another release');
|
||||||
|
seal(f.release);
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /bound to another/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing unidentified world cannot be silently adopted', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.mkdirSync(path.join(f.host, 'runtime'));
|
||||||
|
fs.writeFileSync(path.join(f.host, 'runtime/level.dat'), 'valuable data');
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /Nonempty runtime/);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.host, 'runtime/level.dat'), 'utf8'), 'valuable data');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed Compose startup preserves binding, propagates exit code, and releases lock', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.root, '.tools/fail-start'), '');
|
||||||
|
assert.equal(f.run(['up', '-d']), 19);
|
||||||
|
assert(fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('operation lock and compiled offline contract are enforced before runtime mutation', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.mkdirSync(path.join(f.host, '.mcpack-runtime.lock'));
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /Another runtime operation/);
|
||||||
|
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||||
|
fs.rmdirSync(path.join(f.host, '.mcpack-runtime.lock'));
|
||||||
|
fs.writeFileSync(path.join(f.root, '.tools/wrong-contract'), '');
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /offline release contract/);
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsafe paths and Compose file/project overrides are rejected', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
assert.throws(() => f.run(['up', '-f', 'another.yaml']), /controlled by the wrapper/);
|
||||||
|
assert.throws(() => f.run(['up', '-fanother.yaml']), /controlled by the wrapper/);
|
||||||
|
f.env.MC_ENV_FILE = path.join(f.release, 'server/.env.example');
|
||||||
|
assert.throws(() => f.run(['up']), /outside the immutable release/);
|
||||||
|
delete f.env.MC_ENV_FILE;
|
||||||
|
f.env.MC_ROOT = 'relative';
|
||||||
|
assert.throws(() => f.run(['up']), /absolute path/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('existing Compose project with another data root cannot be replaced or stopped', t => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.root, '.tools/existing-project'), '');
|
||||||
|
assert.throws(() => f.run(['up', '-d']), /belongs to another runtime root/);
|
||||||
|
assert.throws(() => f.run(['down']), /belongs to another runtime root/);
|
||||||
|
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||||
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bind address is validated and host settings override ambient bind address', t => {
|
||||||
|
const f=fixture(t),settings=path.join(f.host,'.env');
|
||||||
|
assert.equal(readSettings(settings).MC_BIND_ADDRESS,'127.0.0.1');
|
||||||
|
fs.writeFileSync(settings,fs.readFileSync(settings,'utf8').replace('MC_BIND_ADDRESS=127.0.0.1','MC_BIND_ADDRESS=0.0.0.0'));
|
||||||
|
assert.equal(readSettings(settings).MC_BIND_ADDRESS,'0.0.0.0');
|
||||||
|
f.env.MC_BIND_ADDRESS='127.0.0.1';
|
||||||
|
assert.equal(f.run(['up','-d']),0);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.root,'.tools/invocation.json'))).bind,'0.0.0.0');
|
||||||
|
fs.writeFileSync(settings,fs.readFileSync(settings,'utf8').replace('MC_BIND_ADDRESS=0.0.0.0','MC_BIND_ADDRESS=invalid.host'));
|
||||||
|
assert.throws(()=>readSettings(settings),/IPv4 address/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy immutable Compose honors explicit host bind using a temporary port-only overlay', t => {
|
||||||
|
const f=fixture(t),repository=new URL('../',import.meta.url).pathname;
|
||||||
|
fs.copyFileSync(path.join(repository,'.tools/docker-compose'),path.join(f.root,'.tools/docker-compose'));
|
||||||
|
const image=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json'))).server.image;
|
||||||
|
const compose=`services:\n minecraft:\n image: ${image}\n environment:\n TYPE: MODRINTH\n ONLINE_MODE: "false"\n ENFORCE_SECURE_PROFILE: "false"\n MODRINTH_MODPACK: /modpacks/fixture.mrpack\n ports:\n - "127.0.0.1:25565:25565"\n volumes:\n - "\${MC_ROOT}/runtime:/data"\n - "\${MC_RELEASE_DIR}:/modpacks:ro"\n - "\${MC_RELEASE_DIR}/server/config:/config:ro"\n`;
|
||||||
|
fs.writeFileSync(path.join(f.release,'server/compose.yaml'),compose);
|
||||||
|
const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));
|
||||||
|
const {createHash}=cryptoModule;
|
||||||
|
manifest.tools.compose.sha256=createHash('sha256').update(fs.readFileSync(path.join(f.root,'.tools/docker-compose'))).digest('hex');
|
||||||
|
fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||||
|
fs.writeFileSync(path.join(f.host,'.env'),fs.readFileSync(path.join(f.host,'.env'),'utf8').replace('MC_BIND_ADDRESS=127.0.0.1','MC_BIND_ADDRESS=0.0.0.0'));
|
||||||
|
const before=fs.readFileSync(path.join(f.release,'server/compose.yaml'),'utf8');
|
||||||
|
const result=JSON.parse(runtime({repository:f.root,env:f.env,args:['config','--format','json'],capture:true}));
|
||||||
|
assert.equal(result.services.minecraft.ports.length,1);assert.equal(result.services.minecraft.ports[0].host_ip,'0.0.0.0');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.release,'server/compose.yaml'),'utf8'),before);
|
||||||
|
assert(!fs.existsSync(path.join(f.host,'runtime')));
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Pinned tools, Linux x86_64. Game and mod versions live only in pack/.
|
||||||
|
PACKWIZ_COMMIT=ef87d964f8cbd52b3b13ea42453ef322290e2b9e
|
||||||
|
PACKWIZ_URL=https://nightly.link/packwiz/packwiz/actions/runs/34043101039/Linux%2064-bit%20x86.zip
|
||||||
|
PACKWIZ_ARCHIVE_SHA256=840d193d4c0e8370387e275f9dd985fd548d19c2dbdb9b0c8093248bff1d8f3b
|
||||||
|
PACKWIZ_BINARY_SHA256=7f8ed641be0c28988895651e3270cc9ad4b40c436e3d5cf9b3bcdb718b4369c6
|
||||||
|
COMPOSE_VERSION=v5.6.0
|
||||||
|
COMPOSE_URL=https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-linux-x86_64
|
||||||
|
COMPOSE_SHA256=40343e21ca777173e69cff5dbafeb37c6f81f3b0d57d9e597f036e95eb63e76a
|
||||||
|
MC_IMAGE=itzg/minecraft-server@sha256:99dc8b37f97c3b95915452180bbedf29a7ac8252932e372ce6e5b6e383a5d1e2
|
||||||
Reference in New Issue
Block a user