diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f2e6274 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.git +.github +.env +.env.example +bot.log +out.log +Dockerfile +.dockerignore +README.md +tests diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..17c6d65 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,90 @@ +name: CI/CD — Docker build & push + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + name: Syntax & generator checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install deps + run: npm ci --omit=dev + + - name: Syntax check + run: node --check index.js && node --check lib/generator.js + + - name: Generator smoke test + run: node tests/smoke.mjs + + build: + name: Build Docker image + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR (only on push to main/tags) + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta (tags & labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=sha + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build (and push on main/tags) + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Report ready image + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Summary + run: | + echo "### Image published" >> "$GITHUB_STEP_SUMMARY" + echo "\`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest\` — готов к запуску:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "docker run -d --env-file .env ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16e5ae6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine + +ENV NODE_ENV=production + +WORKDIR /app + +# Сначала зависимости — слой кешируется +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +# Затем код +COPY index.js ./ +COPY lib ./lib + +# Нерут-пользователь +RUN addgroup -S bot && adduser -S -D -G bot bot +USER bot + +CMD ["node", "index.js"] diff --git a/README.md b/README.md index fc10ea4..defed5d 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,26 @@ OWNER_ID=ваш_user_id index.js # бот: меню, хэндлеры кнопок lib/generator.js # генератор + оценка энтропии ``` + +## Docker / CI-CD + +✅ GitHub Actions: `.github/workflows/docker.yml` — при пуше в `main`, PR и теге `v*`: +1. **test** — `node --check` + смоук-тесты генератора (`npm test`) +2. **build** — сборка образа и публикация в GHCR: `ghcr.io/kromskii2/tg_bot_gen` (теги `latest`, `main`, `sha-xxx`, semver для тегов) + +Локально: + +```bash +npm test # проверки +docker build -t tg_bot_gen . # или npm run docker +docker run --env-file .env tg_bot_gen +# или: +docker compose up -d --build +``` + +Запуск собранного в CI образа: + +```bash +docker run -d --name tg_bot_gen --env-file .env ghcr.io/kromskii2/tg_bot_gen:latest +``` +(для private-репозитория: `docker login ghcr.io` с PAT со scope `read:packages`) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f50ad3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + bot: + build: . + image: ghcr.io/kromskii2/tg_bot_gen:latest + env_file: .env + restart: unless-stopped + # long polling — порты наружу не нужны diff --git a/package.json b/package.json index 4ab45f2..2cd3a9b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "type": "module", "scripts": { "start": "node index.js", - "dev": "node --watch index.js" + "dev": "node --watch index.js", + "test": "node tests/smoke.mjs", + "docker": "docker build -t tg_bot_gen ." }, "keywords": [ "telegram", diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..cbfc231 --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,55 @@ +// Смоук-тест генератора: запускается в CI (node tests/smoke.mjs) +import assert from 'node:assert/strict'; +import { generatePassword, strength } from '../lib/generator.js'; + +const cases = []; + +// 1. Обычный пароль: все наборы, длина соблюдена +for (let i = 0; i < 50; i++) { + const p = generatePassword({ length: 16, upper: true, lower: true, digits: true, symbols: true }); + assert.equal(p.length, 16, 'length 16'); + assert.match(p, /[A-Z]/); + assert.match(p, /[a-z]/); + assert.match(p, /[0-9]/); + assert.match(p, /[^a-zA-Z0-9]/); + cases.push('random16'); +} + +// 2. Без похожих символов: ni один запрещённый символ +for (let i = 0; i < 50; i++) { + const p = generatePassword({ length: 24, upper: true, lower: true, digits: true, symbols: true, excludeAmbiguous: true }); + assert.equal(p.length, 24); + assert.doesNotMatch(p, /[l1oOI0]/, 'no ambiguous chars'); + cases.push('noAmbiguous24'); +} + +// 3. Произносимый режим +const pr = generatePassword({ length: 12, pronounceable: true }); +assert.equal(pr.length, 12); +assert.match(pr, /[aeiou]/, 'has vowels'); +cases.push('pronounceable12'); + +// 4. Границы длины +assert.equal(generatePassword({ length: 1, upper: true }).length, 4, 'min length clamps to 4'); +assert.equal(generatePassword({ length: 999 }).length, 128, 'max length clamps to 128'); +cases.push('bounds'); + +// 5. Пустые наборы -> не падает +const p5 = generatePassword({ length: 8 }); +assert.equal(p5.length, 8); +cases.push('noSetsFallback'); + +// 6. Стойкость +const st = strength('aZ1!'); +assert.ok(st.bits > 0 && typeof st.level === 'string'); +const stStrong = strength(generatePassword({ length: 64 })); +assert.ok(stStrong.bits >= 200, 'long password has high entropy'); +cases.push('strength'); + +// 7. Уникальность (1000 паролей не повторяются) +const set = new Set(); +for (let i = 0; i < 1000; i++) set.add(generatePassword({ length: 16 })); +assert.equal(set.size, 1000, 'all unique'); +cases.push('uniqueness'); + +console.log('SMOKE OK — ' + cases.length + ' групп проверок пройдено');