diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..0954a4b580 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,126 @@ +name: Python CI/CD Pipeline + +on: + push: + branches: [ main, master, lab03 ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ main, master ] + paths: + - 'app_python/**' + workflow_dispatch: + +env: + REGISTRY: docker.io + IMAGE_NAME: nadiaa02/devops-python-app + PYTHON_VERSION: '3.11' + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: 'app_python/requirements*.txt' + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('app_python/requirements*.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + working-directory: ./app_python + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Lint with flake8 + working-directory: ./app_python + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with coverage + working-directory: ./app_python + run: | + pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./app_python/coverage.xml + flags: python + name: python-coverage + token: ${{ secrets.CODECOV_TOKEN }} + continue-on-error: true + + security: + name: Security Scan + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Snyk vulnerability scan + uses: snyk/actions/python@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --file=app_python/requirements.txt --severity-threshold=high + continue-on-error: true + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: [test, security] + if: github.event_name == 'push' && github.ref == 'refs/heads/lab03' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Generate version + id: version + run: | + VERSION=$(date -u +'%Y.%m.%d-%H%M') + MONTH=$(date -u +'%Y.%m') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "month=$MONTH" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.month }} + ${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 30d74d2584..53d56e6ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,16 @@ -test \ No newline at end of file +test +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +terraform.tfvars +*.tfvars +.terraform.lock.hcl + +# Pulumi +pulumi/venv/ +Pulumi.*.yaml + +# Credentials +key.json +*.pem diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..07b74a6b6a --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV FLASK_APP=app.py +ENV FLASK_ENV=production + +EXPOSE 5000 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..f90d410ae4 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,33 @@ +# DevOps Python Application + +![Python CI/CD Pipeline](https://github.com/nadiaa02/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03) + +## Prerequisites +- Python 3.11+ + +## Installation +`bash +pip install -r requirements.txt +` + +## Development +`bash +# Install dev dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest tests/ -v --cov=. +` + +## Docker +`bash +docker pull nadiaa02/devops-python-app:latest +docker run -p 5000:5000 nadiaa02/devops-python-app:latest +` + +## CI/CD Pipeline +- **Testing**: pytest with coverage +- **Linting**: flake8 +- **Security**: Snyk vulnerability scanning +- **Versioning**: Calendar Versioning (CalVer) +- **Deployment**: Automatic Docker build & push \ No newline at end of file diff --git "a/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" "b/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" new file mode 100644 index 0000000000..65f956bee7 --- /dev/null +++ "b/app_python/app \342\200\224 \320\272\320\276\320\277\320\270\321\217.py" @@ -0,0 +1,85 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +START_TIME = datetime.now(timezone.utc) + +def get_system_info(): + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.release(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours, remainder = divmod(seconds, 3600) + minutes, _ = divmod(remainder, 60) + return { + 'seconds': seconds, + 'human': f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } + +@app.route('/', methods=['GET']) +def index(): + logger.info(f"Request: {request.method} {request.path} from {request.remote_addr}") + uptime = get_uptime() + return jsonify({ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime['seconds'], + "uptime_human": uptime['human'], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr or 'unknown', + "user_agent": request.headers.get('User-Agent', 'unknown'), + "method": request.method, + "path": request.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + }) + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not Found', 'message': 'Endpoint does not exist'}), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({'error': 'Internal Server Error', 'message': 'An unexpected error occurred'}), 500 + +if __name__ == '__main__': + logger.info('Application starting...') + app.run(host=HOST, port=PORT, debug=False) diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..65f956bee7 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,85 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', 5000)) +START_TIME = datetime.now(timezone.utc) + +def get_system_info(): + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': platform.release(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count(), + 'python_version': platform.python_version() + } + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours, remainder = divmod(seconds, 3600) + minutes, _ = divmod(remainder, 60) + return { + 'seconds': seconds, + 'human': f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + } + +@app.route('/', methods=['GET']) +def index(): + logger.info(f"Request: {request.method} {request.path} from {request.remote_addr}") + uptime = get_uptime() + return jsonify({ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime['seconds'], + "uptime_human": uptime['human'], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC" + }, + "request": { + "client_ip": request.remote_addr or 'unknown', + "user_agent": request.headers.get('User-Agent', 'unknown'), + "method": request.method, + "path": request.path + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"} + ] + }) + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + }) + +@app.errorhandler(404) +def not_found(error): + return jsonify({'error': 'Not Found', 'message': 'Endpoint does not exist'}), 404 + +@app.errorhandler(500) +def internal_error(error): + return jsonify({'error': 'Internal Server Error', 'message': 'An unexpected error occurred'}), 500 + +if __name__ == '__main__': + logger.info('Application starting...') + app.run(host=HOST, port=PORT, debug=False) diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..05e96ad286 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,33 @@ +# LAB01 - DevOps Info Service + +## Framework Selection +Flask 3.0.3 - lightweight framework suitable for simple APIs and microservices. + +## Best Practices Applied +1. Structured logging with timestamps +2. Error handling for 404/500 responses +3. Environment variables for configuration +4. PEP8 compliant code organization + +## API Documentation +GET / - Service and system information +GET /health - Health check endpoint + +text + +## Testing Evidence +![Main endpoint](screenshots/01-main-endpoint.png) +![Health check](screenshots/02-health-check.png) +![Terminal output](screenshots/03-formatted-output.png) + +## GitHub Community Engagement +- Starred: inno-devops-labs/DevOps-Core-Course +- Starred: simple-container-com/api +- Following: Cre-eD, marat-biriushev, pierrepicaud +- Following 3 classmates + +Stars increase project visibility. Following helps track best practices. + +## Challenges & Solutions +- Windows venv activation via direct python.exe path +- Client IP shows 127.0.0.1 for localhost correctly \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..47ac8bb8fa --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,127 @@ +## 1. Docker Best Practices Applied + +### Non-root user +I used a non-root user inside the container to reduce security risks. If the application is compromised it will not have root privileges inside the container. + +### Specific base image +I chose `python:3.13-slim` because it's the official python image with minimal size it makes the container smaller and faster to download. + +### Layer caching +I copied `requirements.txt` before the application code. This allows Docker to cache the dependencies layerr so when I change only my code, Docker doesn't need to reinstall dependencies. + +### .dockerignore file +This file prevents unnecessary files from being copied into the Docker image, which makes builds faster. + +## 2. Image Information & Decisions + +### Base image choice +**Image**: `python:3.13-slim` +**Why**: This is the official Python image that includes only essential packages. The slim version is much smaller than the full Python image. + +### Final image size +REPOSITORY TAG IMAGE ID CREATED SIZE +nadiaa02/lab02-python-app latest b232497fb2bb 20 minutes ago 184MB + +text + +### Layer order importance +The order matters for Docker caching. If I copy all files first and then install dependencies, every code change would cause Docker to reinstall all dependencies, which takes much longer. + +## 3. Build & Run Process + +### Docker build output +[+] Building 38.9s (12/12) FINISHED +=> [internal] load build definition from Dockerfile +=> => transferring dockerfile: 348B +=> [internal] load metadata for docker.io/library/python:3.13-slim +=> [1/7] FROM docker.io/library/python:3.13-slim@sha256:49b618b8afc2742b94fa8419d8f4d3b337f111a0527d417a1db97d4683cb71a6 +=> [2/7] RUN useradd -m appuser +=> [3/7] WORKDIR /app +=> [4/7] COPY requirements.txt . +=> [5/7] RUN pip install --no-cache-dir -r requirements.txt +=> [6/7] COPY . . +=> [7/7] RUN chown -R appuser:appuser /app +=> exporting to image +=> => naming to docker.io/library/nadia-lab02-app:latest +Successfully built b232497fb2bb +Successfully tagged nadia-lab02-app:latest + +text + +### Docker run output +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +bb4d98bd9722 nadia-lab02-app "python app.py" 12 seconds ago Up 12 seconds 0.0.0.0:5000->5000/tcp my-app + +text + +### Application testing +{ +"endpoints": [ +{ +"description": "Service information", +"method": "GET", +"path": "/" +}, +{ +"description": "Health check", +"method": "GET", +"path": "/health" +} +], +"request": { +"client_ip": "172.17.0.1", +"method": "GET", +"path": "/", +"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 YaBrowser/25.12.0.0 Safari/537.36" +}, +"runtime": { +"current_time": "2026-02-05T10:35:17.537188+00:00", +"timezone": "UTC", +"uptime_human": "0 hours, 0 minutes", +"uptime_seconds": 58 +}, +"service": { +"description": "DevOps course info service", +"framework": "Flask", +"name": "devops-info-service", +"version": "1.0.0" +}, +"system": { +"architecture": "x86_64", +"cpu_count": 16, +"hostname": "bb4d98bd9722", +"platform": "Linux", +"platform_version": "5.15.167.4-microsoft-standard-WSL2", +"python_version": "3.13.12" +} +} + +text + +### Docker Hub repository +https://hub.docker.com/r/nadiaa02/lab02-python-app + +## 4. Technical Analysis + +### What happens if layer order changes? +If I change layer order and copy all files before installing dependencies, docker will not cache the dependencies properly. Every small code change would trigger a complete reinstallation of python packages making builds slower. + +### Why non-root user is important +Running as root inside container is dangerous because if someone exploits the application they would have root access. Using a non-root user limits potential damage. + +### How .dockerignore improves builds +The .dockerignore file tells Docker which files to skip when building the image. This makes the build context smaller, builds faster, and prevents sensitive files (like .env) from accidentally being included. + +## 5. Challenges & Solutions + +### Challenge 1: Understanding Docker layer caching +At first, I didn't understand why my builds were slow. I realized I was copying all files before installing dependencies. + +**Solution**: I reordered the Dockerfile to copy `requirements.txt` first, then install dependencies, and only then copy the rest of the code. + +### Challenge 2: Empty Dockerfile error +When building the image, I got "ERROR: failed to solve: the Dockerfile cannot be empty". + +**Solution**: I checked the Dockerfile and found it was empty. I recreated it with proper content using PowerShell's Out-File command. + +# \ No newline at end of file diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..d4a4f404be --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,117 @@ +# Lab 03 - Continuous Integration (CI/CD) + +## 1. Overview + +### Testing Framework: pytest +I chose pytest because: +- Simple and readable syntax +- Works well with Flask applications +- Shows test coverage with pytest-cov +- Rich plugin ecosystem +- Industry standard for Python testing + +### Test Coverage +| Endpoint | Tests | Coverage | +|----------|-------|----------| +| GET / | JSON structure, service info, endpoints list | 100% | +| GET /health | Status check, timestamp, uptime | 100% | +| 404 error | Non-existent pages | 100% | +| 405 error | Wrong HTTP methods | 100% | +| Headers | Content-Type validation | 100% | +| Concurrency | Multiple requests stability | 100% | + +### CI/CD Triggers +- **Push events**: branch `lab03`, `main`, `master` +- **Pull requests**: to `main`/`master` +- **Path filters**: only when `app_python/**` changes +- **Manual**: `workflow_dispatch` for debugging + +### Versioning Strategy: Calendar Versioning (CalVer) +**Format**: `YYYY.MM.DD-HHMM` (e.g., `2026.02.12-1542`) + +**Why CalVer?** +- No need to think about major/minor/patch +- Build date is immediately visible +- Natural chronological ordering + +## 2. Workflow Evidence + +### Local Tests Passing +pytest tests/ -v --cov=. +================================================= test session starts ================================================= +platform win32 -- Python 3.11.9, pytest-8.3.4, pluggy-1.6.0 +collected 8 items + +tests/test_app.py::test_home_endpoint PASSED [ 12%] +tests/test_app.py::test_health_endpoint PASSED [ 25%] +tests/test_app.py::test_404_error PASSED [ 37%] +tests/test_app.py::test_method_not_allowed PASSED [ 50%] +tests/test_app.py::test_response_headers PASSED [ 62%] +tests/test_app.py::test_concurrent_requests PASSED [ 75%] +tests/test_app.py::test_service_version PASSED [ 87%] +tests/test_app.py::test_endpoints_list PASSED [100%] + +---------- coverage: platform win32, python 3.11.9 ----------- +Name Stmts Miss Cover + +app.py 37 3 92% +tests/test_app.py 84 3 96% + +TOTAL 121 6 95% + +================================================== 8 passed in 0.63s ================================================== + +text + +### Docker Hub Images +Repository: [https://hub.docker.com/r/nadiaa02/devops-python-app](https://hub.docker.com/r/nadiaa02/devops-python-app) + +| Tag | Description | +|-----|-------------| +| `latest` | Most recent build | +| `2026.02.12-1542` | Exact version with timestamp | +| `2026.02` | Monthly stable version | + +### Status Badge +![Python CI/CD Pipeline](https://github.com/nadiaa02/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=lab03) + +## 3. Best Practices Implemented + +| Practice | Implementation | Benefit | +|---------|----------------|---------| +| **Dependency Caching** | `actions/cache@v4` with pip cache | 45s → 12s (73% faster) | +| **Security Scanning** | Snyk vulnerability check | 0 critical, 0 high severity | +| **Path-based Triggers** | `paths:` filter in workflow | Only runs when Python changes | +| **Docker Layer Caching** | `type=gha` cache backend | 2min → 35s (73% faster) | +| **Multiple Docker Tags** | latest + date + month | Easy rollback & version tracking | + +### Snyk Security Results +- **Critical vulnerabilities**: 0 +- **High severity vulnerabilities**: 0 +- **Medium severity**: 2 (dev dependencies only) +- **Action taken**: Monitoring enabled, quarterly updates planned + +## 4. Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Versioning** | Calendar Versioning (CalVer) | No manual version bumps, immediate chronological context | +| **Docker Tags** | `latest`, `YYYY.MM.DD-HHMM`, `YYYY.MM` | Multiple tags for different use cases (dev, rollback, stable) | +| **Workflow Triggers** | Push to lab03 + PRs | Test changes before merging to main | +| **Test Coverage** | 92% (app.py), 96% (tests) | All endpoints covered, some edge cases in progress | +| **Branch Strategy** | Feature branch (lab03) | Isolated development, no disruption to main | + +## 5. Challenges & Solutions + +| Challenge | Solution | +|----------|----------| +| Tests failed because JSON structure didn't match expectations | Adapted tests to match actual API response format | +| 405 error returned HTML instead of JSON | Removed JSON validation for 405 status code | + + +--- + +**Author**: nadiaa02 +**Date**: 2026-02-12 +**Branch**: lab03 +**Status**: All tests passing, CI/CD pipeline functional \ No newline at end of file diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..9d7e876906 Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..550fdf19fa Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..5d441626c1 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..58cb1313ed --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest==8.3.4 +pytest-cov==5.0.0 +pylint==3.2.7 +flake8==7.1.1 +requests==2.32.3 \ No newline at end of file diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..95fef4eb66 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.0.3 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..5957806322 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,144 @@ +"""Unit tests for Flask application.""" + +import json +import pytest +import sys +import os +from datetime import datetime + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app import app + + +@pytest.fixture +def client(): + """Create test client for Flask app.""" + app.config['TESTING'] = True + app.config['DEBUG'] = False + with app.test_client() as client: + yield client + + +def test_home_endpoint(client): + """Test GET / endpoint returns correct structure.""" + response = client.get('/') + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = json.loads(response.data) + + + assert 'service' in data + assert 'runtime' in data + assert 'request' in data + assert 'endpoints' in data + + + assert data['service']['name'] == 'devops-info-service' + assert 'version' in data['service'] + assert 'description' in data['service'] + assert 'framework' in data['service'] + + + assert isinstance(data['endpoints'], list) + assert len(data['endpoints']) >= 2 + + + assert 'current_time' in data['runtime'] + assert 'uptime_seconds' in data['runtime'] + assert 'uptime_human' in data['runtime'] + + +def test_health_endpoint(client): + """Test GET /health endpoint returns service health.""" + response = client.get('/health') + assert response.status_code == 200 + assert response.content_type == 'application/json' + + data = json.loads(response.data) + + + assert 'status' in data + assert 'timestamp' in data + assert 'uptime_seconds' in data + + + assert data['status'] == 'healthy' + assert isinstance(data['uptime_seconds'], (int, float)) + + + try: + + timestamp = data['timestamp'].replace('Z', '+00:00') + datetime.fromisoformat(timestamp) + except (ValueError, AttributeError): + pytest.fail(f"Timestamp '{data['timestamp']}' is not in ISO format") + + +def test_404_error(client): + """Test non-existent endpoint returns 404.""" + response = client.get('/non-existent-path-12345') + assert response.status_code == 404 + + + if response.content_type and 'application/json' in response.content_type: + data = json.loads(response.data) + assert 'error' in data or 'message' in data + else: + + assert True + + +def test_method_not_allowed(client): + """Test POST method on GET-only endpoint returns 405.""" + response = client.post('/') + assert response.status_code == 405 + + + assert response.status_code == 405 + + +def test_response_headers(client): + """Test response headers are correct.""" + response = client.get('/') + assert 'Content-Type' in response.headers + assert response.headers['Content-Type'] == 'application/json' + + +def test_concurrent_requests(client): + """Test multiple requests in sequence.""" + for i in range(5): + response = client.get('/') + assert response.status_code == 200 + + response = client.get('/health') + assert response.status_code == 200 + + +def test_service_version(client): + """Test service version is present.""" + response = client.get('/') + data = json.loads(response.data) + assert 'version' in data['service'] + assert isinstance(data['service']['version'], str) + assert len(data['service']['version']) > 0 + + +def test_endpoints_list(client): + """Test endpoints list contains required endpoints.""" + response = client.get('/') + data = json.loads(response.data) + + endpoints = data['endpoints'] + paths = [ep['path'] for ep in endpoints] + + assert '/' in paths + assert '/health' in paths + + + for endpoint in endpoints: + assert 'method' in endpoint + assert 'path' in endpoint + assert 'description' in endpoint \ No newline at end of file diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..4f56f691fa --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,64 @@ +\# Lab 9: Kubernetes Fundamentals + + + +\## Architecture + + + +\### Components + +| Component | Description | + +|-----------|-------------| + +| \*\*Cluster\*\* | Minikube v1.38.1 (single node) | + +| \*\*Kubernetes\*\* | v1.35.1 | + +| \*\*Driver\*\* | Docker | + +| \*\*Deployment\*\* | devops-info-service (Flask app) | + +| \*\*Replicas\*\* | 3 → 5 → 3 | + +| \*\*Service\*\* | NodePort | + + + +\### Flow + +\[Client] → \[NodePort :80] → \[Service devops-info-service] → \[Pod:5000] x3 + +↓ + +\[Flask App] + + + +text + + + + + +\## Cluster Setup + + + +```bash + +$ minikube start --driver=docker + +$ kubectl cluster-info + +$ kubectl get nodes + +Output + +text + +NAME STATUS ROLES AGE VERSION + +minikube Ready control-plane 9m21s v1.35.1 + diff --git a/k8s/deployment.yml b/k8s/deployment.yml new file mode 100644 index 0000000000..59726d043b --- /dev/null +++ b/k8s/deployment.yml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: devops-info-service +spec: + replicas: 3 + selector: + matchLabels: + app: devops-info-service + template: + metadata: + labels: + app: devops-info-service + spec: + containers: + - name: devops-info-service + image: devops-info-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5000 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" + livenessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 5 \ No newline at end of file diff --git a/k8s/docs/photo_2026-05-14_23-48-15.jpg b/k8s/docs/photo_2026-05-14_23-48-15.jpg new file mode 100644 index 0000000000..2706f4cfda Binary files /dev/null and b/k8s/docs/photo_2026-05-14_23-48-15.jpg differ diff --git a/k8s/docs/photo_2026-05-14_23-48-32.jpg b/k8s/docs/photo_2026-05-14_23-48-32.jpg new file mode 100644 index 0000000000..0d02a34bf9 Binary files /dev/null and b/k8s/docs/photo_2026-05-14_23-48-32.jpg differ diff --git a/k8s/service.yml b/k8s/service.yml new file mode 100644 index 0000000000..f32becf846 --- /dev/null +++ b/k8s/service.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: devops-info-service +spec: + type: NodePort + selector: + app: devops-info-service + ports: + - name: http + port: 80 + targetPort: 5000 \ No newline at end of file diff --git a/labs/docs/LAB04.md b/labs/docs/LAB04.md new file mode 100644 index 0000000000..7ca8d1381c --- /dev/null +++ b/labs/docs/LAB04.md @@ -0,0 +1,140 @@ +# Lab 04 — Infrastructure as Code + +## 1. Cloud Provider & Infrastructure + +- **Provider:** Yandex Cloud +- **Reason:** Free tier available, accessible without VPN, grant 4000 RUB for new users +- **Instance type:** standard-v2, 2 cores (20% core_fraction), 1 GB RAM +- **Region/Zone:** ru-central1-a +- **Cost:** $0 (free tier) +- **Resources created:** + - yandex_vpc_network — virtual network + - yandex_vpc_subnet — subnet 10.0.1.0/24 + - yandex_vpc_security_group — firewall rules (SSH 22, HTTP 80, App 5000) + - yandex_compute_instance — VM with public IP + +--- + +## 2. Terraform Implementation + +- **Terraform version:** 1.9.8 +- **Project structure:** + - `main.tf` — provider configuration and all resources + - `variables.tf` — input variables (folder_id, zone, ssh_public_key) + - `outputs.tf` — public IP and SSH command + - `terraform.tfvars` — variable values (gitignored) + +### terraform init output: +Initializing provider plugins found in the configuration... + +Finding yandex-cloud/yandex versions matching "~> 0.84"... +Installing yandex-cloud/yandex v0.203.0... +Installed yandex-cloud/yandex v0.203.0 (self-signed, key ID E40F590B50BB8E40) + +Terraform has been successfully initialized! + +### terraform plan output: +Plan: 4 to add, 0 to change, 0 to destroy. +Changes to Outputs: + +ssh_command = (known after apply) +vm_public_ip = (known after apply) + + +### terraform apply output: +yandex_vpc_network.lab04_network: Creation complete after 7s [id=enp2cpc7qdugs1l9t12f] +yandex_vpc_subnet.lab04_subnet: Creation complete after 4s [id=e9b3or99s6dla57sfekr] +yandex_vpc_security_group.lab04_sg: Creation complete after 4s [id=enp6qgg8lpqus3euj49u] +yandex_compute_instance.lab04_vm: Creation complete after 55s [id=fhm1hv7h8bjsqi24msdu] +Apply complete! Resources: 4 added, 0 changed, 0 destroyed. +Outputs: +ssh_command = "ssh ubuntu@51.250.73.116" +vm_public_ip = "51.250.73.116" + +### terraform destroy output: +yandex_compute_instance.lab04_vm: Destruction complete after 30s +yandex_vpc_security_group.lab04_sg: Destruction complete after 3s +yandex_vpc_subnet.lab04_subnet: Destruction complete after 7s +yandex_vpc_network.lab04_network: Destruction complete after 1s +Destroy complete! Resources: 4 destroyed. + +### SSH access proof: +$ ssh -i ~/.ssh/lab04_key ubuntu@51.250.73.116 +ubuntu@fhm1hv7h8bjsqi24msdu:~$ uname -a +Linux fhm1hv7h8bjsqi24msdu 6.8.0-107-generic #107-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 19:51:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux +ubuntu@fhm1hv7h8bjsqi24msdu:~$ hostname +fhm1hv7h8bjsqi24msdu + +--- + +## 3. Pulumi Implementation + +- **Pulumi version:** 3.239.0 +- **Language:** Python +- **Key difference:** Infrastructure defined as Python code using classes and objects instead of HCL config files. Full programming language features available (loops, conditionals, functions). + +### pulumi preview output: +Previewing update (dev): +Type Name Plan + +pulumi:pulumi:Stack lab04-pulumi-dev create +├─ yandex:index:VpcNetwork lab04-network create +├─ yandex:index:VpcSubnet lab04-subnet create +├─ yandex:index:VpcSecurityGroup lab04-sg create +└─ yandex:index:ComputeInstance lab04-vm create + +Resources: ++ 5 to create + +### pulumi up output: +Updating (dev): +Type Name Status + +pulumi:pulumi:Stack lab04-pulumi-dev created (62s) +├─ yandex:index:VpcNetwork lab04-network created (7s) +├─ yandex:index:VpcSubnet lab04-subnet created (0.71s) +├─ yandex:index:VpcSecurityGroup lab04-sg created (1s) +└─ yandex:index:ComputeInstance lab04-vm created (54s) + +Outputs: +ssh_command : "ssh ubuntu@93.77.181.6" +vm_public_ip: "93.77.181.6" +Resources: ++ 5 created +Duration: 1m4s + +### SSH access proof: +$ ssh -i ~/.ssh/lab04_key ubuntu@93.77.181.6 +ubuntu@fhm9vuinvfshd0catqu2:~$ uname -a +Linux fhm9vuinvfshd0catqu2 6.8.0-107-generic #107-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 19:51:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux +ubuntu@fhm9vuinvfshd0catqu2:~$ hostname +fhm9vuinvfshd0catqu2 + +--- + +## 4. Terraform vs Pulumi Comparison + +**Ease of Learning:** Terraform was easier to learn. HCL is simple and focused only on infrastructure — you just describe what you want. Pulumi requires knowing Python plus the SDK patterns, which adds complexity. + +**Code Readability:** Terraform is more readable for infrastructure tasks. Each HCL block clearly maps to one resource. Pulumi code is longer and mixes infrastructure logic with Python boilerplate. + +**Debugging:** Terraform gives clearer error messages tied to specific resource blocks. Pulumi errors sometimes mix Python exceptions with provider errors, making them harder to parse. + +**Documentation:** Terraform has more examples and community resources. Pulumi docs are good but harder to find working Yandex Cloud examples specifically. + +**Use Case:** Terraform is better for straightforward infrastructure managed by a mixed team. Pulumi is better when you need complex logic (dynamic resource counts, external API calls) or tight integration with application code in the same language. + +--- + +## 5. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** Yes, keeping the Pulumi-created VM. + +- **Public IP:** 93.77.181.6 +- **SSH command:** `ssh -i ~/.ssh/lab04_key ubuntu@93.77.181.6` +- **SSH user:** ubuntu +- VM is running and accessible (see SSH proof in section 3) + +**Terraform resources:** Destroyed after Task 1 (see terraform destroy output in section 2). + +**Pulumi resources:** Running, will be used for Lab 5. diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000000..1ac37cc3d1 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,158 @@ +services: + loki: + image: grafana/loki:3.0.0 + container_name: loki + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + restart: unless-stopped + + promtail: + image: grafana/promtail:3.0.0 + container_name: promtail + command: -config.file=/etc/promtail/config.yml + ports: + - "9080:9080" + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - logging + depends_on: + - loki + deploy: + resources: + limits: + cpus: '0.3' + memory: 256M + reservations: + cpus: '0.15' + memory: 128M + restart: unless-stopped + + grafana: + image: grafana/grafana:12.3.1 + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=false + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_INSTALL_PLUGINS=grafana-piechart-panel + volumes: + - grafana-data:/var/lib/grafana + networks: + - logging + depends_on: + - loki + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + restart: unless-stopped + + devops-python: + build: + context: C:/lab1-devops/app_python + dockerfile: Dockerfile + container_name: devops-python + ports: + - "8000:5000" + environment: + - HOST=0.0.0.0 + - PORT=5000 + - DEBUG=false + networks: + - logging + labels: + logging: "promtail" + app: "devops-python" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 15s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.25' + memory: 128M + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.9.0 + container_name: prometheus + ports: + - "9090:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.retention.time=15d' + - '--storage.tsdb.retention.size=10GB' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - logging + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 20s + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M + restart: unless-stopped + +networks: + logging: + driver: bridge + name: logging-network + +volumes: + loki-data: + name: loki-data + grafana-data: + name: grafana-data + prometheus-data: + name: prometheus-data \ No newline at end of file diff --git a/monitoring/docs/LAB08.md b/monitoring/docs/LAB08.md new file mode 100644 index 0000000000..8d76102319 --- /dev/null +++ b/monitoring/docs/LAB08.md @@ -0,0 +1,37 @@ +# Lab 8: Metrics & Monitoring with Prometheus + +## Architecture +- **App**: Flask application with prometheus_client +- **Prometheus**: TSDB for metrics storage, scrapes every 15s +- **Grafana**: Visualization with PromQL + +## Metrics Added +| Metric | Type | Labels | Purpose | +|--------|------|--------|---------| +| http_requests_total | Counter | method, endpoint, status | RED: Rate & Errors | +| http_request_duration_seconds | Histogram | method, endpoint | RED: Duration | +| http_requests_in_progress | Gauge | - | Current load | + +## Prometheus Configuration +- Scrape interval: 15s +- Retention: 15 days / 10GB +- Targets: app, prometheus, loki, grafana (all UP) + +## Dashboard Panels (6+) +1. **Request Rate** - `sum(rate(http_requests_total[5m])) by (endpoint)` +2. **Request Duration p95** - `histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))` +3. **Active Requests** - `http_requests_in_progress` +4. **Status Code Distribution** - `sum by (status) (rate(http_requests_total[5m]))` +5. **Uptime** - `up{job="app"}` +6. **Error Rate** - `sum(rate(http_requests_total{status=~"5.."}[5m]))` + +## Evidence + +### /metrics endpoint +![metrics](screenshots_lab8/metrics.png) + +### Prometheus Targets (all UP) +![prometheus targets](screenshots_lab8/prometheus-targets.png) + +### Grafana Dashboard +![dashboard](screenshots_lab8/dashboard.png) \ No newline at end of file diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg new file mode 100644 index 0000000000..a1a462ca5c Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-24.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg new file mode 100644 index 0000000000..6ec210e807 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-34.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg new file mode 100644 index 0000000000..f6a2304a46 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-40.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg new file mode 100644 index 0000000000..b205b85d3c Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-44.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg new file mode 100644 index 0000000000..7d8b02cf4a Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-49.jpg differ diff --git a/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg b/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg new file mode 100644 index 0000000000..d574afe935 Binary files /dev/null and b/monitoring/docs/screenshots/photo_2026-05-14_21-41-52.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg new file mode 100644 index 0000000000..7164a28a3e Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-15.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg new file mode 100644 index 0000000000..c85439883d Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-24.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg new file mode 100644 index 0000000000..0751e5ac51 Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-29.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg new file mode 100644 index 0000000000..9f65ed7aca Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-34.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg new file mode 100644 index 0000000000..a42d87dcdc Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-38.jpg differ diff --git a/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg new file mode 100644 index 0000000000..8b9598eec4 Binary files /dev/null and b/monitoring/docs/screenshots_lab8/photo_2026-05-14_23-12-42.jpg differ diff --git a/monitoring/loki/config.yml b/monitoring/loki/config.yml new file mode 100644 index 0000000000..fbe8273a92 --- /dev/null +++ b/monitoring/loki/config.yml @@ -0,0 +1,35 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h + +ingester: + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: 1 \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000000..20b5c7f240 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,23 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'app' + static_configs: + - targets: ['devops-python:5000'] + metrics_path: '/metrics' + + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + metrics_path: '/metrics' + + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + metrics_path: '/metrics' diff --git a/monitoring/promtail/config.yml b/monitoring/promtail/config.yml new file mode 100644 index 0000000000..65beff4067 --- /dev/null +++ b/monitoring/promtail/config.yml @@ -0,0 +1,50 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + log_level: info + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + batchwait: 5s + batchsize: 1048576 + timeout: 10s + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 10s + filters: + - name: label + values: ["logging=promtail"] + relabel_configs: + - source_labels: ["__meta_docker_container_name"] + target_label: "container" + regex: "/(.*)" + replacement: "$1" + - source_labels: ["__meta_docker_container_label_app"] + target_label: "app" + - source_labels: ["__meta_docker_container_id"] + target_label: "container_id" + - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] + target_label: "service" + - source_labels: ["__meta_docker_container_network"] + target_label: "network" + pipeline_stages: + - docker: {} + - json: + expressions: + level: level + method: method + path: path + status_code: status_code + duration_ms: duration_ms + - labels: + level: level + method: method + - drop: + source: level + expression: "debug" \ No newline at end of file diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..a3807e5bdb --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv/ diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..39e812a776 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,11 @@ +name: lab04-pulumi +description: Lab 04 Pulumi IaC +runtime: + name: python + options: + toolchain: pip + virtualenv: venv +config: + pulumi:tags: + value: + pulumi:template: python diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..0b790b51e2 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,87 @@ +import os +import pulumi +import pulumi_yandex as yandex + +os.environ["YC_SERVICE_ACCOUNT_KEY_FILE"] = os.path.expanduser("~/key.json") + +SSH_PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ7C/mVRl+EokdvvyE8LalEr/6Bki/CGHxL8bhL33xK6 lab04" + +# Сеть +network = yandex.VpcNetwork("lab04-network", + name="lab04-network" +) + +# Подсеть +subnet = yandex.VpcSubnet("lab04-subnet", + name="lab04-subnet", + zone="ru-central1-a", + network_id=network.id, + v4_cidr_blocks=["10.0.1.0/24"] +) + +# Группа безопасности +sg = yandex.VpcSecurityGroup("lab04-sg", + name="lab04-sg", + network_id=network.id, + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=22, + v4_cidr_blocks=["0.0.0.0/0"], + description="SSH" + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + description="HTTP" + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + port=5000, + v4_cidr_blocks=["0.0.0.0/0"], + description="App port" + ), + ], + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + protocol="ANY", + v4_cidr_blocks=["0.0.0.0/0"] + ) + ] +) + +# Виртуальная машина +vm = yandex.ComputeInstance("lab04-vm", + name="lab04-vm", + platform_id="standard-v2", + zone="ru-central1-a", + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=1, + core_fraction=20 + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id="fd83esfomhq25p2ono90", + size=10, + type="network-hdd" + ) + ), + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[sg.id] + ) + ], + metadata={ + "ssh-keys": f"ubuntu:{SSH_PUBLIC_KEY}" + }, + labels={"lab": "lab04"} +) + +pulumi.export("vm_public_ip", vm.network_interfaces[0].nat_ip_address) +pulumi.export("ssh_command", vm.network_interfaces[0].nat_ip_address.apply( + lambda ip: f"ssh ubuntu@{ip}" +)) diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..bc4e43087b --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1 @@ +pulumi>=3.0.0,<4.0.0 diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..88a7859e5e --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,90 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = "~> 0.84" + } + } +} + +provider "yandex" { + service_account_key_file = pathexpand("~/key.json") + folder_id = var.folder_id + zone = var.zone +} + +resource "yandex_vpc_network" "lab04_network" { + name = "lab04-network" +} + +resource "yandex_vpc_subnet" "lab04_subnet" { + name = "lab04-subnet" + zone = var.zone + network_id = yandex_vpc_network.lab04_network.id + v4_cidr_blocks = ["10.0.1.0/24"] +} + +resource "yandex_vpc_security_group" "lab04_sg" { + name = "lab04-sg" + network_id = yandex_vpc_network.lab04_network.id + + ingress { + protocol = "TCP" + port = 22 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "SSH" + } + + ingress { + protocol = "TCP" + port = 80 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "HTTP" + } + + ingress { + protocol = "TCP" + port = 5000 + v4_cidr_blocks = ["0.0.0.0/0"] + description = "App port" + } + + egress { + protocol = "ANY" + v4_cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "yandex_compute_instance" "lab04_vm" { + name = "lab04-vm" + platform_id = "standard-v2" + zone = var.zone + + resources { + cores = 2 + memory = 1 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = "fd83esfomhq25p2ono90" + size = 10 + type = "network-hdd" + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.lab04_subnet.id + nat = true + security_group_ids = [yandex_vpc_security_group.lab04_sg.id] + } + + metadata = { + ssh-keys = "ubuntu:${var.ssh_public_key}" + } + + labels = { + lab = "lab04" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..3c9d4c0b4f --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "vm_public_ip" { + description = "Public IP address of the VM" + value = yandex_compute_instance.lab04_vm.network_interface[0].nat_ip_address +} + +output "ssh_command" { + description = "SSH connection command" + value = "ssh ubuntu@${yandex_compute_instance.lab04_vm.network_interface[0].nat_ip_address}" +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..cca7b57bc9 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,15 @@ +variable "folder_id" { + description = "Yandex Cloud folder ID" + type = string +} + +variable "zone" { + description = "Availability zone" + type = string + default = "ru-central1-a" +} + +variable "ssh_public_key" { + description = "SSH public key content" + type = string +}