diff --git a/.github/workflows/ci-serovaa.yml b/.github/workflows/ci-serovaa.yml new file mode 100644 index 0000000..55a2c08 --- /dev/null +++ b/.github/workflows/ci-serovaa.yml @@ -0,0 +1,60 @@ +name: CI - SerovAA + +on: + pull_request: + branches: [ main, master ] + +jobs: + test-backend: + runs-on: ubuntu-latest + services: + redis: + image: redis:alpine + ports: [6379:6379] + options: --health-cmd "redis-cli ping" --health-interval 10s + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install backend dependencies + working-directory: SerovAA/backend + run: pip install -r requirements.txt + - name: Install pytest + run: pip install pytest + - name: Run backend tests + working-directory: SerovAA + run: PYTHONPATH=. python -m pytest tests/test_backend.py -v + env: + REDIS_HOST: localhost + + test-worker: + runs-on: ubuntu-latest + services: + redis: + image: redis:alpine + ports: [6379:6379] + options: --health-cmd "redis-cli ping" --health-interval 10s + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install worker dependencies + working-directory: SerovAA/worker + run: pip install -r requirements.txt + - name: Install pytest + run: pip install pytest + - name: Run worker tests + working-directory: SerovAA + run: PYTHONPATH=. python -m pytest tests/test_worker.py -v + env: + REDIS_HOST: localhost + + docker-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build Docker images + working-directory: SerovAA + run: docker compose build diff --git a/SerovAA/README.md b/SerovAA/README.md new file mode 100644 index 0000000..a9de0c5 --- /dev/null +++ b/SerovAA/README.md @@ -0,0 +1,70 @@ +# Password Generator — микросервисное веб-приложение + +## Описание проекта + +Генератор паролей с микросервисной архитектурой. +Состоит из пяти компонентов: + +- **Nginx** — обратный прокси и точка входа (порт 8080) +- **Frontend** — статический HTML/JS интерфейс +- **Backend (Flask)** — API для создания задач генерации паролей +- **Worker** — фоновый сервис, выполняющий длительную генерацию +- **Redis** — брокер задач и хранилище результатов + +## Запуск + +bash +- docker compose up -d + +Приложение будет доступно по адресу: http://localhost:8080 + +## Остановить: + +bash +- docker compose down + +## Взаимодействие сервисов +- Пользователь вводит параметры пароля (длина, цифры, спецсимволы) и нажимает «Generate». +- Frontend отправляет POST-запрос на /api/generate в Backend. +- Backend создаёт задачу в Redis и возвращает task_id. +- Worker забирает задачу из очереди, генерирует пароль (с имитацией задержки 2 сек) и сохраняет результат. +- Frontend каждую секунду опрашивает /api/result/ и отображает пароль. + +## Проверка работоспособности (curl) +Создать задачу на генерацию пароля: + +bash +curl -X POST http://localhost:8080/api/generate \ + -H "Content-Type: application/json" \ + -d '{"length": 16, "use_digits": true, "use_special": true}' +Получить результат по task_id (подставьте полученный идентификатор): + +bash +curl http://localhost:8080/api/result/ + +## CI/CD Pipeline +При создании pull request в ветку main автоматически запускается GitHub Actions workflow: + +- Установка зависимостей для Backend и Worker +- Запуск тестов (pytest) для обоих сервисов +- Проверка сборки Docker Compose + +Статус CI: https://github.com/SoftwareEngineering2026/Practice106/actions/workflows/ci-serovaa.yml/badge.svg + +## Локальный запуск тестов +* bash +* pip install pytest +* pytest tests/ + +## Особенности реализации +✅ Единая точка входа — Nginx (порт 8080) + +✅ Все сервисы общаются через внутреннюю сеть Docker + +✅ Длительные задачи вынесены в отдельный Worker (не блокируют Backend) + +✅ Полностью микросервисная архитектура + +✅ Запуск одной командой docker compose up + +✅ Автоматическое CI-тестирование при Pull Request diff --git a/SerovAA/backend/Dockerfile b/SerovAA/backend/Dockerfile new file mode 100644 index 0000000..6113261 --- /dev/null +++ b/SerovAA/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY app.py . +CMD ["python", "app.py"] diff --git a/SerovAA/backend/app.py b/SerovAA/backend/app.py new file mode 100644 index 0000000..9b1c62c --- /dev/null +++ b/SerovAA/backend/app.py @@ -0,0 +1,36 @@ +from flask import Flask, request, jsonify +import redis +import uuid +import json +import os + +app = Flask(__name__) +redis_client = redis.Redis(host=os.getenv('REDIS_HOST', 'localhost'), port=6379, db=0) + +@app.route('/api/generate', methods=['POST']) +def generate(): + data = request.json + task_id = str(uuid.uuid4()) + + task_data = { + 'status': 'pending', + 'length': data['length'], + 'use_digits': data['use_digits'], + 'use_special': data['use_special'] + } + + redis_client.set(f"task:{task_id}", json.dumps(task_data)) + redis_client.lpush('password_tasks', task_id) + + return jsonify({'task_id': task_id}) + +@app.route('/api/result/', methods=['GET']) +def result(task_id): + task_data = redis_client.get(f"task:{task_id}") + if task_data: + task = json.loads(task_data) + return jsonify(task) + return jsonify({'status': 'not_found'}), 404 + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) diff --git a/SerovAA/backend/requirements.txt b/SerovAA/backend/requirements.txt new file mode 100644 index 0000000..bf804e5 --- /dev/null +++ b/SerovAA/backend/requirements.txt @@ -0,0 +1,2 @@ +Flask==2.3.3 +redis==5.0.1 diff --git a/SerovAA/docker-compose.yml b/SerovAA/docker-compose.yml new file mode 100644 index 0000000..960b21f --- /dev/null +++ b/SerovAA/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./frontend:/usr/share/nginx/html + depends_on: + - backend + networks: + - app-network + + backend: + build: ./backend + environment: + - REDIS_HOST=redis + depends_on: + - redis + networks: + - app-network + + worker: + build: ./worker + environment: + - REDIS_HOST=redis + depends_on: + - redis + networks: + - app-network + + redis: + image: redis:alpine + networks: + - app-network + +networks: + app-network: + driver: bridge diff --git a/SerovAA/frontend/index.html b/SerovAA/frontend/index.html new file mode 100644 index 0000000..c5e87cf --- /dev/null +++ b/SerovAA/frontend/index.html @@ -0,0 +1,73 @@ + + + + Password Generator + + + +

