first cycle lab release

This commit is contained in:
2026-10-04 00:30:09 +07:00
parent 85f6897365
commit d134daafd8
92 changed files with 5623 additions and 9 deletions
+23
View File
@@ -1 +1,24 @@
*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-*/
+157
View File
@@ -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).
View File
+118
View File
@@ -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).
+94
View File
@@ -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
View File
@@ -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).
+237
View File
@@ -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.
+85
View File
@@ -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).
+52
View File
@@ -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.
+36
View File
@@ -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 этим изменением не выполнялась.
+51
View File
@@ -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 проверен свежим скачиванием.
+47
View File
@@ -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 лишь фиксирует контракт.
+41
View File
@@ -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).
+43
View File
@@ -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).
+74
View File
@@ -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.
+61
View File
@@ -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.
+94
View File
@@ -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.
+32
View File
@@ -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 остаётся непроверенной веткой.
+29
View File
@@ -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 нет.
+42
View File
@@ -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 и рабочий сервер не менялись.
+47
View File
@@ -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
View File
@@ -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`.
+118
View File
@@ -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
+20
View File
@@ -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"
+13
View File
@@ -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"
+26
View File
@@ -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"
Executable
+4
View File
@@ -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" "$@"
+40 -8
View File
@@ -6,7 +6,9 @@
Платформа должна быть оптимизирована под:
- Prism Launcher как клиентский лаунчер.
- пользовательские модпаки на NeoForge и Forge;
- запуск без обязательного Microsoft/Mojang-аккаунта и без онлайн-проверки сессии;
- Prism Launcher как первоначальный клиентский лаунчер.
- Docker Compose как серверный runtime.
- удалённый доступ к серверу по SSH.
- Git как источник истины для описания сборки и конфигурации.
@@ -28,6 +30,8 @@
Не разрабатывать собственный resolver зависимостей Minecraft-модов без реальной необходимости.
Авторизация аккаунтов не является задачей платформы. Целевой сервер использует online-mode=false и enforce-secure-profile=false; для Prism допускается offline-аккаунт/Launch Offline. NeoForge и Forge — целевые loader; проверка Fabric не заменяет их integration spike.
---
# 2. Основная архитектура
@@ -447,6 +451,8 @@ services:
EULA: "${MC_EULA:?Set MC_EULA after accepting the Minecraft EULA}"
MEMORY: "${MC_MEMORY:-8G}"
TYPE: MODRINTH
ONLINE_MODE: "false"
ENFORCE_SECURE_PROFILE: "false"
MODRINTH_MODPACK: /modpack/friday-minecraft-0.4.0.mrpack
volumes:
- ${MC_ROOT:?Set absolute server root}/runtime:/data
@@ -548,6 +554,8 @@ runtime/
Manifest содержит управляемые пути. Удалять только файлы прежнего релиза; неизвестные файлы не удалять молча. Для конфигов, изменяемых самим runtime, определить в spike политику replace, seed-only или preserve. Проверить defaultconfigs и datapacks на существующем мире.
Не определять тип или владельца конфига только по директории: в проверяемом NeoForge 1.21.1 серверные конфиги создаются в `runtime/config/`. Путь, поддержку `defaultconfigs/` и per-world overrides проверять для закреплённого loader; результаты одной ветки не переносить автоматически на Forge.
Не коммитить runtime-generated файлы автоматически.
В будущем добавить явную команду для переноса полезных runtime-изменений обратно в source.
@@ -949,7 +957,8 @@ mcpack doctor --remote
Задачи:
- создать минимальный packwiz pack;
- добавить один Modrinth-мод;
- выбрать и закрепить NeoForge или Forge и совместимую версию Minecraft;
- добавить минимальный совместимый набор Modrinth-модов, включая gameplay-мод и client-only мод;
- экспортировать `.mrpack`;
- импортировать `.mrpack` в Prism;
- запустить совместимый modded server через `itzg/minecraft-server`;
@@ -969,19 +978,21 @@ mcpack doctor --remote
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:
- Prism импортирует сгенерированный pack;
- сервер успешно запускается;
- modded client подключается к серверу;
- modded client с целевым loader подключается к серверу без онлайн-проверки аккаунта;
- runtime-файлы существуют как обычные host-файлы.
---
## 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
Статус: завершена. `./mcpack build` читает platform config и создаёт полный development release 0.0.4; `--release` требует чистого Git worktree. Проверки и текущие ограничения описаны в `docs/phase2-results.md`, контракт — в `docs/build.md`.
Реализовать:
```bash
@@ -1043,6 +1056,8 @@ dist/<name>-<version>/manifest.json
## Phase 3 — Управление модами
Статус: завершена. Mod wrappers сохраняют вывод/prompts/exit code packwiz. Реальные add/remove/update/update --all и четыре full build проверены в тестовом clone; результаты — `docs/phase3-results.md`, контракт — `docs/mods.md`.
Предоставить тонкие wrappers:
```bash
@@ -1064,6 +1079,8 @@ Acceptance criteria:
## 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, который:
- использует pinned Minecraft version;
@@ -1084,6 +1101,8 @@ docker compose up -d
## 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 и проверенную процедуру восстановления.
Acceptance criteria: ошибка backup блокирует применение; snapshot восстанавливается в отдельный каталог; данные модов вне world сохраняются.
@@ -1092,12 +1111,16 @@ Acceptance criteria: ошибка backup блокирует применение
## 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.
---
## 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.
Acceptance criteria:
@@ -1114,7 +1137,8 @@ Acceptance criteria:
## Phase 8 — Rollback
Реализовать rollback предыдущего release.
Статус: завершена. Реализованы pack-only и snapshot rollback по SSH без build.
Результаты и ограничения: [docs/phase8-results.md](docs/phase8-results.md).
Acceptance criteria:
@@ -1124,13 +1148,21 @@ mcpack rollback
без указания режима ничего не меняет. Явный rollback использует сохранённый полный релиз без повторного build; pack-only требует подтверждения совместимости.
Восстановление persistent snapshot может оставаться ручным, но процедура обязательна и проверяется на практике.
Восстановление persistent snapshot автоматизировано и проверено на изолированном
SSH/Docker-стенде. Оба режима требуют явного подтверждения и проверенного backup
текущего состояния; pointers меняются после health. Ручной recovery после ошибки
описан в [docs/rollback.md](docs/rollback.md).
---
## 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
mcpack release <version>
@@ -1579,7 +1611,7 @@ mcpack logs
- client artifact импортируется в Prism;
- сервер запускается из того же определения сборки;
- клиент может подключиться;
- клиент на NeoForge/Forge может подключиться без Microsoft/Mojang-аккаунта;
- моды версионируются через packwiz;
- server files хранятся как обычные директории на host;
- world переживает recreation контейнера;
+1
View File
@@ -0,0 +1 @@
neoforge-pack-v1
+20
View File
@@ -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
+13
View File
@@ -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"
+13
View File
@@ -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"
+13
View File
@@ -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"
+13
View File
@@ -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"
+46
View File
@@ -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"
}
}
}
}
+9
View File
@@ -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" }
}
+25
View File
@@ -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).
+244
View File
@@ -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);
}
}
+325
View File
@@ -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);
}
}
+103
View File
@@ -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;
}
+13
View File
@@ -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"
}
]
}
+13
View File
@@ -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
+183
View File
@@ -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});}
});
}
+89
View File
@@ -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 };
}
+63
View File
@@ -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);}
}
+152
View File
@@ -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});
}
});
}
+234
View File
@@ -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 }); }
}
View File
+40
View File
@@ -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 }
}
}
}
}
+155
View File
@@ -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});}
}
View File
+38
View File
@@ -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
+23
View File
@@ -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();}
+27
View File
@@ -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;
}
+26
View File
@@ -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;}});
+9
View File
@@ -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;
}
+5
View File
@@ -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" "$@"
+34
View File
@@ -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"
+26
View File
@@ -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"
+12
View File
@@ -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" "$@"
+19
View File
@@ -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
+56
View File
@@ -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"
+75
View File
@@ -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'
+84
View File
@@ -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'
+130
View File
@@ -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"
+122
View File
@@ -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"
+46
View File
@@ -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"
+8
View File
@@ -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
+30
View File
@@ -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 появятся в следующих фазах.
+11
View File
@@ -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
+10
View File
@@ -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
+36
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
server-override-v1
@@ -0,0 +1 @@
{"values":["phase0:load"]}
@@ -0,0 +1 @@
say [Phase0] datapack-v2-loaded
+6
View File
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "Phase 0 existing-world probe"
}
}
+155
View File
@@ -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);
});
+165
View File
@@ -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/);
});
+76
View File
@@ -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));
});
+122
View File
@@ -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');
});
+82
View File
@@ -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']));
});
+75
View File
@@ -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']);
}
+45
View File
@@ -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);
}
+82
View File
@@ -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 }) };
}
+64
View File
@@ -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']),'');
});
+110
View File
@@ -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));
});
+132
View File
@@ -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')));
});
+9
View File
@@ -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