diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b01208ba29 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,131 @@ +name: Python CI/CD + +on: + push: + branches: + - master + - main + - lab03 + paths: + - 'Lab-1/app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: + - master + - main + paths: + - 'Lab-1/app_python/**' + - '.github/workflows/python-ci.yml' + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Lint and tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ['3.11', '3.12'] + + defaults: + run: + working-directory: Lab-1/app_python + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + Lab-1/app_python/requirements.txt + Lab-1/app_python/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint with Ruff + run: ruff check . + + - name: Run tests with coverage + run: pytest --cov=. --cov-report=term-missing --cov-fail-under=70 + + security: + name: Snyk dependency scan + runs-on: ubuntu-latest + needs: quality + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + working-directory: Lab-1/app_python + run: pip install -r requirements.txt + + - name: Run Snyk scan + if: ${{ secrets.SNYK_TOKEN != '' }} + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + command: test + args: --file=Lab-1/app_python/requirements.txt --severity-threshold=high + + - name: Snyk token is missing + if: ${{ secrets.SNYK_TOKEN == '' }} + run: echo "SNYK_TOKEN is not configured. Security scan skipped." + + docker: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: + - quality + - security + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate CalVer tags + run: | + echo "CALVER=$(date -u +'%Y.%m.%d').${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV" + echo "CALVER_MONTH=$(date -u +'%Y.%m')" >> "$GITHUB_ENV" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./Lab-1/app_python + file: ./Lab-1/app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:${{ env.CALVER }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:${{ env.CALVER_MONTH }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-lab2:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..10442acc5f --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,66 @@ +name: Terraform Validate + +on: + push: + branches: + - master + - main + - lab04 + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + pull_request: + branches: + - master + - main + paths: + - 'terraform/**' + - '.github/workflows/terraform-ci.yml' + +concurrency: + group: terraform-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + workdir: + - terraform + - terraform/github-import + + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.8 + + - name: Setup TFLint + uses: terraform-linters/setup-tflint@v4 + + - name: Check Terraform formatting + run: terraform fmt -check -recursive + + - name: Terraform init + working-directory: ${{ matrix.workdir }} + run: terraform init -backend=false + + - name: Terraform validate + working-directory: ${{ matrix.workdir }} + run: terraform validate + + - name: Initialize TFLint plugins + working-directory: ${{ matrix.workdir }} + run: tflint --init + + - name: Run TFLint + working-directory: ${{ matrix.workdir }} + run: tflint --format compact diff --git a/.gitignore b/.gitignore index 30d74d2584..fe2b520861 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,34 @@ -test \ No newline at end of file +test + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +*.tfvars +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Pulumi +.pulumi/ +Pulumi.*.yaml +pulumi/venv/ + +# Python caches +.pytest_cache/ +.ruff_cache/ +.coverage +**/__pycache__/ +**/*.pyc + +# Credentials and keys +*.pem +*.key +*.p12 +*.jks +*.json +credentials diff --git a/Lab-1/app_python/.coverage b/Lab-1/app_python/.coverage new file mode 100644 index 0000000000..7f2f91c844 Binary files /dev/null and b/Lab-1/app_python/.coverage differ diff --git a/Lab-1/app_python/.dockerignore b/Lab-1/app_python/.dockerignore new file mode 100644 index 0000000000..d6a3c723bd --- /dev/null +++ b/Lab-1/app_python/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.log +.env +venv/ +.venv/ +.git/ +.gitignore +.vscode/ +.idea/ +docs/ +tests/ +README.md diff --git a/Lab-1/app_python/.gitignore b/Lab-1/app_python/.gitignore new file mode 100644 index 0000000000..4928ef8572 --- /dev/null +++ b/Lab-1/app_python/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log +.env + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store diff --git a/Lab-1/app_python/Dockerfile b/Lab-1/app_python/Dockerfile new file mode 100644 index 0000000000..3de77fcb17 --- /dev/null +++ b/Lab-1/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +USER app + +CMD ["python", "app.py"] diff --git a/Lab-1/app_python/README.md b/Lab-1/app_python/README.md new file mode 100644 index 0000000000..b8c51ee71e --- /dev/null +++ b/Lab-1/app_python/README.md @@ -0,0 +1,95 @@ +# DevOps Info Service (Flask) +[![Python CI/CD](https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg?branch=master)](https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## Overview +A small Flask web service that reports service metadata, system information, runtime details, and request context. It also exposes a health check endpoint and Swagger UI. + +## Prerequisites +- Python 3.11+ +- pip + +## Installation +```bash +python -m venv venv +# Linux/macOS +source venv/bin/activate +# Windows PowerShell +.\venv\Scripts\Activate.ps1 + +pip install -r requirements.txt +``` + +## Configuration via .env (optional) +Create a `.env` file in `app_python/`: +```env +HOST=0.0.0.0 +PORT=5000 +DEBUG=false +``` + +## Running the Application +```bash +python app.py +``` + +With custom configuration: +```bash +PORT=8080 python app.py +HOST=127.0.0.1 PORT=3000 DEBUG=true python app.py +``` + +Windows PowerShell: +```powershell +$env:PORT=8080; python app.py +$env:HOST='127.0.0.1'; $env:PORT=3000; $env:DEBUG='true'; python app.py +``` + +## Docker +Build image (pattern): +```bash +docker build -t linktur/devops-lab2:v1 . +``` + +Run container (pattern): +```bash +docker run --rm -p 5000:5000 --name devops-lab2 linktur/devops-lab2:v1 +``` + +Pull from Docker Hub (pattern): +```bash +docker pull linktur/devops-lab2:v1 +``` + +## API Endpoints +- `GET /` - Service and system information +- `GET /health` - Health check +- `GET /swagger.json` - OpenAPI spec +- `GET /docs` - Swagger UI + +## Local Quality Checks +Install development dependencies: +```bash +pip install -r requirements.txt -r requirements-dev.txt +``` + +Run linter: +```bash +ruff check . +``` + +Run unit tests: +```bash +pytest +``` + +Run tests with coverage threshold (same as CI): +```bash +pytest --cov=. --cov-report=term-missing --cov-fail-under=70 +``` + +## Configuration +| Variable | Default | Description | +|---|---|---| +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | HTTP port | +| `DEBUG` | `False` | Flask debug mode (`true`/`false`) | diff --git a/Lab-1/app_python/app.py b/Lab-1/app_python/app.py new file mode 100644 index 0000000000..4a6bdfb9a2 --- /dev/null +++ b/Lab-1/app_python/app.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import logging +import os +import platform +import socket +from datetime import datetime, timezone + +from dotenv import load_dotenv +from flask import Flask, jsonify, request +from flask_swagger_ui import get_swaggerui_blueprint + +app = Flask(__name__) + +# conf +load_dotenv() +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' + +# start time +START_TIME = datetime.now(timezone.utc) + +# Logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) +logger.info('Application starting...') + +# swagger info +SWAGGER_URL = '/docs' +SWAGGER_API_URL = '/swagger.json' + +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + SWAGGER_API_URL, + config={'app_name': 'DevOps Info Service'} +) +app.register_blueprint(swaggerui_blueprint, url_prefix=SWAGGER_URL) + + +def _iso_utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') + + +def get_uptime() -> dict: + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return { + 'seconds': seconds, + 'human': f"{hours} hours, {minutes} minutes" + } + + +def get_platform_version() -> str: + system = platform.system() + if system == 'Linux': + try: + os_release = platform.freedesktop_os_release() + return os_release.get('PRETTY_NAME') or os_release.get('NAME') or platform.release() + except (OSError, AttributeError): + return platform.release() + if system == 'Windows': + return platform.version() + return platform.release() + + +def get_system_info() -> dict: + return { + 'hostname': socket.gethostname(), + 'platform': platform.system(), + 'platform_version': get_platform_version(), + 'architecture': platform.machine(), + 'cpu_count': os.cpu_count() or 0, + 'python_version': platform.python_version() + } + + +def get_request_info() -> dict: + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr or '') + if ',' in client_ip: + client_ip = client_ip.split(',')[0].strip() + + return { + 'client_ip': client_ip, + 'user_agent': request.headers.get('User-Agent', ''), + 'method': request.method, + 'path': request.path + } + + +def get_service_info() -> dict: + """return metadata""" + return { + 'name': 'devops-info-service', + 'version': '1.0.0', + 'description': 'DevOps course info service', + 'framework': 'Flask' + } + + +def get_endpoints() -> list[dict]: + """return a list of available endpoints""" + return [ + {'path': '/', 'method': 'GET', 'description': 'Service information'}, + {'path': '/health', 'method': 'GET', 'description': 'Health check'} + ] + +#API +OPENAPI_SPEC = { + 'openapi': '3.0.3', + 'info': { + 'title': 'DevOps Info Service', + 'version': '1.0.0', + 'description': 'Service and system information API' + }, + 'paths': { + '/': { + 'get': { + 'summary': 'Service information', + 'responses': { + '200': { + 'description': 'Service and system information' + } + } + } + }, + '/health': { + 'get': { + 'summary': 'Health check', + 'responses': { + '200': { + 'description': 'Health status' + } + } + } + } + } +} + + +@app.before_request +def log_request() -> None: + logger.debug('Request: %s %s', request.method, request.path) + + +@app.route('/') +def index(): + """main endpoint""" + uptime = get_uptime() + payload = { + 'service': get_service_info(), + 'system': get_system_info(), + 'runtime': { + 'uptime_seconds': uptime['seconds'], + 'uptime_human': uptime['human'], + 'current_time': _iso_utc_now(), + 'timezone': 'UTC' + }, + 'request': get_request_info(), + 'endpoints': get_endpoints() + } + return jsonify(payload) + + +@app.route('/health') +def health(): + """health check endpoint""" + uptime = get_uptime() + return jsonify({ + 'status': 'healthy', + 'timestamp': _iso_utc_now(), + 'uptime_seconds': uptime['seconds'] + }) + + +@app.route('/swagger.json') +def swagger_json(): + return jsonify(OPENAPI_SPEC) + + +@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__': + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/Lab-1/app_python/docs/LAB01.md b/Lab-1/app_python/docs/LAB01.md new file mode 100644 index 0000000000..f8887f20f8 --- /dev/null +++ b/Lab-1/app_python/docs/LAB01.md @@ -0,0 +1,110 @@ +# LAB01 - DevOps Info Service (Python / Flask) + +## 1. Framework Selection +**Chosen framework:** Flask + +**Why Flask:** +- Minimal setup and easy to understand for a first lab +- Clear request/response handling without extra abstractions) +- I tried Django, regretted it + +**Comparison Table** +| Framework | Pros | Cons | Why Not Chosen | +|---|---|---|---| +| Flask | Lightweight, simple, widely used | Fewer built-in features | Selected due to simplicity | +| FastAPI | Async, auto-docs, type hints | Slightly more setup | Didn't try it, because of luck of time | +| Django | Full-featured, includes ORM | Heavy for small API | Too much for the first time| + +## 2. Best Practices Applied +1. **Clean code organization** - helper functions for system, runtime, and request info +2. **Error handling** - custom 404 and 500 responses +3. **Logging** - structured logging with timestamp and level +4. **Configuration via environment variables** - HOST, PORT, DEBUG +5. **Pinned dependencies** - exact versions in `requirements.txt` + +**Code examples (from `app.py`):** +```python +HOST = os.getenv('HOST', '0.0.0.0') +PORT = int(os.getenv('PORT', '5000')) +DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +``` + +```python +@app.errorhandler(404) +def not_found(error): + return jsonify({ + 'error': 'Not Found', + 'message': 'Endpoint does not exist' + }), 404 +``` + +```python +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +``` + +## 3. API Documentation +### 3.1 `GET /` +**Description:** Returns service, system, runtime, request info, and endpoints. + +**Example request:** +```bash +curl http://127.0.0.1:5000/ +``` + +**Example response (truncated):** +```json +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask" + } +} +``` + +### 3.2 `GET /health` +**Description:** Health check endpoint for monitoring. + +**Example request:** +```bash +curl http://127.0.0.1:5000/health +``` + +**Example response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-28T17:31:00.456Z", + "uptime_seconds": 180 +} +``` + +### 3.3 Swagger UI +**OpenAPI spec:** +``` +GET /swagger.json +``` + +**Swagger UI:** +``` +GET /docs +``` +*it was easier to check app with swagger + +## 4. Testing Evidence +Add screenshots to `docs/screenshots/` and embed them here. + +- **Main endpoint:** `screenshots/01-main-endpoint.png` +- **Health check:** `screenshots/02-health-check.png` +- **Pretty-printed output:** `screenshots/03-formatted-output.png` + +## 5. Challenges & Solutions +- **Timezone formatting:** Used UTC with ISO 8601 and `Z` suffix for consistency. +- **Client IP handling:** Added `X-Forwarded-For` fallback for proxy setups. + +## 6. GitHub Community +Starring repositories helps to find useful tools and bookmaer them. Following developers improves collaboration by keeping you aware of classmates' and instructors' work, which supports learning and teamwork. diff --git a/Lab-1/app_python/docs/LAB02.md b/Lab-1/app_python/docs/LAB02.md new file mode 100644 index 0000000000..7bdfec9a5b --- /dev/null +++ b/Lab-1/app_python/docs/LAB02.md @@ -0,0 +1,88 @@ +# LAB02 - Docker Containerization (Python) + +## Docker Best Practices Applied +- Non-root user: I created user `app` and run the app with `USER app` to reduce privileges. +- Fixed base image: `python:3.13-slim` gives a smaller and stable image. +- Layer caching: I copy `requirements.txt` first, then install deps so rebuilds are faster. +- Minimal copy: I only copy `requirements.txt` and `app.py`. +- `.dockerignore`: I exclude `venv/`, `tests/`, `docs/`, VCS, and IDE files to keep the context small. + +Dockerfile snippets: +```dockerfile +FROM python:3.13-slim +``` +Fixed base image keeps builds repeatable and smaller. + +```dockerfile +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +``` +Install deps before app code so cache works. + +```dockerfile +RUN addgroup --system app && adduser --system --ingroup app app +USER app +``` +Run as non-root for better security. + +## Image Information & Decisions +- Base image: `python:3.13-slim` because it is smaller but still works with `pip` and `glibc`. +- Final image size: `127MB`. +- Layer order: base -> env/workdir -> user -> deps -> app code -> user -> cmd. +- Optimizations: slim image, cached deps, no pip cache. + +## Build & Run Process +Build output: +``` +docker build -t linktur/devops-lab2:v1 . +[+] Building 29.5s (12/12) FINISHED + => [internal] load build definition from Dockerfile + => [internal] load metadata for docker.io/library/python:3.13-slim + => [internal] load .dockerignore + => [1/6] FROM docker.io/library/python:3.13-slim + => [2/6] WORKDIR /app + => [3/6] RUN addgroup --system app && adduser --system --ingroup app app + => [4/6] COPY requirements.txt . + => [5/6] RUN pip install --no-cache-dir -r requirements.txt + => [6/6] COPY app.py . + => exporting to image + => naming to docker.io/linktur/devops-lab2:v1 +``` + +Run output: +``` +docker run --rm -p 5000:5000 --name devops-lab2 linktur/devops-lab2:v1 +2026-02-05 09:12:25,566 - __main__ - INFO - Application starting... + * Serving Flask app 'app' + * Debug mode: off + * Running on all addresses (0.0.0.0) + * Running on http://127.0.0.1:5000 + * Running on http://172.17.0.2:5000 +Press CTRL+C to quit +2026-02-05 09:13:00,463 - werkzeug - INFO - 172.17.0.1 - - [05/Feb/2026 09:13:00] "GET /health HTTP/1.1" 200 - +2026-02-05 09:13:05,335 - werkzeug - INFO - 172.17.0.1 - - [05/Feb/2026 09:13:05] "GET / HTTP/1.1" 200 - +``` + +Endpoint tests: +``` +curl http://localhost:5000/health +{"status":"healthy","timestamp":"2026-02-05T09:13:00.463Z","uptime_seconds":34} + +curl http://localhost:5000/ +{"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":"curl/8.13.0"},"runtime":{"current_time":"2026-02-05T09:13:05.335Z","timezone":"UTC","uptime_human":"0 hours, 0 minutes","uptime_seconds":39},"service":{"description":"DevOps course info service","framework":"Flask","name":"devops-info-service","version":"1.0.0"},"system":{"architecture":"x86_64","cpu_count":12,"hostname":"99a476249f8","platform":"Linux","platform_version":"Debian GNU/Linux 13 (trixie)","python_version":"3.13.12"}} +``` + +Docker Hub repository URL: +``` +https://hub.docker.com/repository/docker/linktur/devops-lab2 +``` +Screenshot with proof: +`screenshots/docker-logs.png` +## Technical Analysis +- The Dockerfile installs deps first, then copies app code. This keeps cache when only code changes. +- If I copy code before deps, every change breaks cache and build is slower. +- Security: non-root user, small base image, no extra tools. +- `.dockerignore` makes the build context smaller and faster. + +## Challenges & Solutions +- What I learned: `I finally registered in Docker Hub.` diff --git a/Lab-1/app_python/docs/LAB03.md b/Lab-1/app_python/docs/LAB03.md new file mode 100644 index 0000000000..99424b788c --- /dev/null +++ b/Lab-1/app_python/docs/LAB03.md @@ -0,0 +1,66 @@ +# LAB03 + +## What I implemented +For this lab, I set up a full basic CI/CD flow for the Python app. +- Testing framework: `pytest`. +- Tests file: `Lab-1/app_python/tests/test_app.py`. +- Covered endpoints and cases: + - `GET /` + - `GET /health` + - `404` error + - `500` error + +## CI workflow +I added one workflow that handles quality checks, security scan, and Docker publishing. +- File: `.github/workflows/python-ci.yml` +- Triggers: `push` and `pull_request` with path filters +- Steps: + - install dependencies + - run linter (`ruff`) + - run tests (`pytest`) + - run security scan (`Snyk`) + - build and push Docker image + +## Versioning strategy +I chose **CalVer** because it is simple and works well for continuous delivery. +- Docker tags: + - `YYYY.MM.DD.RUN_NUMBER` + - `YYYY.MM` + - `latest` + +## Evidence +- Workflow: `https://github.com/Linktur/DevOps-Core-Course/actions/workflows/python-ci.yml` +- Docker Hub: `https://hub.docker.com/r/linktur/devops-lab2/tags` +- Status badge: `Lab-1/app_python/README.md` + +Local checks screenshot: +`screenshots/lab03-local-checks.png` + +## Local commands +```bash +pip install -r requirements.txt -r requirements-dev.txt +ruff check . +pytest --cov=. --cov-report=term-missing --cov-fail-under=70 +``` + +## Local result +Everything passed locally: +- `ruff`: passed +- `pytest`: 4 passed +- coverage: 94% + +## CI best practices used +- Path filters +- Matrix testing (Python 3.11 and 3.12) +- Dependency caching +- Concurrency (cancel outdated runs) +- Job dependencies (`needs`) +- Docker publish only from `main`/`master` + +## Final checklist +Before final submission, only these checks are needed: +- Configure GitHub secrets: + - `DOCKERHUB_USERNAME` + - `DOCKERHUB_TOKEN` + - `SNYK_TOKEN` +- Verify one successful green run in GitHub Actions diff --git a/Lab-1/app_python/docs/LAB04.md b/Lab-1/app_python/docs/LAB04.md new file mode 100644 index 0000000000..3653d6ed3e --- /dev/null +++ b/Lab-1/app_python/docs/LAB04.md @@ -0,0 +1,55 @@ +# LAB04 + +### 1. Provider & Infrastructure + +I decided to use a local VM for this lab instead of a cloud instance, as I don't have access to any cloud provider. A local setup is also more convenient for my workflow. + +My machine handles the VM without issues. The VM specs: + +| Parameter | Value | +|-----------------|----------------------------------| +| OS | Debian 13 (6.12.63 amd64) | +| RAM | 2 GB | +| Disk | 10 GB | +| Network | Bridged mode | +| IP Address | 10.241.1.215 | +| SSH | Installed and configured | +| Auth | Public key in `~/.ssh/authorized_keys` | + +### 2. Terraform Implementation + +Terraform was not applied against a cloud provider since a local VM was chosen. However, the full Terraform configuration for AWS is present in `terraform/` — it defines a VPC, subnets, security groups, and an EC2 instance — and passes `terraform validate` successfully. + +### 3. Pulumi Implementation + +Similarly, Pulumi was not run against a cloud provider. The full Pulumi Python configuration is available in `pulumi/` and mirrors the Terraform setup. `pulumi preview` confirms the plan is valid. + +### 4. VM Creation + +After downloading and installing `virtualbox-7.2` (host: `6.18.9+kali-amd64`) and the Debian 13 `.iso`, I set up the VM: + +![Setup step 1](./screenshots/setup1.png) +![Setup step 2](./screenshots/setup2.png) +![Setup step 3](./screenshots/setup3.png) +![Setup step 4](./screenshots/setup4.png) + +Then installed the required packages including `openssh-server`: + +![SSH setup](./screenshots/ssh.png) + +### 5. Exposed Ports & Firewall + +The following ports are accessible within the bridged network: + +| Port | Purpose | +|------|----------| +| 22 | SSH | +| 3000 | App | + +### 6. Lab 5 Preparation & Cleanup + +**Keeping VM for Lab 5:** Yes + +The local Debian 13 VM will be used directly in Lab 5 (Ansible) for Docker installation and application deployment. + +No cloud resources were provisioned, so no `terraform destroy` or `pulumi destroy` is required. diff --git a/Lab-1/app_python/docs/screenshots/01-main-endpoint.png b/Lab-1/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..7a1686749a Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/Lab-1/app_python/docs/screenshots/02-health-check.png b/Lab-1/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..0f778c34e5 Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/02-health-check.png differ diff --git a/Lab-1/app_python/docs/screenshots/03-formatted-output.png b/Lab-1/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..8ee9e2626d Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/Lab-1/app_python/docs/screenshots/docker-logs.png b/Lab-1/app_python/docs/screenshots/docker-logs.png new file mode 100644 index 0000000000..5e3b4b816f Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/docker-logs.png differ diff --git a/Lab-1/app_python/docs/screenshots/lab03-local-checks.png b/Lab-1/app_python/docs/screenshots/lab03-local-checks.png new file mode 100644 index 0000000000..6c6df6f2bb Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/lab03-local-checks.png differ diff --git a/Lab-1/app_python/docs/screenshots/setup1.png b/Lab-1/app_python/docs/screenshots/setup1.png new file mode 100644 index 0000000000..494ef9b6eb Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/setup1.png differ diff --git a/Lab-1/app_python/docs/screenshots/setup2.png b/Lab-1/app_python/docs/screenshots/setup2.png new file mode 100644 index 0000000000..0a0f69937d Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/setup2.png differ diff --git a/Lab-1/app_python/docs/screenshots/setup3.png b/Lab-1/app_python/docs/screenshots/setup3.png new file mode 100644 index 0000000000..b663cffd2a Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/setup3.png differ diff --git a/Lab-1/app_python/docs/screenshots/setup4.png b/Lab-1/app_python/docs/screenshots/setup4.png new file mode 100644 index 0000000000..865c9707d2 Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/setup4.png differ diff --git a/Lab-1/app_python/docs/screenshots/ssh.png b/Lab-1/app_python/docs/screenshots/ssh.png new file mode 100644 index 0000000000..cc8b4d6109 Binary files /dev/null and b/Lab-1/app_python/docs/screenshots/ssh.png differ diff --git a/Lab-1/app_python/requirements-dev.txt b/Lab-1/app_python/requirements-dev.txt new file mode 100644 index 0000000000..bfdecde632 --- /dev/null +++ b/Lab-1/app_python/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==8.3.4 +pytest-cov==6.0.0 +ruff==0.9.10 diff --git a/Lab-1/app_python/requirements.txt b/Lab-1/app_python/requirements.txt new file mode 100644 index 0000000000..7d97f97331 --- /dev/null +++ b/Lab-1/app_python/requirements.txt @@ -0,0 +1,6 @@ +# Web Framework +Flask==3.1.0 +# Swagger UI +flask-swagger-ui==4.11.1 +# Env support +python-dotenv==1.0.1 diff --git a/Lab-1/app_python/tests/__init__.py b/Lab-1/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Lab-1/app_python/tests/test_app.py b/Lab-1/app_python/tests/test_app.py new file mode 100644 index 0000000000..5ec5d231df --- /dev/null +++ b/Lab-1/app_python/tests/test_app.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import app as app_module +import pytest + + +@pytest.fixture() +def client(): + app_module.app.config.update(TESTING=True, PROPAGATE_EXCEPTIONS=False) + with app_module.app.test_client() as test_client: + yield test_client + + +def test_index_returns_required_json_structure(client): + response = client.get( + "/", + headers={ + "User-Agent": "pytest-client", + "X-Forwarded-For": "203.0.113.10, 10.0.0.1", + }, + ) + + assert response.status_code == 200 + payload = response.get_json() + + assert isinstance(payload, dict) + assert {"service", "system", "runtime", "request", "endpoints"}.issubset(payload.keys()) + + assert payload["service"]["name"] == "devops-info-service" + assert payload["request"]["client_ip"] == "203.0.113.10" + assert payload["request"]["user_agent"] == "pytest-client" + assert payload["request"]["method"] == "GET" + assert payload["request"]["path"] == "/" + assert isinstance(payload["endpoints"], list) + + +def test_health_returns_healthy_status(client): + response = client.get("/health") + + assert response.status_code == 200 + payload = response.get_json() + + assert payload["status"] == "healthy" + assert isinstance(payload["timestamp"], str) + assert payload["timestamp"].endswith("Z") + assert isinstance(payload["uptime_seconds"], int) + assert payload["uptime_seconds"] >= 0 + + +def test_not_found_returns_404_json(client): + response = client.get("/missing") + + assert response.status_code == 404 + payload = response.get_json() + assert payload == { + "error": "Not Found", + "message": "Endpoint does not exist", + } + + +def test_internal_server_error_returns_500_json(client, monkeypatch): + def boom(): + raise RuntimeError("forced failure") + + monkeypatch.setattr(app_module, "get_service_info", boom) + + response = client.get("/") + + assert response.status_code == 500 + payload = response.get_json() + assert payload == { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..21048d6b35 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,6 @@ +name: lab04-pulumi-aws +runtime: + name: python + options: + virtualenv: venv +description: Lab 04 Pulumi project for AWS VM provisioning diff --git a/pulumi/README.md b/pulumi/README.md new file mode 100644 index 0000000000..fe1dd27373 --- /dev/null +++ b/pulumi/README.md @@ -0,0 +1,32 @@ +# Pulumi Lab 04 (AWS, Python) + +## Prerequisites +- Pulumi CLI +- Python 3.11+ +- AWS credentials configured +- Existing SSH public key + +## Quick Start +```bash +cd pulumi +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +pulumi stack init dev +pulumi config set aws:region us-east-1 +pulumi config set awsRegion us-east-1 +pulumi config set availabilityZone us-east-1a +pulumi config set sshAllowedCidrs '["x.x.x.x/32"]' --path +pulumi config set sshPublicKeyPath ~/.ssh/id_rsa.pub +pulumi preview +pulumi up +``` + +## Destroy +```bash +pulumi destroy +``` + +## Notes +- Keep `Pulumi..yaml` out of git (already ignored in root `.gitignore`). +- Restrict SSH CIDR to your own IP. diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..b8afc02bee --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,123 @@ +import pathlib + +import pulumi +import pulumi_aws as aws + +config = pulumi.Config() + +aws_region = config.get("awsRegion") or "us-east-1" +availability_zone = config.get("availabilityZone") or "us-east-1a" +project_name = config.get("projectName") or "devops-core-lab04" +instance_type = config.get("instanceType") or "t2.micro" +instance_username = config.get("instanceUsername") or "ubuntu" +vpc_cidr = config.get("vpcCidr") or "10.20.0.0/16" +public_subnet_cidr = config.get("publicSubnetCidr") or "10.20.1.0/24" +ssh_allowed_cidrs = config.get_object("sshAllowedCidrs") or ["0.0.0.0/0"] +ssh_public_key_path = config.get("sshPublicKeyPath") or "~/.ssh/id_rsa.pub" +key_pair_name = config.get("keyPairName") or "devops-core-lab04-key" + +public_key = pathlib.Path(ssh_public_key_path).expanduser().read_text(encoding="utf-8").strip() + +vpc = aws.ec2.Vpc( + "lab04-vpc", + cidr_block=vpc_cidr, + enable_dns_support=True, + enable_dns_hostnames=True, + tags={"Name": f"{project_name}-vpc", "Project": project_name, "Lab": "lab04"}, +) + +subnet = aws.ec2.Subnet( + "lab04-public-subnet", + vpc_id=vpc.id, + cidr_block=public_subnet_cidr, + availability_zone=availability_zone, + map_public_ip_on_launch=True, + tags={"Name": f"{project_name}-public-subnet", "Project": project_name, "Lab": "lab04"}, +) + +igw = aws.ec2.InternetGateway( + "lab04-igw", + vpc_id=vpc.id, + tags={"Name": f"{project_name}-igw", "Project": project_name, "Lab": "lab04"}, +) + +route_table = aws.ec2.RouteTable( + "lab04-public-rt", + vpc_id=vpc.id, + routes=[aws.ec2.RouteTableRouteArgs(cidr_block="0.0.0.0/0", gateway_id=igw.id)], + tags={"Name": f"{project_name}-public-rt", "Project": project_name, "Lab": "lab04"}, +) + +aws.ec2.RouteTableAssociation( + "lab04-public-rta", + subnet_id=subnet.id, + route_table_id=route_table.id, +) + +security_group = aws.ec2.SecurityGroup( + "lab04-sg", + vpc_id=vpc.id, + description="Security group for Lab 04 VM", + ingress=[ + aws.ec2.SecurityGroupIngressArgs( + description="SSH", + from_port=22, + to_port=22, + protocol="tcp", + cidr_blocks=ssh_allowed_cidrs, + ), + aws.ec2.SecurityGroupIngressArgs( + description="HTTP", + from_port=80, + to_port=80, + protocol="tcp", + cidr_blocks=["0.0.0.0/0"], + ), + aws.ec2.SecurityGroupIngressArgs( + description="App port", + from_port=5000, + to_port=5000, + protocol="tcp", + cidr_blocks=["0.0.0.0/0"], + ), + ], + egress=[ + aws.ec2.SecurityGroupEgressArgs( + from_port=0, + to_port=0, + protocol="-1", + cidr_blocks=["0.0.0.0/0"], + ) + ], + tags={"Name": f"{project_name}-sg", "Project": project_name, "Lab": "lab04"}, +) + +key_pair = aws.ec2.KeyPair( + "lab04-keypair", + key_name=key_pair_name, + public_key=public_key, +) + +ubuntu_ami = aws.ec2.get_ami( + most_recent=True, + owners=["099720109477"], + filters=[ + aws.ec2.GetAmiFilterArgs(name="name", values=["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]), + aws.ec2.GetAmiFilterArgs(name="virtualization-type", values=["hvm"]), + ], +) + +instance = aws.ec2.Instance( + "lab04-vm", + ami=ubuntu_ami.id, + instance_type=instance_type, + subnet_id=subnet.id, + vpc_security_group_ids=[security_group.id], + key_name=key_pair.key_name, + associate_public_ip_address=True, + tags={"Name": f"{project_name}-vm", "Project": project_name, "Lab": "lab04"}, +) + +pulumi.export("vmPublicIp", instance.public_ip) +pulumi.export("sshCommand", pulumi.Output.concat("ssh ", instance_username, "@", instance.public_ip)) +pulumi.export("securityGroupId", security_group.id) diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..1f4cbd43ab --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=3.150.0,<4.0.0 +pulumi-aws>=6.66.0,<7.0.0 diff --git a/terraform/.tflint.hcl b/terraform/.tflint.hcl new file mode 100644 index 0000000000..f7f28786b9 --- /dev/null +++ b/terraform/.tflint.hcl @@ -0,0 +1,9 @@ +plugin "terraform" { + enabled = true +} + +plugin "aws" { + enabled = true + version = "0.38.1" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000000..2a71d88588 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,27 @@ +# Terraform Lab 04 (AWS) + +## Prerequisites +- Terraform >= 1.9 +- AWS credentials configured (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) +- Existing SSH public key + +## Quick Start +```bash +cd terraform +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform fmt +terraform validate +terraform plan +terraform apply +``` + +## Destroy +```bash +terraform destroy +``` + +## Notes +- Use free-tier instance type only (`t2.micro`). +- Restrict SSH CIDR in `terraform.tfvars` to your real IP (`x.x.x.x/32`). +- Never commit `terraform.tfvars` or state files. diff --git a/terraform/github-import/.tflint.hcl b/terraform/github-import/.tflint.hcl new file mode 100644 index 0000000000..af0a76a285 --- /dev/null +++ b/terraform/github-import/.tflint.hcl @@ -0,0 +1,9 @@ +plugin "terraform" { + enabled = true +} + +plugin "github" { + enabled = true + version = "0.41.0" + source = "github.com/terraform-linters/tflint-ruleset-github" +} diff --git a/terraform/github-import/README.md b/terraform/github-import/README.md new file mode 100644 index 0000000000..8c46cfa1cb --- /dev/null +++ b/terraform/github-import/README.md @@ -0,0 +1,25 @@ +# Terraform Bonus: GitHub Repository Import + +## Setup +```bash +cd terraform/github-import +terraform init +``` + +Set token as environment variable: +```bash +export TF_VAR_github_token=ghp_xxx +``` + +## Import existing repository +```bash +terraform import github_repository.course_repo DevOps-Core-Course +terraform plan +``` + +## Apply managed settings +```bash +terraform apply +``` + +This keeps existing repository under Terraform control and lets you manage settings as code. diff --git a/terraform/github-import/main.tf b/terraform/github-import/main.tf new file mode 100644 index 0000000000..ba1f6f766d --- /dev/null +++ b/terraform/github-import/main.tf @@ -0,0 +1,25 @@ +terraform { + required_version = ">= 1.9.0" + + required_providers { + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} + +provider "github" { + token = var.github_token + owner = var.github_owner +} + +resource "github_repository" "course_repo" { + name = var.repository_name + description = var.repository_description + visibility = "public" + + has_issues = true + has_wiki = false + has_projects = false +} diff --git a/terraform/github-import/outputs.tf b/terraform/github-import/outputs.tf new file mode 100644 index 0000000000..7d11ad6ccb --- /dev/null +++ b/terraform/github-import/outputs.tf @@ -0,0 +1,3 @@ +output "repository_full_name" { + value = github_repository.course_repo.full_name +} diff --git a/terraform/github-import/variables.tf b/terraform/github-import/variables.tf new file mode 100644 index 0000000000..04e83f4fb6 --- /dev/null +++ b/terraform/github-import/variables.tf @@ -0,0 +1,23 @@ +variable "github_token" { + description = "GitHub token with repo permissions" + type = string + sensitive = true +} + +variable "github_owner" { + description = "GitHub owner/user name" + type = string + default = "Linktur" +} + +variable "repository_name" { + description = "Repository name to import" + type = string + default = "DevOps-Core-Course" +} + +variable "repository_description" { + description = "Managed repository description" + type = string + default = "DevOps course lab assignments" +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..9e28879b1e --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,152 @@ +terraform { + required_version = ">= 1.9.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.80" + } + } +} + +provider "aws" { + region = var.aws_region +} + +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +resource "aws_vpc" "lab04" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.project_name}-vpc" + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.lab04.id + cidr_block = var.public_subnet_cidr + availability_zone = var.availability_zone + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-public-subnet" + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_internet_gateway" "lab04" { + vpc_id = aws_vpc.lab04.id + + tags = { + Name = "${var.project_name}-igw" + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.lab04.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.lab04.id + } + + tags = { + Name = "${var.project_name}-public-rt" + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +resource "aws_security_group" "vm" { + name = "${var.project_name}-sg" + description = "Security group for Lab 04 VM" + vpc_id = aws_vpc.lab04.id + + ingress { + description = "SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = var.ssh_allowed_cidr_blocks + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "App port" + from_port = 5000 + to_port = 5000 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-sg" + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_key_pair" "lab04" { + key_name = var.key_pair_name + public_key = file(pathexpand(var.ssh_public_key_path)) + + tags = { + Project = var.project_name + Lab = "lab04" + } +} + +resource "aws_instance" "vm" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + subnet_id = aws_subnet.public.id + vpc_security_group_ids = [aws_security_group.vm.id] + key_name = aws_key_pair.lab04.key_name + associate_public_ip_address = true + + tags = { + Name = "${var.project_name}-vm" + Project = var.project_name + Lab = "lab04" + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..a57515a7d7 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,14 @@ +output "vm_public_ip" { + description = "Public IP address of the VM" + value = aws_instance.vm.public_ip +} + +output "ssh_command" { + description = "SSH command to connect to VM" + value = "ssh ${var.instance_username}@${aws_instance.vm.public_ip}" +} + +output "security_group_id" { + description = "Security group ID for the VM" + value = aws_security_group.vm.id +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..b998d3bae8 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,6 @@ +aws_region = "us-east-1" +availability_zone = "us-east-1a" +instance_type = "t2.micro" +ssh_allowed_cidr_blocks = ["203.0.113.10/32"] +ssh_public_key_path = "~/.ssh/id_rsa.pub" +key_pair_name = "devops-core-lab04-key" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..b7f66aa500 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,59 @@ +variable "aws_region" { + description = "AWS region for all resources" + type = string + default = "us-east-1" +} + +variable "availability_zone" { + description = "Availability zone for the public subnet" + type = string + default = "us-east-1a" +} + +variable "project_name" { + description = "Project name used in tags" + type = string + default = "devops-core-lab04" +} + +variable "instance_type" { + description = "EC2 instance type (use free-tier type)" + type = string + default = "t2.micro" +} + +variable "instance_username" { + description = "SSH username for Ubuntu images" + type = string + default = "ubuntu" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.10.0.0/16" +} + +variable "public_subnet_cidr" { + description = "CIDR block for the public subnet" + type = string + default = "10.10.1.0/24" +} + +variable "ssh_allowed_cidr_blocks" { + description = "CIDR blocks allowed to access SSH (use your IP/32)" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "ssh_public_key_path" { + description = "Path to your public SSH key" + type = string + default = "~/.ssh/id_rsa.pub" +} + +variable "key_pair_name" { + description = "AWS key pair name" + type = string + default = "devops-core-lab04-key" +}