🔐 Password Generator

+ +
+
+
+ + + + +
+ Password: -
+ Task ID: -
+ Status: Idle +
+ + + + diff --git a/SerovAA/nginx/nginx.conf b/SerovAA/nginx/nginx.conf new file mode 100644 index 0000000..8b8e9a7 --- /dev/null +++ b/SerovAA/nginx/nginx.conf @@ -0,0 +1,26 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:5000; + } + + server { + listen 80; + + # Frontend + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/SerovAA/requirements-dev.txt b/SerovAA/requirements-dev.txt new file mode 100644 index 0000000..acdca76 --- /dev/null +++ b/SerovAA/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest==7.4.3 +pytest-cov==4.1.0 diff --git a/SerovAA/screenshots/errors.png b/SerovAA/screenshots/errors.png new file mode 100644 index 0000000..40d6302 Binary files /dev/null and b/SerovAA/screenshots/errors.png differ diff --git a/SerovAA/screenshots/success.png b/SerovAA/screenshots/success.png new file mode 100644 index 0000000..de3ee93 Binary files /dev/null and b/SerovAA/screenshots/success.png differ diff --git a/SerovAA/tests/test_backend.py b/SerovAA/tests/test_backend.py new file mode 100644 index 0000000..49e95eb --- /dev/null +++ b/SerovAA/tests/test_backend.py @@ -0,0 +1,26 @@ +import pytest +import json +from backend.app import app + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as client: + yield client + +def test_generate_endpoint_returns_task_id(client): + response = client.post('/api/generate', + json={'length': 10, 'use_digits': True, 'use_special': False}) + assert response.status_code == 200 + data = json.loads(response.data) + assert 'task_id' in data + assert len(data['task_id']) > 0 + +def test_result_endpoint_for_nonexistent_task(client): + response = client.get('/api/result/nonexistent-id-123') + assert response.status_code == 404 + +def test_generate_with_invalid_json(client): + response = client.post('/api/generate', data='invalid', content_type='application/json') + # Flask вернёт 400 или 500 — в зависимости, но главное не 200 + assert response.status_code != 200 diff --git a/SerovAA/tests/test_worker.py b/SerovAA/tests/test_worker.py new file mode 100644 index 0000000..3d137e3 --- /dev/null +++ b/SerovAA/tests/test_worker.py @@ -0,0 +1,24 @@ +import pytest +from worker.worker import generate_password + +def test_generate_password_default(): + pwd = generate_password(12, True, True) + assert len(pwd) == 12 + # должна содержать хотя бы одну букву и одну цифру (если digits true) + assert any(c.isalpha() for c in pwd) + assert any(c.isdigit() for c in pwd) + +def test_generate_password_no_digits(): + pwd = generate_password(8, use_digits=False, use_special=False) + assert len(pwd) == 8 + assert not any(c.isdigit() for c in pwd) + assert not any(c in '!@#$%^&*()' for c in pwd) + +def test_generate_password_only_letters(): + pwd = generate_password(10, use_digits=False, use_special=False) + assert pwd.isalpha() + +def test_generate_password_with_special(): + pwd = generate_password(15, use_digits=False, use_special=True) + specials = set('!@#$%^&*()') + assert any(c in specials for c in pwd) diff --git a/SerovAA/worker/Dockerfile b/SerovAA/worker/Dockerfile new file mode 100644 index 0000000..d55b117 --- /dev/null +++ b/SerovAA/worker/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY worker.py . +CMD ["python", "worker.py"] diff --git a/SerovAA/worker/requirements.txt b/SerovAA/worker/requirements.txt new file mode 100644 index 0000000..785cc92 --- /dev/null +++ b/SerovAA/worker/requirements.txt @@ -0,0 +1 @@ +redis==5.0.1 diff --git a/SerovAA/worker/worker.py b/SerovAA/worker/worker.py new file mode 100644 index 0000000..5b150af --- /dev/null +++ b/SerovAA/worker/worker.py @@ -0,0 +1,36 @@ +import redis +import json +import random +import string +import os +import time + +redis_client = redis.Redis(host=os.getenv('REDIS_HOST', 'localhost'), port=6379, db=0) + +def generate_password(length, use_digits, use_special): + chars = string.ascii_letters + if use_digits: + chars += string.digits + if use_special: + chars += '!@#$%^&*()' + return ''.join(random.choice(chars) for _ in range(length)) + +# Этот код будет выполняться ТОЛЬКО при запуске worker.py как скрипта, +# но НЕ при импорте из тестов +if __name__ == "__main__": + while True: + task_id = redis_client.brpop('password_tasks')[1].decode() + time.sleep(2) # имитация долгой обработки + task_data = json.loads(redis_client.get(f"task:{task_id}")) + try: + password = generate_password( + task_data['length'], + task_data['use_digits'], + task_data['use_special'] + ) + task_data['status'] = 'completed' + task_data['password'] = password + except Exception as e: + task_data['status'] = 'failed' + task_data['error'] = str(e) + redis_client.set(f"task:{task_id}", json.dumps(task_data))