diff --git a/.dockerignore b/.dockerignore index 54a0095..6ef7e57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,11 @@ node_modules .git +.gitea +.gitattributes .env -test +.env.* +!.env.example *.md coverage src/public/assets/mc +.superpowers diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..749f25b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +# Documentação e configuração de ferramenta não mudam nada que a suíte teste. +# Na main não roda por push: o release.yml é quem a chama, e só quando a imagem +# precisa ser reconstruída; rodar nos dois repetiria a mesma suíte no mesmo commit. +# Sem gatilho de pull_request: o push da branch já valida o mesmo commit, e os +# dois juntos rodariam tudo duas vezes (o repositório não recebe PR de fork). +# +# A imagem é construída aqui uma única vez, em arm64 (a arquitetura do servidor), +# testada e publicada como inputs-. Ao fazer o merge, o release encontra +# essa tag e só aplica os nomes novos, sem rodar nem construir nada de novo. +on: + push: + branches-ignore: [main] + paths-ignore: ['**.md', 'LICENSE', '.claude/**', '.superpowers/**', '.wolf/**', 'docker-compose*.yaml', '.env.example', 'scripts/release-notes.sh'] + schedule: + - cron: '0 9 * * 1' # toda segunda: reavalia com a base de vulnerabilidades nova + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # Trivy roda pela imagem oficial: o trivy-action clona o repositório do Trivy + # do mesmo servidor do workflow, que aqui é o Gitea. O workspace do job não é + # visível ao daemon, então os arquivos entram no container com `docker cp`. + # O .trivyignore do repositório (DS-0002, documentado) é lido automaticamente. + seguranca: + name: Trivy (dependências, segredos e configuração) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Repositório + run: | + cid=$(docker create --network host -v trivy-cache:/root/.cache/ -w /src aquasec/trivy:0.70.0 \ + fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .) + docker cp . "$cid:/src" + docker start -a "$cid" + status=$(docker inspect -f '{{.State.ExitCode}}' "$cid") + docker rm "$cid" >/dev/null + exit "$status" + + imagem: + name: Testes e imagem Docker (linux/arm64) + needs: [seguranca] + runs-on: ubuntu-24.04-arm + timeout-minutes: 40 + env: + REGISTRY: git.lucasrguerra.dev.br + steps: + - uses: actions/checkout@v4 + + # Conteúdo já publicado (re-push, tag num commit já validado): nada a fazer. + - id: plano + run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT" + + # Vitest dentro do estágio `test` do Dockerfile: ele compila o addon nativo do + # LevelDB uma vez, e as camadas ficam no daemon do runner para o build abaixo. + - name: Testes (Vitest) + if: ${{ steps.plano.outputs.reuse != 'true' }} + run: docker build --target test -t craftdock:test . + + - name: Build + if: ${{ steps.plano.outputs.reuse != 'true' }} + run: docker build -t craftdock:ci . + + - name: Trivy na imagem + if: ${{ steps.plano.outputs.reuse != 'true' }} + run: | + docker run --rm --network host -v trivy-cache:/root/.cache/ -e DOCKER_HOST=tcp://127.0.0.1:2375 aquasec/trivy:0.70.0 \ + image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 craftdock:ci + + - name: A imagem sobe e responde + if: ${{ steps.plano.outputs.reuse != 'true' }} + run: scripts/smoke-imagem.sh craftdock:ci + + # O login fica por último: testes, build, Trivy e smoke não precisam de credencial. + - if: ${{ steps.plano.outputs.reuse != 'true' }} + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + # Passou em tudo: publica a mesma imagem que foi testada, sem reconstruir. + - name: Publica inputs- + if: ${{ steps.plano.outputs.reuse != 'true' }} + run: | + tag="${{ steps.plano.outputs.image }}:inputs-${{ steps.plano.outputs.inputs }}" + docker tag craftdock:ci "$tag" + docker push "$tag" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..eaa86d8 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,129 @@ +name: Release + +# Publica a imagem no registro de containers do Gitea depois de um merge em main, +# e de novo com tag semver quando uma tag v* é enviada. O servidor só faz pull. +# Precisa do secret REGISTRY_TOKEN no repositório (token do Gitea com write:package): +# o registro recusa o token do job. +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +env: + REGISTRY: git.lucasrguerra.dev.br + +jobs: + # A imagem só depende dos arquivos que entram no build, não da documentação nem + # da configuração de ferramentas. O push da branch já a construiu, testou e + # publicou como inputs-; quando essa tag existe (o caso normal de um + # merge, uma tag num commit validado, um merge só de docs), não há o que + # construir: as tags novas apontam para o manifesto existente. + plano: + name: Plano (reaproveitar a imagem?) + runs-on: ubuntu-latest + outputs: + inputs: ${{ steps.plano.outputs.inputs }} + reuse: ${{ steps.plano.outputs.reuse }} + image: ${{ steps.plano.outputs.image }} + steps: + - uses: actions/checkout@v4 + - id: plano + run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT" + + # Nada é publicado sem a suíte completa passar antes. Pulada quando a imagem é + # reaproveitada: o mesmo conteúdo já passou por ela. + ci: + needs: plano + if: ${{ needs.plano.outputs.reuse != 'true' }} + uses: ./.gitea/workflows/ci.yml + # Workflows reutilizáveis não herdam os secrets sozinhos: o login no registro precisa deles. + secrets: inherit + + publicar: + name: Publica a imagem (linux/arm64) + needs: [plano, ci] + # ci é pulado, e não bem-sucedido, quando a imagem é reaproveitada. + if: ${{ !cancelled() && needs.plano.result == 'success' && (needs.ci.result == 'success' || needs.plano.outputs.reuse == 'true') }} + # O único runner é arm64 e o servidor de destino (Oracle) também, então a + # imagem é construída nativamente, sem emulação. + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + # Se o ci chamado acima acabou de publicar a imagem (push direto na main), + # o plano lá de cima está desatualizado: confere de novo antes de construir. + - id: replano + run: scripts/plano-imagem.sh "$REGISTRY" "${{ gitea.repository }}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v4 + + - name: Autentica no registro + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Metadados + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ needs.plano.outputs.image }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + type=sha,format=long + + - name: Build e push + if: ${{ steps.replano.outputs.reuse != 'true' }} + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/arm64 + push: true + tags: | + ${{ steps.meta.outputs.tags }} + ${{ needs.plano.outputs.image }}:inputs-${{ needs.plano.outputs.inputs }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ needs.plano.outputs.image }}:buildcache + cache-to: type=registry,ref=${{ needs.plano.outputs.image }}:buildcache,mode=max + + # Mesmo manifesto, nomes novos: nenhuma camada é lida nem escrita. + - name: Aplica as tags à imagem existente + if: ${{ steps.replano.outputs.reuse == 'true' }} + run: | + tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') + docker buildx imagetools create $tags "${{ needs.plano.outputs.image }}:inputs-${{ needs.plano.outputs.inputs }}" + + # Cria a entrada em "Releases" quando a execução é de uma tag v*. Roda depois + # de publicar a imagem, com as notas da seção do CHANGELOG.md. + release: + name: Cria a release no Gitea + needs: [plano, publicar] + # Explícito, como o publicar: o ci acima é pulado quando a imagem é + # reaproveitada, e o success() implícito pularia este job também. + if: ${{ !cancelled() && needs.publicar.result == 'success' && startsWith(gitea.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Release + env: + TAG: ${{ gitea.ref_name }} + TOKEN: ${{ gitea.token }} + API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} + IMAGE: ${{ needs.plano.outputs.image }} + run: | + scripts/release-notes.sh "$TAG" "$IMAGE" > notes.md + pre=false; case "$TAG" in *-*) pre=true ;; esac + jq -n --arg tag "$TAG" --arg name "CraftDock ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \ + '{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json + curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \ + -H 'Content-Type: application/json' -d @release.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8461628 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +Everything notable in CraftDock is recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.1.0] - 2026-11-01 + +First versioned release. It collects the state of CraftDock on 2026-11-01. + +### Added + +- Web panel to manage a single Minecraft server (Java or Bedrock) running as a Docker + container: start / stop / restart / kill, live CPU, RAM and player count, real-time + console over WebSocket, `server.properties` editor, world download / upload / regeneration, + seed and seed map, and a persistent player directory with a per-player inspector + (position, health, inventory, XP) read from the world save (Java `playerdata`, Bedrock + LevelDB). +- Adapter layer for the two editions (Java over RCON, Bedrock over `docker attach`). +- Structured JSON logging, a bcrypt-authenticated session on every route and socket, + and a self-healing healthcheck. +- `docker-compose..registry.yaml`, which runs the stack from the published image. +- A CI on the Gitea runners (`.gitea/workflows`): Trivy on the repository and on the image, + the Vitest suite, an arm64 image build and a smoke test. The image is built once per + content hash and reused by the release. +- `CHANGELOG.md`. + +### Changed + +- The code, the container image and the releases now live on Gitea + (`git.lucasrguerra.dev.br`); the deployment pulls the image from its registry. +- Dockerfile: Node 20 → 22; a dedicated `test` stage runs the suite on the same compiled + dependencies; the runtime image no longer carries `npm`. +- The repository history was lost with the previous GitHub account, so this repository starts + from a fresh history at the 2026-11-01 state. +- `.gitattributes` forces LF line endings. + +[Unreleased]: https://git.lucasrguerra.dev.br/lucasrguerra/craftdock/compare/v0.1.0...HEAD +[0.1.0]: https://git.lucasrguerra.dev.br/lucasrguerra/craftdock/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 5936847..c6137d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,12 @@ npm run dev # dev server (loads .env, watches files), panel on :3000 npm run hash -- # generate the admin bcrypt hash (raw + base64 forms) docker compose -f docker-compose.bedrock.yaml up -d --build # full Bedrock stack docker compose -f docker-compose.java.yaml up -d --build # full Java stack +docker build --target test . # Vitest inside the image (what CI runs) +scripts/smoke-imagem.sh # image smoke test (what CI runs) ``` +CI/CD lives in `.gitea/workflows` (Gitea runners): `ci.yml` on branches, `release.yml` on `main` and `vX.Y.Z` tags. The image is `git.lucasrguerra.dev.br/lucasrguerra/craftdock` (public). Remote: `gitea`. Keep `CHANGELOG.md` updated under *Unreleased* and release with `git tag vX.Y.Z && git push gitea vX.Y.Z`. + Run a single test file: `npx vitest run test/services/playerDetailService.test.js`. ## Git workflow (mandatory) diff --git a/Dockerfile b/Dockerfile index dc0f9b1..3a79202 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,9 @@ # Why not Alpine? `leveldb-zlib` is a native addon that compiles from source via # cmake-js. On Alpine/musl (esp. ARM64) that build repeatedly failed at the CMake # configure step; Debian glibc is the reliable path for this toolchain. -FROM node:20-bookworm-slim AS builder + +# ── deps: every dependency (dev included) with the native addon compiled ── +FROM node:22-bookworm-slim AS deps WORKDIR /app COPY package*.json ./ # scripts/ must exist before npm ci: the postinstall hook runs the (no-op here) @@ -13,24 +15,37 @@ COPY package*.json ./ COPY scripts ./scripts RUN apt-get update \ && apt-get install -y --no-install-recommends python3 make g++ cmake zlib1g-dev ca-certificates \ - && npm ci --omit=dev + && npm ci + +# ── test: `docker build --target test .` runs the Vitest suite (used by CI) ── +FROM deps AS test +COPY src ./src +COPY test ./test +COPY vitest.config.js ./ +RUN npm test + +# ── builder: production dependencies only ── +FROM deps AS builder +RUN npm prune --omit=dev # Item icons are generated at build time (not committed, not downloaded at # runtime): install the versioned minecraft-assets package and extract the flat # item PNGs. Bump ASSETS_VERSION (or rebuild) to pick up new game items. -FROM node:20-bookworm-slim AS assets +FROM node:22-bookworm-slim AS assets ARG ASSETS_VERSION=latest WORKDIR /app COPY scripts/extract-mc-assets.mjs scripts/ RUN npm install --no-save --ignore-scripts minecraft-assets@${ASSETS_VERSION} \ && node scripts/extract-mc-assets.mjs --required -FROM node:20-bookworm-slim +FROM node:22-bookworm-slim WORKDIR /app -# Patch fixable OS CVEs in the base image at build time (trivy image scan). +# Patch fixable OS CVEs in the base image at build time (trivy image scan). The +# runtime only needs node: the npm bundled in the base image carries its own +# dependencies, which Trivy reports and nothing here uses. RUN apt-get update \ && apt-get upgrade -y \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx # The compiled node-leveldb.node links libz (zlib1g) and libstdc++6, both already # present in the slim base image — so no extra runtime packages are needed. COPY --from=builder /app/node_modules ./node_modules diff --git a/README.md b/README.md index 1805994..3b6dc06 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ docker compose -f docker-compose.bedrock.yaml logs -f # follow panel + server ## Requirements -- **Node.js ≥ 18** (dev) or Docker (deploy). +- **Node.js ≥ 18** (dev; the image uses Node 22) or Docker (deploy). - A Docker host — the panel mounts `/var/run/docker.sock` to control the game container. --- @@ -100,7 +100,12 @@ Open and log in with your password. ## Running with Docker -The panel image is built locally from the included `Dockerfile` — it is **not** published to any registry, so a plain `docker compose pull`/`up` without a build step fails with *"pull access denied"*. Always build it: +The panel image is built from the included `Dockerfile`. Two ways to run each stack: + +- **Build it yourself** (`docker-compose..yaml`, `--build`): works anywhere, no registry needed. +- **Pull the published image** (`docker-compose..registry.yaml`): `git.lucasrguerra.dev.br/lucasrguerra/craftdock` (linux/arm64), built, scanned and smoke-tested by CI. Tags: `latest`, `X.Y.Z`, `X.Y` and `sha-`; pin one with `IMAGE_TAG`. This is what the reference Coolify deployment runs. + +To build it: Two reference stacks are provided: @@ -268,3 +273,14 @@ src/ ``` CraftDock also keeps two metadata files at the data root (bundled into world backups): `craftdock_players.json` (persistent XUID→name player directory) and `craftdock_bedrock_ids.json` (Bedrock identity bridge). See [CLAUDE.md](CLAUDE.md) for the full architecture and contribution conventions (numbered feature branches, `#N:` commit prefix). + +--- + +## Continuous integration and releases + +CI runs on the Gitea runners (`.gitea/workflows`): + +- **`ci.yml`** (branches and every Monday): Trivy on the repository (vulnerabilities, secrets, misconfiguration; the accepted `DS-0002` is documented in [`.trivyignore`](.trivyignore)), the Vitest suite inside the Dockerfile `test` stage, the arm64 image build, Trivy on the image and a smoke test (`scripts/smoke-imagem.sh`: UI served, API refuses unauthenticated requests, item icons generated, LevelDB addon loads, image healthcheck turns healthy). +- **`release.yml`** (`main` and `vX.Y.Z` tags): if the registry already holds the image for the same content (`inputs-`), nothing is rebuilt — only the new tags are applied. On a tag it creates the Gitea release with the [CHANGELOG](CHANGELOG.md) notes. +- To release: update the CHANGELOG and `package.json`, commit, then `git tag vX.Y.Z && git push gitea vX.Y.Z`. + diff --git a/docker-compose.bedrock.registry.yaml b/docker-compose.bedrock.registry.yaml new file mode 100644 index 0000000..ca0bf2a --- /dev/null +++ b/docker-compose.bedrock.registry.yaml @@ -0,0 +1,82 @@ +# CraftDock — Bedrock stack, from the published image (no local build). +# Usage: docker compose -f docker-compose.bedrock.registry.yaml up -d +# Same stack as docker-compose.bedrock.yaml, but the panel is pulled from the Gitea +# registry (built and tested by CI) instead of being built on the host. +# Secrets/overrides (${...}) are read from the project .env by docker compose. +# See the "Environment variables" table in README.md for every variable. +services: + craftdock-panel: + image: git.lucasrguerra.dev.br/lucasrguerra/craftdock:${IMAGE_TAG:-latest} + pull_policy: always + container_name: craftdock-panel + # Behind a reverse proxy (Coolify/Traefik) the panel is reached over the + # internal Docker network, so only EXPOSE the port — do NOT publish it on the + # host. In Coolify, set the service domain WITHOUT a port + # (https://your.domain, not https://your.domain:3000) and make sure the + # service's target port is 3000 — otherwise Traefik forwards to the wrong + # port and plain 443 returns "no available server". + # + # For LOCAL testing without a proxy, comment out `expose` and uncomment + # `ports` to publish on the host, then open http://localhost:3000. + expose: + - "3000" + # ports: + # - "${PORT:-3000}:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - mc-data:/minecraft/data + environment: + - NODE_ENV=${NODE_ENV:-production} + - LOG_LEVEL=${LOG_LEVEL:-info} + # base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars. + - ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64} + - SESSION_SECRET=${SESSION_SECRET} + - MC_CONTAINER_NAME=craftdock-mc-server + - MC_DATA_PATH=/minecraft/data + - MC_EDITION=bedrock + - MC_WORLD_NAME=worlds + - RCON_HOST=craftdock-mc-server + - RCON_PORT=25575 + - RCON_PASSWORD=${RCON_PASSWORD:-} # Bedrock ignores RCON; leave empty + - MAP_VERSION=${MAP_VERSION:-} + healthcheck: + test: ["CMD", "node", "src/healthcheck.js"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: [craftdock-net] + # `always` (not `unless-stopped`): the panel must come back after ANY exit — + # a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a + # host/daemon reboot. It's the control plane, so it should never stay down. + restart: always + + minecraft-server: + image: itzg/minecraft-bedrock-server:latest + container_name: craftdock-mc-server + # stdin_open + tty are REQUIRED by CraftDock: without a TTY, stdin (the Bedrock + # command channel via `docker attach`) never reaches the server, and the log + # stream is multiplexed (garbles the Console tab). + stdin_open: true + tty: true + ports: + - "19132:19132/udp" # Bedrock game port (raw UDP — open it in the host firewall) + volumes: + - mc-data:/data + environment: + - EULA=TRUE + - OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists + healthcheck: + test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + networks: [craftdock-net] + restart: unless-stopped + +volumes: + mc-data: +networks: + craftdock-net: + driver: bridge diff --git a/docker-compose.java.registry.yaml b/docker-compose.java.registry.yaml new file mode 100644 index 0000000..3e70903 --- /dev/null +++ b/docker-compose.java.registry.yaml @@ -0,0 +1,85 @@ +# CraftDock — Java stack, from the published image (no local build). +# Usage: docker compose -f docker-compose.java.registry.yaml up -d +# Same stack as docker-compose.java.yaml, but the panel is pulled from the Gitea +# registry (built and tested by CI) instead of being built on the host. +# Secrets/overrides (${...}) are read from the project .env by docker compose. +# See the "Environment variables" table in README.md for every variable. +services: + craftdock-panel: + image: git.lucasrguerra.dev.br/lucasrguerra/craftdock:${IMAGE_TAG:-latest} + pull_policy: always + container_name: craftdock-panel + # Behind a reverse proxy (Coolify/Traefik) the panel is reached over the + # internal Docker network, so only EXPOSE the port — do NOT publish it on the + # host. In Coolify, set the service domain WITHOUT a port + # (https://your.domain, not https://your.domain:3000) and make sure the + # service's target port is 3000 — otherwise Traefik forwards to the wrong + # port and plain 443 returns "no available server". + # + # For LOCAL testing without a proxy, comment out `expose` and uncomment + # `ports` to publish on the host, then open http://localhost:3000. + expose: + - "3000" + # ports: + # - "${PORT:-3000}:3000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - mc-data:/minecraft/data + environment: + - NODE_ENV=${NODE_ENV:-production} + - LOG_LEVEL=${LOG_LEVEL:-info} + # base64 of the bcrypt hash — avoids docker/Coolify eating the `$` chars. + - ADMIN_PASSWORD_HASH_B64=${ADMIN_PASSWORD_HASH_B64} + - SESSION_SECRET=${SESSION_SECRET} + - MC_CONTAINER_NAME=craftdock-mc-server + - MC_DATA_PATH=/minecraft/data + - MC_EDITION=java + - MC_WORLD_NAME=world + - RCON_HOST=craftdock-mc-server + - RCON_PORT=25575 + - RCON_PASSWORD=${RCON_PASSWORD:-} + - MAP_VERSION=${MAP_VERSION:-} + healthcheck: + test: ["CMD", "node", "src/healthcheck.js"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: [craftdock-net] + # `always` (not `unless-stopped`): the panel must come back after ANY exit — + # a crash, its own healthcheck self-restart (kill PID 1), a manual stop, or a + # host/daemon reboot. It's the control plane, so it should never stay down. + restart: always + + minecraft-server: + image: itzg/minecraft-server:latest + container_name: craftdock-mc-server + # stdin_open + tty are REQUIRED by CraftDock: without a TTY, Docker returns a + # multiplexed log stream (8-byte frame headers) that garbles the Console tab. + stdin_open: true + tty: true + ports: + - "25565:25565" # Java game port (raw TCP — open it in the host firewall) + volumes: + - mc-data:/data + environment: + - EULA=TRUE + - TYPE=PAPER + - VERSION=LATEST + - ENABLE_RCON=true + - RCON_PASSWORD=${RCON_PASSWORD:-} # required for the panel's Java command channel + - OVERRIDE_SERVER_PROPERTIES=false # so the Options tab persists + healthcheck: + test: ["CMD-SHELL", "/usr/local/bin/mc-health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + networks: [craftdock-net] + restart: unless-stopped + +volumes: + mc-data: +networks: + craftdock-net: + driver: bridge diff --git a/scripts/plano-imagem.sh b/scripts/plano-imagem.sh new file mode 100755 index 0000000..4398f60 --- /dev/null +++ b/scripts/plano-imagem.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Uso: scripts/plano-imagem.sh +# Imprime inputs=, image= e reuse= (para $GITHUB_OUTPUT). O hash cobre só os +# arquivos que entram no build da imagem, não a documentação nem a configuração +# de ferramentas. reuse=true quando o registro já tem inputs-. +set -euo pipefail +registry=$1; ns=${2,,} + +inputs=$(git ls-tree -r HEAD \ + | awk -F'\t' '$2 !~ /^(\.gitea|\.github|\.claude|\.superpowers|\.wolf)\// && $2 !~ /^(README.*|CHANGELOG\.md|CLAUDE\.md|LICENSE|\.gitignore|\.gitattributes|\.trivyignore|docker-compose.*\.yaml|\.env\.example|scripts\/release-notes\.sh)$/' \ + | sha256sum | cut -c1-16) +token=$(curl -fsS "https://$registry/v2/token?service=container_registry&scope=repository:$ns:pull" | jq -r .token) +code=$(curl -s -o /dev/null -w '%{http_code}' -I \ + -H "Authorization: Bearer $token" \ + -H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json' \ + "https://$registry/v2/$ns/manifests/inputs-$inputs") +echo "inputs-$inputs -> HTTP $code" >&2 +reuse=false; [ "$code" = 200 ] && reuse=true +echo "inputs=$inputs" +echo "image=$registry/$ns" +echo "reuse=$reuse" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..7664192 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Imprime as notas de uma release: a imagem publicada (quando houver) e a seção +# do CHANGELOG.md daquela versão (ou o log de commits desde a tag anterior, se +# não houver seção). +# +# scripts/release-notes.sh v1.2.3 [git.exemplo.com/dono/projeto] +set -euo pipefail + +tag=${1:?uso: release-notes.sh [imagem]} +image=${2:-} +version=${tag#v} + +if [ -n "$image" ]; then + echo "## Imagem" + echo + echo '```' + echo "$image:$version" + echo '```' + echo +fi + +section="" +if [ -f CHANGELOG.md ]; then + section=$(awk -v head="## [$version]" ' + index($0, head) == 1 { found = 1; next } + found && /^## \[/ { exit } + found && /^\[[^]]+\]: / { next } + found { print } + ' CHANGELOG.md) +fi + +if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then + printf '%s\n' "$section" +else + previous=$(git describe --tags --abbrev=0 "$tag^" 2>/dev/null || true) + echo "## Mudanças${previous:+ desde $previous}" + echo + git log --no-merges --format='- %s (%h)' "${previous:+$previous..}$tag" +fi diff --git a/scripts/smoke-imagem.sh b/scripts/smoke-imagem.sh new file mode 100755 index 0000000..dcb1b80 --- /dev/null +++ b/scripts/smoke-imagem.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Teste de fumaça da imagem do CraftDock, sem o servidor de Minecraft: o painel sobe, +# serve a interface, recusa quem não está logado, traz os ícones gerados no build e o +# healthcheck da própria imagem fica saudável. +# +# scripts/smoke-imagem.sh +# +# Cada falha é impressa e o script sai com 1 no fim, para mostrar tudo de uma vez. +set -uo pipefail + +imagem=${1:?uso: smoke-imagem.sh } +app=craftdock-smoke-${GITHUB_RUN_ID:-$$} +falhas=0 +falhou() { echo "FALHOU $1"; falhas=$((falhas + 1)); } + +# Os daemons dos runners persistem entre jobs: limpa sempre, inclusive se falhar. +trap 'docker rm -f "$app" >/dev/null 2>&1' EXIT + +# Valores descartáveis. O hash é o de teste que a suíte já usa (config.test.js); a +# senha dele não importa aqui, o smoke não faz login. Sem docker.sock: o painel sobe e +# só as rotas que falam com o Docker deixam de funcionar. +hash='$2a$12$YjrtHh2BNcUT0P76L2ZUF.mCtxzUwN.gZ4siUqji2oxrDbIXRdvfe' +docker run -d --name "$app" \ + -e ADMIN_PASSWORD_HASH_B64="$(printf %s "$hash" | base64 -w0)" \ + -e SESSION_SECRET=segredo-do-smoke \ + "$imagem" >/dev/null || exit 1 + +ip="" +for _ in $(seq 1 40); do + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$app" 2>/dev/null) + [ -n "$ip" ] && curl -fsS -o /dev/null "http://$ip:3000/api/health" 2>/dev/null && break + sleep 1 +done +base=http://$ip:3000 +curl -fsS -o /dev/null "$base/api/health" || { echo "FALHOU /api/health: sem resposta"; docker logs "$app" | tail -20; exit 1; } + +status() { [ "$(curl -s -o /dev/null -w '%{http_code}' "$base$2")" = "$1" ] || falhou "$2: esperado $1"; } + +status 200 / +status 200 /api/health +# Tudo sob /api, menos auth e health, exige sessão. +status 401 /api/status +status 401 /api/players +status 401 /api/properties +status 401 /api/world + +curl -s "$base/" | grep -qi 'craftdock' || falhou "/: a interface não carregou" +curl -s "$base/api/health" | grep -q '"status":"ok"' || falhou "/api/health: corpo inesperado" + +# Os ícones de itens são gerados no build (não ficam no repositório). +n=$(docker exec "$app" sh -c 'ls /app/src/public/assets/mc 2>/dev/null | wc -l') +[ "${n:-0}" -gt 0 ] || falhou "os ícones do Minecraft não foram gerados no build" + +# O addon nativo do LevelDB (leitura de mundos Bedrock) carrega na imagem final. +docker exec "$app" node -e "import('leveldb-zlib').then(()=>process.exit(0)).catch(e=>{console.error(e.message);process.exit(1)})" || falhou "leveldb-zlib não carrega" + +# O healthcheck da própria imagem (src/healthcheck.js) reporta saudável. +for _ in $(seq 1 30); do + estado=$(docker inspect -f '{{.State.Health.Status}}' "$app" 2>/dev/null) + [ "$estado" = healthy ] && break + sleep 2 +done +[ "$estado" = healthy ] || falhou "o healthcheck da imagem não ficou healthy (estado: ${estado:-nenhum})" + +[ "$falhas" = 0 ] && echo "ok: interface, autenticação, ícones, LevelDB e healthcheck conferidos" +[ "$falhas" = 0 ]