diff --git a/README.md b/README.md index 534e5b7..8c94295 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Practice107 \ No newline at end of file +# Practice106 \ No newline at end of file diff --git a/SerovAA/README.md b/SerovAA/README.md new file mode 100644 index 0000000..8d257a8 --- /dev/null +++ b/SerovAA/README.md @@ -0,0 +1,84 @@ +# Password Generator — микросервисное веб-приложение + +## Описание проекта + +Генератор паролей с микросервисной архитектурой. +Состоит из пяти компонентов: + +- **Nginx** — обратный прокси и точка входа (порт 8080) +- **Frontend** — статический HTML/JS интерфейс +- **Backend (Flask)** — API для создания задач генерации паролей +- **Worker** — фоновый сервис, выполняющий длительную генерацию +- **Redis** — брокер задач и хранилище результатов + +## Запуск + +bash +- docker compose up -d + +## Доступные endpoints +- Веб-приложение: http://localhost:8080 +- Prometheus метрики: http://localhost:8080/metrics (или http://localhost:9090/targets) +- Grafana: http://localhost:3000 (логин admin / admin) + +## Мониторинг в Grafana +- После входа в Grafana: +- Перейдите в Dashboards → Password Generator Monitoring. +- Вы увидите график скорости генерации паролей, счётчик запросов и логи сервисов. + +## Собственная метрика +В бэкенде реализован счётчик generate_requests_total. Он увеличивается при каждом POST /api/generate и доступен по /metrics в формате Prometheus. + +## Сбор логов +Логи всех контейнеров собираются через Promtail и отправляются в Loki. В Grafana можно фильтровать логи по сервису (service="backend"). + +## Остановить: + +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..419b72b --- /dev/null +++ b/SerovAA/backend/app.py @@ -0,0 +1,42 @@ +import os +import json +import uuid +import redis +from flask import Flask, request, jsonify +from prometheus_client import Counter, generate_latest, REGISTRY + +app = Flask(__name__) + +# Собственная метрика: счётчик запросов на генерацию +GENERATE_REQUESTS = Counter('generate_requests_total', 'Total number of password generation requests') + +redis_client = redis.Redis(host=os.getenv('REDIS_HOST', 'redis'), port=6379, db=0) + +@app.route('/api/generate', methods=['POST']) +def generate(): + GENERATE_REQUESTS.inc() # увеличиваем счётчик + 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: + return jsonify(json.loads(task_data)) + return jsonify({'status': 'not_found'}), 404 + +@app.route('/metrics', methods=['GET']) +def metrics(): + return generate_latest(REGISTRY), 200, {'Content-Type': 'text/plain'} + +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..a8d226d --- /dev/null +++ b/SerovAA/backend/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.3.3 +redis==5.0.1 +prometheus-client==0.19.0 diff --git a/SerovAA/docker-compose.yml b/SerovAA/docker-compose.yml new file mode 100644 index 0000000..ce65b1b --- /dev/null +++ b/SerovAA/docker-compose.yml @@ -0,0 +1,91 @@ +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 + labels: + - "com.docker.compose.service=backend" + + worker: + build: ./worker + environment: + - REDIS_HOST=redis + depends_on: + - redis + networks: + - app-network + labels: + - "com.docker.compose.service=worker" + + redis: + image: redis:alpine + networks: + - app-network + labels: + - "com.docker.compose.service=redis" + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - app-network + + loki: + image: grafana/loki:latest + command: -config.file=/etc/loki/loki-config.yaml + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml + ports: + - "3100:3100" + networks: + - app-network + + promtail: + image: grafana/promtail:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./promtail/promtail-config.yaml:/etc/promtail/promtail-config.yaml + command: -config.file=/etc/promtail/promtail-config.yaml + depends_on: + - loki + networks: + - app-network + + grafana: + image: grafana/grafana:latest + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + ports: + - "3000:3000" + depends_on: + - prometheus + - loki + 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/grafana/provisioning/dashboards/dashboard.yaml b/SerovAA/grafana/provisioning/dashboards/dashboard.yaml new file mode 100644 index 0000000..f4b3078 --- /dev/null +++ b/SerovAA/grafana/provisioning/dashboards/dashboard.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/SerovAA/grafana/provisioning/dashboards/password-generator-dashboard.json b/SerovAA/grafana/provisioning/dashboards/password-generator-dashboard.json new file mode 100644 index 0000000..241e864 --- /dev/null +++ b/SerovAA/grafana/provisioning/dashboards/password-generator-dashboard.json @@ -0,0 +1,37 @@ +{ + "title": "Password Generator Monitoring", + "panels": [ + { + "title": "Generate Requests Rate", + "type": "graph", + "targets": [ + { + "expr": "rate(generate_requests_total[1m])", + "legendFormat": "req/s" + } + ], + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0} + }, + { + "title": "Total Generate Requests", + "type": "stat", + "targets": [ + { + "expr": "generate_requests_total" + } + ], + "gridPos": {"h": 4, "w": 6, "x": 12, "y": 0} + }, + { + "title": "Logs (Loki)", + "type": "logs", + "targets": [ + { + "expr": "{service=\"backend\"} or {service=\"worker\"}", + "refId": "A" + } + ], + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 8} + } + ] +} diff --git a/SerovAA/grafana/provisioning/datasources/datasources.yaml b/SerovAA/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000..01e043b --- /dev/null +++ b/SerovAA/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + - name: Loki + type: loki + access: proxy + url: http://loki:3100 diff --git a/SerovAA/loki/loki-config.yaml b/SerovAA/loki/loki-config.yaml new file mode 100644 index 0000000..bc4bb97 --- /dev/null +++ b/SerovAA/loki/loki-config.yaml @@ -0,0 +1,25 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + replication_factor: 1 + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /loki/chunks diff --git a/SerovAA/nginx/nginx.conf b/SerovAA/nginx/nginx.conf new file mode 100644 index 0000000..c77b630 --- /dev/null +++ b/SerovAA/nginx/nginx.conf @@ -0,0 +1,28 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:5000; + } + + server { + listen 80; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + } + + location /metrics { + proxy_pass http://backend/metrics; + proxy_set_header Host $host; + } + } +} diff --git a/SerovAA/prometheus/prometheus.yml b/SerovAA/prometheus/prometheus.yml new file mode 100644 index 0000000..af9a19b --- /dev/null +++ b/SerovAA/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'backend' + static_configs: + - targets: ['backend:5000'] + metrics_path: '/metrics' diff --git a/SerovAA/promtail/promtail-config.yaml b/SerovAA/promtail/promtail-config.yaml new file mode 100644 index 0000000..d6d7917 --- /dev/null +++ b/SerovAA/promtail/promtail-config.yaml @@ -0,0 +1,21 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker_logs + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: 'container' + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: 'service' 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))