diff --git a/.gitignore b/.gitignore index 222b181..67cfb35 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,24 @@ -*Zone.Identifier \ No newline at end of file +*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-*/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..718e9b0 --- /dev/null +++ b/README.md @@ -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 +./mcpack remove +./mcpack update +./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). diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..51578ea --- /dev/null +++ b/docs/backups.md @@ -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/-/`: + +```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). diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000..7c38a6f --- /dev/null +++ b/docs/build.md @@ -0,0 +1,94 @@ +# Build contract — Phase 2 + +Из корня проекта, с Node.js 22+ и npm: + +```bash +npm ci --ignore-scripts +scripts/spike-tools.sh +./mcpack build +``` + +Дополнительные параметры: + +```bash +./mcpack build --config platform/config.yaml +./mcpack build --release +``` + +`--config` выбирает YAML manifest; относительные пути pack/output внутри +него считаются от корня репозитория. По умолчанию build разрешает dirty +worktree и записывает это в manifest. `--release` требует чистого worktree. +Команды не делают commit/tag/deploy и не запускают Minecraft. + +Имя, версия, Minecraft и loader читаются из TOML packwiz. Полный релиз +создаётся в `/-/` и содержит: + +```text +-.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 и записи результата. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..c861819 --- /dev/null +++ b/docs/deploy.md @@ -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). diff --git a/docs/fabric-spike-results.md b/docs/fabric-spike-results.md new file mode 100644 index 0000000..00d1217 --- /dev/null +++ b/docs/fabric-spike-results.md @@ -0,0 +1,237 @@ +# Phase 0 — integration spike + +Дата: 2026-10-03. Статус: **предварительный Fabric-эксперимент проверен; целевой Phase 0 должен проверить NeoForge/Forge без аккаунтов**. +GUI-проверки не заменяются проверкой ZIP или Minecraft status query. + +## Окружение и версии + +Arch Linux x86_64 в WSL2, kernel `6.18.40.1-microsoft-standard-WSL2`. +Изначально отсутствовали Linux packwiz, Docker, Compose, Go и Java. +В Windows обнаружен каталог PrismLauncher; аккаунты и существующие instances +не читались и не изменялись. + +| Компонент | Зафиксированное значение | +| --- | --- | +| Minecraft | 1.21.1 | +| Fabric Loader | 0.19.5 | +| Fabric installer/launcher | 1.1.2, наблюдаемое значение в .fabric-manifest.json | +| Fabric API | 0.116.17+1.21.1, Modrinth `Mys3P7lK`, both | +| Mod Menu | 11.0.5, Modrinth `6lgOkclV`, client | +| Text Placeholder API | 2.4.2+1.21, Modrinth `U5bhVym2`, client в этой сборке | +| packwiz commit | `ef87d964f8cbd52b3b13ea42453ef322290e2b9e` | +| packwiz GitHub Actions run | `34043101039`, Linux 64-bit x86 | +| packwiz binary SHA-256 | `7f8ed641be0c28988895651e3270cc9ad4b40c436e3d5cf9b3bcdb718b4369c6` | +| Prism Launcher | 11.0.3-main, commit d393323fdc52ef645c48a04ca73aff2bbe24ffc9 (из локального лога) | +| JVM сервера | Temurin 21.0.12.1+1-LTS | +| Docker Engine | 29.8.2, storage driver overlayfs | +| Compose | v5.6.0, локальный официальный binary | +| itzg image digest | `sha256:99dc8b37f97c3b95915452180bbedf29a7ac8252932e372ce6e5b6e383a5d1e2` | +| itzg source revision | `beed693663b4c963d1bb4af1101855eccbc6a37b` | +| itzg image creation | 2026-10-02T12:03:38.864Z | + +Образ первоначально скачан по тегу java21; Compose использует только digest. +JVM проверена без запуска Minecraft: Temurin OpenJDK 21.0.12.1+1-LTS. +Команда: `docker run --rm --entrypoint java <закреплённый-image> -version`. + +## Фактически выполнено + +1. Получены точные версии из официальных GitHub, Fabric и Modrinth API. +2. Скачан официальный packwiz из привязанного к commit workflow run; + SHA-256 архива и binary закреплены в `toolchain.lock.env`. +3. Установлен Docker в WSL (`pacman -S --needed --noconfirm docker`), + daemon запущен командой `systemctl start docker`, без enable при загрузке. +4. Скачан и проверен SHA-256 официального Compose v5.6.0 в `.tools/`. +5. Создан source pack и добавлены закреплённые моды через packwiz. +6. Для Text Placeholder API сторона явно изменена с both на client: + здесь библиотека нужна только client-only Mod Menu. +7. Обновлён source index, выполнен export в временной копии source. +8. Проверены содержимое ZIP, версии dependencies и env каждой записи. +9. `scripts/spike-server.sh config --quiet` и `bash -n scripts/*.sh` успешны. +10. После явного принятия EULA пользователем выставлен MC_EULA=TRUE в игнорируемом server/.env. +11. Первый запуск, status healthcheck, side filtering и bind mounts проверены на реальном сервере. +12. Проверены recreate, смена версии мода, удаление модов, конфиги и datapack на существующем мире. +13. Startup с отсутствующим mrpack вернул 1; hash level.dat при ошибке не изменился. +14. Исходный snapshot восстановлен в отдельный каталог; world/config совпали до запуска, сервер 0.0.1 снова healthy. +15. `git diff --check` успешен; .tools, артефакты, world, backups и .env игнорируются Git. + +Артефакт: + +```text +dist/minecraft-lab-spike-0.0.1/minecraft-lab-spike-0.0.1.mrpack +SHA-256: 923fa6e5b54921b7b19925bbe634d91e09b113ccd512f1edaaeab818de3a9338 +``` + +ZIP содержит `modrinth.index.json` и `overrides/config/spike-marker.txt`. +Fabric API помечен required на обеих сторонах; Mod Menu и Text Placeholder API +имеют `server: unsupported`. На установленном сервере подтверждено отсутствие обоих client-only JAR. +Маркер проверяет копирование произвольного конфига; его не читает игровой мод. + +## Команды создания и воспроизведения + +Первоначальный init из `pack/`: + +```bash +../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml init \ + --name minecraft-lab-spike --version 0.0.1 --author minecraft-lab \ + --mc-version 1.21.1 --modloader fabric --fabric-version 0.19.5 -y +../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml modrinth add --project-id P7dR8mSH --version-id Mys3P7lK -y +../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml modrinth add --project-id mOgUt4GM --version-id 6lgOkclV -y +# Явно изменить side в placeholder-api.pw.toml на client. +../.tools/packwiz/packwiz --cache ../.tools/packwiz/cache --config ../.tools/packwiz/config.toml refresh +``` + +В новом checkout достаточно уже закоммиченного source: + +```bash +scripts/spike-tools.sh +scripts/spike-build.sh +scripts/spike-server.sh config --quiet +bsdtar -xOf dist/minecraft-lab-spike-0.0.1/minecraft-lab-spike-0.0.1.mrpack modrinth.index.json +``` + +Для изменения source packwiz запускается из `pack/` с локальными cache/config, +как в командах выше. Проверка SHA-256 артефакта выполняется из его каталога: + +```bash +cd dist/minecraft-lab-spike-0.0.1 +sha256sum -c SHA256SUMS +``` + +Build v0 не создаёт production release manifest, Git tags или удалённый deployment. +Игровые версии читаются из pack.toml; выбор MC_PACK — ссылка на готовый артефакт. + +## Матрица проверки + +| Проверка | Статус | +| --- | --- | +| packwiz описывает и экспортирует сборку | PASS | +| ZIP содержит закреплённые dependencies и side metadata | PASS | +| Compose config валиден | PASS | +| Prism импортирует и запускает pack | PASS: instance 0.0.1, Minecraft/Fabric и три JAR подтверждены | +| Сервер устанавливает локальный mrpack и становится healthy | PASS: mc-health, 1.21.1, online=0 | +| На сервере нет client-only JAR | PASS: только fabric-api JAR | +| World и config находятся в host bind mount | PASS | +| World переживает recreation контейнера | PASS: level.dat hash до запуска совпал | +| Обновление удаляет старый mod JAR | PASS: 0.116.17 → 0.116.16; затем удалены все JAR | +| Конфиги: pack defaults, server override, ручная правка | PASS: override победил; update перезаписал ручную правку | +| Backup/restore возвращает world и прежний release | PASS: восстановлен baseline и pack 0.0.1 | +| Startup failure возвращает non-zero | PASS: отсутствующий mrpack, exit 1; world не изменён | +| Клиент Prism подключается к offline-серверу | PENDING: повторная попытка после online-mode=false | +| Datapack на существующем мире | PASS: file/phase0 автоматически загружен в 0.0.2 | +| NeoForge/Forge defaultconfigs | НЕ ПРОВЕРЕНО: другой loader | + +## Порядок серверных проверок + +После принятия EULA скопировать `.env.example` в `.env` и выставить MC_EULA=TRUE. +Сервер изолирован локальным адресом, без публикации RCON. Первоначально ошибочно требовался online-mode=true; по уточнению пользователя переключён на online-mode=false и enforce-secure-profile=false. Microsoft/Mojang-аккаунт не требуется. + +```bash +scripts/spike-server.sh up -d --wait --wait-timeout 600 +scripts/spike-server.sh ps +scripts/spike-server.sh logs --tail 100 +scripts/spike-server.sh exec -T minecraft java -version +scripts/spike-server.sh exec -T minecraft mc-health +ls runtime/mods runtime/config runtime/world +``` + +Затем остановить сервер и записать hash `runtime/world/level.dat`. +Recreate выполняется при остановленном сервере: + +```bash +scripts/spike-server.sh stop +sha256sum runtime/world/level.dat +scripts/spike-server.sh create --force-recreate +sha256sum runtime/world/level.dat +scripts/spike-server.sh start +``` + +Два hash до запуска должны совпасть. После старта world может штатно измениться; +это не потеря данных. Дополнительно проверить UUID/seed и игровой маркер из клиента. + +Для воспроизведения вариантов на новом checkout после исходного build выполнить `scripts/spike-variants.sh`. Скрипт создаёт source-копии в .tools/repro-variants и артефакты 0.0.2/0.0.3; runtime не меняет. Повторный запуск при существующих каталогах прекращается без перезаписи. + +0.0.2: Fabric API fpB2VatZ (0.116.16+1.21.1), marker v2, статический datapack из testdata/spike-datapack. 0.0.3: удалены Mod Menu, Text Placeholder API и Fabric API, остаётся loader и config marker. Исходный pack/ всё время оставался на 0.0.1. +Перед сменой MC_PACK обязательно остановить сервер и создать snapshot. +Проверить удаление прежнего JAR и фактическое состояние marker. +Возврат старого pack выполнять вместе с соответствующим snapshot. + +Минимальная ручная процедура backup (в корне, после stop): + +```bash +mkdir -p backups +task_snapshot="backups/$(date -u +%Y%m%dT%H%M%SZ)-$(date +%s%N).tar.zst" +tar --exclude=./.gitkeep -C runtime -I zstd -cf "$task_snapshot" . +tar -I zstd -tf "$task_snapshot" > /dev/null +sha256sum "$task_snapshot" > "$task_snapshot.sha256" +# Записать рядом исходный MC_PACK и SHA256SUMS релиза. +``` + +Restore: сервер остановлен; сначала сохранить текущее состояние. Проверить hash +архива, распаковать в новый каталог рядом с runtime и проверить файлы. +Переименовать текущий runtime в сохранённый каталог, новый — в runtime; +выбрать исходный MC_PACK и запустить с healthcheck. Не распаковывать поверх мира. +Этот ручной сценарий проверен: snapshot phase0-before-update восстановлен в отдельный каталог, исходный runtime сохранён в .tools/runtime-before-restore, затем запущен pack 0.0.1. + +Prism: импортировать исходный .mrpack, запустить Minecraft, проверить Mod Menu, +зайти на localhost:25565, поставить различимый игровой маркер и проверить его +после recreation. Записать версию Prism, JVM клиента и фактический результат. + +## Фактические результаты переходов + +- Первый запуск: Minecraft 1.21.1, Fabric 0.19.5, JVM Temurin 21.0.12.1+1; mc-health отвечает version=1.21.1 online=0 max=20. +- Docker inspect: /data — rw bind на runtime/, /modpacks — ro bind на dist/. Named volumes отсутствуют. +- Recreate: до повторного запуска SHA-256 level.dat остался `32832255e84e2d8b4e324527d21b233b0b0cc4615ae17ee0296e7e8c5d4d9728`. +- Update 0.0.1 → 0.0.2: только fabric-api-0.116.16+1.21.1.jar, старый 0.116.17 удалён; manual-runtime-v1 заменён на pack-default-v2. +- Datapack: лог «Found new data pack file/phase0, loading it automatically», затем «[Phase0] datapack-v2-loaded». +- Optional override: `MC_PACK=minecraft-lab-spike-0.0.2/minecraft-lab-spike-0.0.2.mrpack scripts/spike-server.sh -f server/compose.spike-override.yaml up -d --wait --wait-timeout 120`; marker стал server-override-v1. +- Update → 0.0.3 без override: runtime/mods пуст, управляемые файлы прежнего datapack удалены, unmanaged-probe.txt сохранился. +- Failure: MC_PACK=missing-phase0.mrpack, Compose --wait вернул 1; контейнер exited(1), hash мира остался прежним. +- Restore: sha256sum snapshot проверен, распаковка в .tools/runtime-restored, сравнение world/config с baseline успешно; прежний runtime сохранён, восстановленный переименован в runtime. После запуска исходного pack 0.0.1 сервер healthy. + +Каждый изменяющий тест предварялся корректной остановкой и snapshot. Пять архивов в backups/ имеют SHA-256 и JSON metadata: исходный артефакт, image digest, server override и время; исключений нет. Имена phase0-* относятся к однократному запуску этого эксперимента. Для обычных backups использовать уникальные ID из примера выше. + +Артефакты тестовых переходов (ZIP timestamps могут отличаться при повторном export): + +| Релиз | SHA-256 | +| --- | --- | +| 0.0.2 | ca10d1faffa8a71ee58431f7f3f2ee88be4c7572e035cac8824331129b701fab | +| 0.0.3 | ced6fe830055ce3f0ae2d1c4c14e0fb41b5a8580bdaf5cac3010b0bdc8a9ceba | + +Финальное состояние: сервер запущен с исходным pack 0.0.1 и восстановленным baseline, optional override выключен. Импорт и запуск Prism подтверждены локальными instance metadata и client log. Подключение не завершилось: Failed to log in: The authentication servers are currently not reachable. В client log sessionHost/servicesHost=https://nope.invalid и UnknownHostException: nope.invalid; launcher log содержит четыре -Dminecraft.api.*.host с этим адресом. В pack и instance/global JvmArgs такого override нет, переменные JAVA_TOOL_OPTIONS/JDK_JAVA_OPTIONS/_JAVA_OPTIONS Windows этих overrides не содержат. Для offline-клиента эти параметры не являются ошибкой настройки: изначальное требование online-mode=true противоречило цели пользователя. Требование аккаунта снято; нужен offline-server login. Account files и токены не читались. + +## Обнаруженные ограничения и изменения архитектуры + +- packwiz автоматически добавил Text Placeholder API и выставил both, хотя он + нужен этой сборке только на клиенте. Metadata проекта не заменяет проверку + конкретного состава зависимостей. Свой resolver не нужен. +- В Arch package index Compose 5.5.1 ссылался на отсутствующий файл (HTTP 404). + Не обновлялась вся система: Docker установлен отдельно, Compose скачан локально. +- В минимальном окружении отсутствует cmp; build сравнивает SHA-256 source и + refresh-копии вместо дополнительной системной зависимости. +- Network-вызовы sandbox требуют разрешения. Подстановка latest не используется + в воспроизводимых командах; закреплены результаты первоначального выбора. +- GitHub Actions artifacts имеют ограниченный срок хранения. Bootstrap по run ID + пригоден для spike, но не для долговременного release tooling. Для него нужен + build packwiz из закреплённого commit или сохранённый binary с checksum. +- Пакет содержит ссылки на Modrinth downloads, а не все JAR; offline install + не доказан. Образ также загружает Minecraft и loader при первом старте. +- Windows Test-NetConnection 127.0.0.1:25565 вернул TcpTestSucceeded=True после восстановления. GET sessionserver hasJoined без сессии возвращает 204 из Windows, WSL и контейнера; GET api.minecraftservices.com/publickeys возвращает 200 из Windows и контейнера. Эти пробы не передавали токены и не подтверждают login, но исключают общую недоступность этих endpoints. +- Минимальная сборка использует Fabric API, не добавляя новые блоки/предметы. Она доказывает установку loader и модов; сетевую совместимость content-модов понадобится проверить на реальной сборке. +- Fabric был неверно выбран вместо целевых NeoForge/Forge. Его результаты сохраняются только как предварительный эксперимент. Целевой Phase 0 ещё требует modpack на NeoForge/Forge, defaultconfigs и подключения offline-клиента. +- При смене mrpack управляемый config заменяется, включая ручную правку. Неуправляемый файл сохраняется. Требуется проверка drift в будущей платформе до применения пакета. +- Server override через /config с SYNC_SKIP_NEWER_IN_DESTINATION=false применяется после modpack defaults и побеждает их. Это намеренная политика теста, не универсальная политика production. +- Datapacks нужны в world/datapacks, а не в корневом /data/datapacks. В варианте 0.0.2 проверен путь overrides/world/datapacks/phase0. Это статические исходники datapack, не snapshot мира. Если сохранить предложенный pack/datapacks layout, нужен явный перенос в целевой world path; world name должен учитываться. +- Важное ограничение воспроизводимости: local mrpack закрепляет loader, но не версию Fabric installer/launcher. В runtime наблюдается 1.1.2. В закреплённом образе TYPE=MODRINTH вызывает install-modrinth-modpack напрямую, не передавая FABRIC_LAUNCHER_VERSION; в help этого helper нет соответствующего параметра. Поэтому простое добавление Fabric env в Compose не доказывает фиксацию installer. Перед production release нужно выбрать проверенный способ фиксировать/сохранять launcher или явно ограничить обещание воспроизводимости. Собственный mod resolver для этого не нужен. +- Production deployment и автоматический rollback остаются вне scope Phase 0. + +Официальные источники: + +- [packwiz installation](https://packwiz.infra.link/installation/) +- [packwiz Modrinth export](https://packwiz.infra.link/tutorials/hosting/modrinth/) +- [itzg local mrpack](https://docker-minecraft-server.readthedocs.io/en/latest/types-and-platforms/mod-platforms/modrinth-modpacks/) +- [itzg healthcheck](https://docker-minecraft-server.readthedocs.io/en/latest/misc/healthcheck/) +- [Modrinth format](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack) + +## Уточнение цели пользователем + +Собирать пользовательские модпаки NeoForge/Forge, распространять клиентский артефакт и запускать сервер без проверки аккаунтов. ONLINE_MODE=false, ENFORCE_SECURE_PROFILE=false. Смена auth режима предварена backup phase0-before-offline-mode. Проверка Microsoft login не входит в acceptance criteria. diff --git a/docs/health.md b/docs/health.md new file mode 100644 index 0000000..ba52d58 --- /dev/null +++ b/docs/health.md @@ -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). diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 0000000..7c86f07 --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,52 @@ +# Управление модами — Phase 3 + +Из корня репозитория, после установки npm dependencies и packwiz: + +```bash +./mcpack add mouse-tweaks +./mcpack remove mouse-tweaks +./mcpack update sophisticated-backpacks +./mcpack update --all +``` + +`add` использует Modrinth; принимает slug, project ID, URL или одну строку +поиска в кавычках. Конкретная версия выбирается штатным version URL: + +```bash +./mcpack add https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X +``` + +`remove` и `update ` используют имя 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. diff --git a/docs/network-results.md b/docs/network-results.md new file mode 100644 index 0000000..a49e2ce --- /dev/null +++ b/docs/network-results.md @@ -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 этим изменением не выполнялась. diff --git a/docs/phase1-results.md b/docs/phase1-results.md new file mode 100644 index 0000000..5aad3fc --- /dev/null +++ b/docs/phase1-results.md @@ -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 проверен свежим скачиванием. diff --git a/docs/phase2-results.md b/docs/phase2-results.md new file mode 100644 index 0000000..550e33b --- /dev/null +++ b/docs/phase2-results.md @@ -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 лишь фиксирует контракт. diff --git a/docs/phase3-results.md b/docs/phase3-results.md new file mode 100644 index 0000000..abed6fb --- /dev/null +++ b/docs/phase3-results.md @@ -0,0 +1,41 @@ +# Phase 3: mod wrappers + +Phase 3 завершена: `mcpack add`, `remove`, `update `, `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). diff --git a/docs/phase4-results.md b/docs/phase4-results.md new file mode 100644 index 0000000..611ebb3 --- /dev/null +++ b/docs/phase4-results.md @@ -0,0 +1,43 @@ +# Phase 4: server runtime + +Phase 4 завершена для NeoForge 21.1.252 / Minecraft 1.21.1. Full release +0.0.4 реально запущен с пустой директорией данных через `scripts/server.sh`. + +## Реальная Docker-проверка + +- Server root: `.tools/phase4-server`, project: `minecraft-lab-phase4`. +- Настройки: EULA=TRUE по ранее полученному принятию, UID/GID=1000:1000, + memory=2G, IPv4 loopback port=25566. +- Первый `up -d --wait --wait-timeout 600` завершился healthy. Лог подтвердил + создание нового мира, Minecraft 1.21.1, NeoForge 21.1.252, Backpacks 3.26.7 + и Core 1.5.5. Mouse Tweaks отсутствовал в серверных JAR. +- Docker inspect подтвердил обычные абсолютные bind mounts: host runtime + → `/data` rw, release → `/modpacks` ro, server/config → `/config` ro. +- `world/level.dat` принадлежит 1000:1000. Pack загружен из `.mrpack`, + server override marker=`server-override-v1`, `online-mode=false` и + `enforce-secure-profile=false` подтверждены. +- Restart того же контейнера завершился healthy. +- После остановки и `create --force-recreate` все 23 файла мира совпали + по путям/SHA256 до нового запуска. Неуправляемый config marker сохранился. +- Вновь запущенный контейнер healthy; mc-health отвечает Minecraft 1.21.1. +- Финальная версия wrapper дополнительно проверена реальным up/exec/down + после добавления проверки принадлежности Compose project. +- Тестовый контейнер/network удалены через down, runtime и binding сохранены. + Основной сервер на 25565 не пересоздавался и остался healthy. + +Свидетельства: `.tools/phase4-evidence/first-start.log`, `mounts.json`, +`before-recreate-world.json`, `recreate-verification.txt`, +`recreated-start.log`. Test root остаётся пригодным для последующих проверок. + +## Автоматические проверки + +`npm test`: 25 тестов. Новые runtime tests используют controlled Compose/Docker +substitutes и проверяют integrity/coverage, EULA/UID/GID, immutable binding, +смену release/project, защиту существующего мира, failed startup/exit code, +operation lock, offline/bind contract, paths и конфликт project другого root. +Build/mod regression tests также проходят. Реальная Docker-проверка выше +подтверждает integration, которую substitutes сами по себе не доказывают. + +Bash/Node syntax и `git diff --check` пройдены. Backup, миграция существующего +мира, remote deployment и расширенная диагностика healthcheck не реализуются +этим этапом. Контракт и команды — [runtime.md](runtime.md). diff --git a/docs/phase5-results.md b/docs/phase5-results.md new file mode 100644 index 0000000..6200e83 --- /dev/null +++ b/docs/phase5-results.md @@ -0,0 +1,74 @@ +# Phase 5: backups и восстановление + +Phase 5 завершена для NeoForge 21.1.252 / Minecraft 1.21.1. Реализованы +`./mcpack backup`, `./mcpack restore` и обязательный gate `backupBeforeApply` +для будущего deployment. Контракт и проверенная инструкция: [backups.md](backups.md). + +## Проверка с реальным Docker + +Проверка 2026-10-03 выполнялась на отдельном root `.tools/phase4-server`, +project `minecraft-lab-phase4`, port 25566, full release 0.0.4. Основной +Phase 0 сервер на 25565 не останавливался и остался healthy. + +- Запущенный сервер был корректно остановлен; backup всего runtime + опубликован после tar/zstd listing и пробной распаковки. +- Snapshot содержит runtime archive, metadata, полную копию release 0.0.4 + и checksums всех файлов. Исключений нет. +- После backup сервер автоматически вернулся на исходный release в healthy; + `mc-health` подтвердил Minecraft 1.21.1. +- Затем намеренно подставлена команда tar с exit 42. Backup вернул non-zero, + оставил сервер остановленным, не создал дополнительный окончательный + snapshot и удалил временный staging. Metadata операции фиксирует failed. +- Старый тестовый контейнер удалён через down. Snapshot восстановлен в новый + `.tools/phase5-restored-server` без распаковки поверх существующего root. +- До запуска совпали все 416 inventory entries (160 файлов, включая 23 файла + world): пути, bytes/SHA256, mode, UID и GID. В наборе есть владельцы + 1000:1000 и 0:0; обе группы сохранились. +- Подтверждены `mod-state/phase5.json` и unmanaged config вне world, + server.properties, ops.json, whitelist.json. Локальный host `.env` не + копировался: при запуске использован уже настроенный файл прежнего test root. +- Восстановленная копия запущена с собственным сохранённым `release/`: + NeoForge 21.1.252, Minecraft 1.21.1, статус healthy и успешный `mc-health`. +- После проверки восстановленный контейнер/network удалены через down. + Оба тестовых runtime и snapshot сохранены для дальнейших проверок. + +Проверенный snapshot: + +```text +.tools/phase4-server/backups/20261003T134409150Z-edd387d7-f947-4c3d-8ffc-bc02649cdfdc +archive bytes: 162695375 +archive SHA256: e52c82adb379942ed2b63fb0cd361dd7d2cfd153314b26b026bafd942c87c9ce +release manifest SHA256: 5933ed34f35473026a32f0c3eab9a8b86b6fb484461bac2f670e0a74930aaa0c +``` + +Свидетельства: `.tools/phase5-evidence/backup.log`, `failed-backup.log`, +`failure-verification.txt`, `restore-verification.txt`, `resumed-health.txt`, +`restored-health.txt`, `restored-start.log`, `primary-server-status.txt`, +`integration.log`. Сценарий — `scripts/verify-phase5.sh`; он требует специально +подготовленного test root Phase 4 и ещё не существующего restore destination. +Его нельзя запускать как универсальную команду на primary сервере. + +## Автоматические проверки + +`npm test`: 37 tests pass. Backup/restore tests проверяют общий lifecycle lock, +stop/archive/verify/resume, stopped/absent state, разные snapshot ID, +восстановление bytes/modes/ownership и связанного release, запрет существующей +цели, повреждённый archive, неполное checksum coverage, ошибку tar/stop/resume, +unsupported links, проверку target до остановки и отказ применения до backup. + +Gate test доказывает, что callback будущего apply не вызывается при ошибке +архива и вызывается только после verified snapshot под той же блокировкой. +При ошибке callback status учитывает текущее состояние контейнера и не +запускает старую сборку автоматически. SSH apply сам пока не реализован. +Build, mod commands и runtime regression tests также проходят. Node/Bash +syntax и `git diff --check` пройдены. + +## Оставшиеся границы + +Автоматическое удаление snapshot, off-host копии, retention, rollback и +переключение active release — последующие фазы. Пока runtime не поддерживает +links/special files в backup. Atomic publication защищает от обычной ошибки +процесса, но fsync для гарантии при отключении питания не реализован. +Primary старого spike wrapper не мигрирован на full-release binding. +Следующая Phase 6 — расширенная диагностика healthcheck, crash/restart-loop +и timeout с полезными logs. diff --git a/docs/phase6-results.md b/docs/phase6-results.md new file mode 100644 index 0000000..5f73868 --- /dev/null +++ b/docs/phase6-results.md @@ -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. diff --git a/docs/phase7-results.md b/docs/phase7-results.md new file mode 100644 index 0000000..0748243 --- /dev/null +++ b/docs/phase7-results.md @@ -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. diff --git a/docs/phase8-results.md b/docs/phase8-results.md new file mode 100644 index 0000000..3438313 --- /dev/null +++ b/docs/phase8-results.md @@ -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 остаётся непроверенной веткой. diff --git a/docs/phase9-results.md b/docs/phase9-results.md new file mode 100644 index 0000000..923e39d --- /dev/null +++ b/docs/phase9-results.md @@ -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 нет. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..abb604f --- /dev/null +++ b/docs/releases.md @@ -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 и рабочий сервер не менялись. diff --git a/docs/rollback.md b/docs/rollback.md new file mode 100644 index 0000000..2c62caf --- /dev/null +++ b/docs/rollback.md @@ -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 автоматически не очищаются. diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..8c5f860 --- /dev/null +++ b/docs/runtime.md @@ -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`. diff --git a/docs/spike-results.md b/docs/spike-results.md new file mode 100644 index 0000000..638efe3 --- /dev/null +++ b/docs/spike-results.md @@ -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. diff --git a/examples/fabric-spike/pack/config/spike-marker.txt b/examples/fabric-spike/pack/config/spike-marker.txt new file mode 100644 index 0000000..d7acf9f --- /dev/null +++ b/examples/fabric-spike/pack/config/spike-marker.txt @@ -0,0 +1 @@ +pack-default-v1 diff --git a/examples/fabric-spike/pack/index.toml b/examples/fabric-spike/pack/index.toml new file mode 100644 index 0000000..2fb92b7 --- /dev/null +++ b/examples/fabric-spike/pack/index.toml @@ -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 diff --git a/examples/fabric-spike/pack/mods/fabric-api.pw.toml b/examples/fabric-spike/pack/mods/fabric-api.pw.toml new file mode 100644 index 0000000..8614e0c --- /dev/null +++ b/examples/fabric-spike/pack/mods/fabric-api.pw.toml @@ -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" diff --git a/examples/fabric-spike/pack/mods/modmenu.pw.toml b/examples/fabric-spike/pack/mods/modmenu.pw.toml new file mode 100644 index 0000000..462aa07 --- /dev/null +++ b/examples/fabric-spike/pack/mods/modmenu.pw.toml @@ -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" diff --git a/examples/fabric-spike/pack/mods/placeholder-api.pw.toml b/examples/fabric-spike/pack/mods/placeholder-api.pw.toml new file mode 100644 index 0000000..254a10c --- /dev/null +++ b/examples/fabric-spike/pack/mods/placeholder-api.pw.toml @@ -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" diff --git a/examples/fabric-spike/pack/pack.toml b/examples/fabric-spike/pack/pack.toml new file mode 100644 index 0000000..e18efdb --- /dev/null +++ b/examples/fabric-spike/pack/pack.toml @@ -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" diff --git a/examples/fabric-spike/variants.sh b/examples/fabric-spike/variants.sh new file mode 100755 index 0000000..3bb3e6c --- /dev/null +++ b/examples/fabric-spike/variants.sh @@ -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" diff --git a/mcpack b/mcpack new file mode 100755 index 0000000..e0c9624 --- /dev/null +++ b/mcpack @@ -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" "$@" diff --git a/minecraft_modpack_platform_PLAN.md b/minecraft_modpack_platform_PLAN.md index 7e0e36b..a22035c 100644 --- a/minecraft_modpack_platform_PLAN.md +++ b/minecraft_modpack_platform_PLAN.md @@ -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/-/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 @@ -1579,7 +1611,7 @@ mcpack logs - client artifact импортируется в Prism; - сервер запускается из того же определения сборки; -- клиент может подключиться; +- клиент на NeoForge/Forge может подключиться без Microsoft/Mojang-аккаунта; - моды версионируются через packwiz; - server files хранятся как обычные директории на host; - world переживает recreation контейнера; diff --git a/pack/config/spike-marker.txt b/pack/config/spike-marker.txt new file mode 100644 index 0000000..78188a1 --- /dev/null +++ b/pack/config/spike-marker.txt @@ -0,0 +1 @@ +neoforge-pack-v1 diff --git a/pack/index.toml b/pack/index.toml new file mode 100644 index 0000000..48d48ab --- /dev/null +++ b/pack/index.toml @@ -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 diff --git a/pack/mods/mouse-tweaks.pw.toml b/pack/mods/mouse-tweaks.pw.toml new file mode 100644 index 0000000..c665b4f --- /dev/null +++ b/pack/mods/mouse-tweaks.pw.toml @@ -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" diff --git a/pack/mods/sophisticated-backpacks.pw.toml b/pack/mods/sophisticated-backpacks.pw.toml new file mode 100644 index 0000000..543d344 --- /dev/null +++ b/pack/mods/sophisticated-backpacks.pw.toml @@ -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" diff --git a/pack/mods/sophisticated-core.pw.toml b/pack/mods/sophisticated-core.pw.toml new file mode 100644 index 0000000..403bfe5 --- /dev/null +++ b/pack/mods/sophisticated-core.pw.toml @@ -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" diff --git a/pack/pack.toml b/pack/pack.toml new file mode 100644 index 0000000..55df65b --- /dev/null +++ b/pack/pack.toml @@ -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" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2f14687 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f00100 --- /dev/null +++ b/package.json @@ -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" } +} diff --git a/platform/README.md b/platform/README.md new file mode 100644 index 0000000..70dc8f5 --- /dev/null +++ b/platform/README.md @@ -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). diff --git a/platform/backups.mjs b/platform/backups.mjs new file mode 100644 index 0000000..3a0bba5 --- /dev/null +++ b/platform/backups.mjs @@ -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); + } +} diff --git a/platform/build.mjs b/platform/build.mjs new file mode 100644 index 0000000..e055dd4 --- /dev/null +++ b/platform/build.mjs @@ -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); + } +} diff --git a/platform/commands.mjs b/platform/commands.mjs new file mode 100644 index 0000000..6fdc7fc --- /dev/null +++ b/platform/commands.mjs @@ -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 [--yes] [--config FILE] + ./mcpack remove [--yes] [--config FILE] + ./mcpack update [--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 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; +} diff --git a/platform/compatibility.json b/platform/compatibility.json new file mode 100644 index 0000000..722b484 --- /dev/null +++ b/platform/compatibility.json @@ -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" + } + ] +} diff --git a/platform/config.yaml b/platform/config.yaml new file mode 100644 index 0000000..8802662 --- /dev/null +++ b/platform/config.yaml @@ -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 diff --git a/platform/deployment.mjs b/platform/deployment.mjs new file mode 100644 index 0000000..2821197 --- /dev/null +++ b/platform/deployment.mjs @@ -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{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});} + }); +} diff --git a/platform/health.mjs b/platform/health.mjs new file mode 100644 index 0000000..b19df2d --- /dev/null +++ b/platform/health.mjs @@ -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 }; +} diff --git a/platform/release.mjs b/platform/release.mjs new file mode 100644 index 0000000..c1c7bbf --- /dev/null +++ b/platform/release.mjs @@ -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);} +} diff --git a/platform/rollback.mjs b/platform/rollback.mjs new file mode 100644 index 0000000..17f9c8a --- /dev/null +++ b/platform/rollback.mjs @@ -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}); + } + }); +} diff --git a/platform/runtime.mjs b/platform/runtime.mjs new file mode 100644 index 0000000..6fbeed5 --- /dev/null +++ b/platform/runtime.mjs @@ -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 [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 }); } +} diff --git a/platform/schemas/.gitkeep b/platform/schemas/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/platform/schemas/config.schema.json b/platform/schemas/config.schema.json new file mode 100644 index 0000000..cea75fa --- /dev/null +++ b/platform/schemas/config.schema.json @@ -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 } + } + } + } +} diff --git a/platform/ssh-deploy.mjs b/platform/ssh-deploy.mjs new file mode 100644 index 0000000..6c694f9 --- /dev/null +++ b/platform/ssh-deploy.mjs @@ -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});} +} diff --git a/runtime/.gitkeep b/runtime/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/build-clean-release.sh b/scripts/build-clean-release.sh new file mode 100755 index 0000000..2d9c5ec --- /dev/null +++ b/scripts/build-clean-release.sh @@ -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 diff --git a/scripts/deploy-worker.mjs b/scripts/deploy-worker.mjs new file mode 100644 index 0000000..6512326 --- /dev/null +++ b/scripts/deploy-worker.mjs @@ -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();} diff --git a/scripts/mcpack.mjs b/scripts/mcpack.mjs new file mode 100644 index 0000000..18ab395 --- /dev/null +++ b/scripts/mcpack.mjs @@ -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; +} diff --git a/scripts/query-minecraft.mjs b/scripts/query-minecraft.mjs new file mode 100644 index 0000000..e29eedb --- /dev/null +++ b/scripts/query-minecraft.mjs @@ -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>>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{console.error(error.message);process.exitCode=1;}); +socket.on('close',()=>{if(!complete&&!process.exitCode){console.error('Connection closed before Minecraft status');process.exitCode=1;}}); diff --git a/scripts/server.mjs b/scripts/server.mjs new file mode 100644 index 0000000..1e9740c --- /dev/null +++ b/scripts/server.mjs @@ -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; +} diff --git a/scripts/server.sh b/scripts/server.sh new file mode 100755 index 0000000..f0e1411 --- /dev/null +++ b/scripts/server.sh @@ -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" "$@" diff --git a/scripts/spike-build.sh b/scripts/spike-build.sh new file mode 100755 index 0000000..b363be3 --- /dev/null +++ b/scripts/spike-build.sh @@ -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" diff --git a/scripts/spike-neoforge-variants.sh b/scripts/spike-neoforge-variants.sh new file mode 100644 index 0000000..603ce34 --- /dev/null +++ b/scripts/spike-neoforge-variants.sh @@ -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" diff --git a/scripts/spike-server.sh b/scripts/spike-server.sh new file mode 100755 index 0000000..262f807 --- /dev/null +++ b/scripts/spike-server.sh @@ -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" "$@" diff --git a/scripts/spike-tools.sh b/scripts/spike-tools.sh new file mode 100755 index 0000000..16e93b0 --- /dev/null +++ b/scripts/spike-tools.sh @@ -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 diff --git a/scripts/verify-phase3.sh b/scripts/verify-phase3.sh new file mode 100644 index 0000000..b009348 --- /dev/null +++ b/scripts/verify-phase3.sh @@ -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" diff --git a/scripts/verify-phase5.sh b/scripts/verify-phase5.sh new file mode 100755 index 0000000..071b655 --- /dev/null +++ b/scripts/verify-phase5.sh @@ -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' diff --git a/scripts/verify-phase6.sh b/scripts/verify-phase6.sh new file mode 100755 index 0000000..6f4a272 --- /dev/null +++ b/scripts/verify-phase6.sh @@ -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' diff --git a/scripts/verify-phase7.sh b/scripts/verify-phase7.sh new file mode 100755 index 0000000..b98d4da --- /dev/null +++ b/scripts/verify-phase7.sh @@ -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" < "$check/ssh/known_hosts" +cut -d ' ' -f 1-2 "$check/ssh/host-key.pub" >> "$check/ssh/known_hosts" +cat > "$check/ssh/client_config" < "$check/bin/ssh" <