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)
+[](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:
+
+
+
+
+
+
+Then installed the required packages including `openssh-server`:
+
+
+
+### 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/ansible/group_vars/all.yml b/ansible/group_vars/all.yml
new file mode 100644
index 0000000000..4388a953ac
--- /dev/null
+++ b/ansible/group_vars/all.yml
@@ -0,0 +1,22 @@
+$ANSIBLE_VAULT;1.1;AES256
+34653434666366333738343063356165393861636663626137643435616430616633336436376531
+3362616138643066366262646134316565383732376338320a373363346165646634663134383735
+65366138663063663635323034353364633163653337313434336532313766613034653337333630
+6564343931396230350a373264626632343431303131636334393863333632383432386639643234
+65666135613138396330363466633132373363323565353863366335303037323434303033333334
+34623734663337386130633936363131653264313462653530653663363434336537626233323564
+61393364303532393333666234376337326230313636646236373930373064303963396135363236
+39393133666138663132653364313564663930383036613234386161306430643936653664366237
+37373938396335336461663332393666313063666234636437646538613434333463646238313637
+36346136356236336465303266616632323561303165386330386164326265623534626237376133
+31313234393534653334623464663233613739316666616132366332303732333066666635356232
+66613464336665393561363030643731333464346135303663623731653438653830316566636435
+32356533373364343830303238313165656537386638326235373664376264386230326562353038
+39633632376433313665636331626665323036383161356538323135346466336665373265653366
+30303865396432633037356666646332303934326665323035303466356363646430623532363736
+63326236383339383136633866613666353138633134316666363932643932666230666337363861
+36353138643061363661623239666332626463386438393539396139336332643162633361363939
+33363965363337643462373736656239376636666136613764323230646236656335393539633361
+62303663333636393632663263396134373935303962323533316363316232643031646663373133
+65623538666639316530313239323733633162653935303731626362656262373064313937383662
+61373862306537613430666131653838613631613830396237623839623334303563
diff --git a/k8s/README.md b/k8s/README.md
new file mode 100644
index 0000000000..f3c080c901
--- /dev/null
+++ b/k8s/README.md
@@ -0,0 +1,79 @@
+## Lab 9 - Kubernetes Fundamentals
+
+### Stack and Objects
+- Application: `devops-lab2` (Flask)
+- Image: `linktur/devops-lab2:v1`
+- Deployment: `devops-lab2` (3 replicas)
+- Service: `devops-lab2-service` (`NodePort`, `80 -> 5000`, `30080`)
+- Probes: liveness `/health`, readiness `/health`
+
+## 1. Architecture Overview
+
+```mermaid
+flowchart TB
+ Service["devops-lab2-service
type: NodePort
80 -> 5000
nodePort: 30080"]
+ Deployment["Deployment/devops-lab2
replicas: 3
RollingUpdate"]
+
+ Pod1["Pod #1 :5000"]
+ Pod2["Pod #2 :5000"]
+ Pod3["Pod #3 :5000"]
+
+ Service -->|selector: app=devops-lab2| Deployment
+ Deployment --> Pod1
+ Deployment --> Pod2
+ Deployment --> Pod3
+```
+
+## 2. Manifest Files
+
+- `k8s/deployment.yml` - Deployment, resources, probes, rolling update strategy.
+- `k8s/service.yml` - NodePort Service for external access.
+
+Key choices:
+- `replicas: 3` for baseline availability.
+- `maxSurge: 1` and `maxUnavailable: 0` for safe rolling updates.
+- resource requests/limits for predictable scheduling.
+
+## 3. Commands Used
+
+```bash
+kubectl apply -f k8s/deployment.yml -f k8s/service.yml
+kubectl rollout status deployment/devops-lab2 --timeout=180s
+kubectl get all
+kubectl get pods,svc,endpoints -o wide
+
+kubectl scale deployment/devops-lab2 --replicas=5
+kubectl rollout status deployment/devops-lab2 --timeout=180s
+
+kubectl set env deployment/devops-lab2 LAB09_ROLLOUT=$(date +%s)
+kubectl rollout history deployment/devops-lab2
+kubectl rollout undo deployment/devops-lab2
+```
+
+## 4. Deployment Evidence (Minimal: 5 Screenshots)
+
+### 1) Cluster objects
+
+
+### 2) Detailed pods/services/endpoints
+
+
+### 3) Service check via curl
+
+
+### 4) Scaling to 5 replicas
+
+
+### 5) Rollback proof
+
+
+## 5. Production Considerations
+
+- Probes prevent traffic to unhealthy Pods and improve self-healing.
+- Requests/limits protect node stability and improve scheduling quality.
+- Future improvements: `startupProbe`, HPA, PodDisruptionBudget, NetworkPolicy, immutable tags.
+
+## 6. Challenges and Fixes
+
+- Issue: rollout could stay unavailable when probe path did not match the running image.
+- Fix: align probe endpoint with application endpoint and re-apply Deployment.
diff --git a/k8s/deployment.yml b/k8s/deployment.yml
new file mode 100644
index 0000000000..fc85838dff
--- /dev/null
+++ b/k8s/deployment.yml
@@ -0,0 +1,67 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: devops-lab2
+ labels:
+ app: devops-lab2
+ component: api
+spec:
+ replicas: 3
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0
+ selector:
+ matchLabels:
+ app: devops-lab2
+ template:
+ metadata:
+ labels:
+ app: devops-lab2
+ component: api
+ spec:
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ fsGroup: 1000
+ containers:
+ - name: app
+ image: linktur/devops-lab2:v1
+ imagePullPolicy: IfNotPresent
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ env:
+ - name: PORT
+ value: "5000"
+ ports:
+ - name: http
+ containerPort: 5000
+ protocol: TCP
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "256Mi"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 10
+ timeoutSeconds: 3
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ timeoutSeconds: 2
+ failureThreshold: 3
diff --git a/k8s/img/curl.png b/k8s/img/curl.png
new file mode 100644
index 0000000000..e9c64112a6
Binary files /dev/null and b/k8s/img/curl.png differ
diff --git a/k8s/img/detailed.png b/k8s/img/detailed.png
new file mode 100644
index 0000000000..d11a516351
Binary files /dev/null and b/k8s/img/detailed.png differ
diff --git a/k8s/img/pods.png b/k8s/img/pods.png
new file mode 100644
index 0000000000..bfefce1310
Binary files /dev/null and b/k8s/img/pods.png differ
diff --git a/k8s/img/rollback.png b/k8s/img/rollback.png
new file mode 100644
index 0000000000..1ed5a48db9
Binary files /dev/null and b/k8s/img/rollback.png differ
diff --git a/k8s/img/scale.png b/k8s/img/scale.png
new file mode 100644
index 0000000000..4dea139dcc
Binary files /dev/null and b/k8s/img/scale.png differ
diff --git a/k8s/service.yml b/k8s/service.yml
new file mode 100644
index 0000000000..07e085af78
--- /dev/null
+++ b/k8s/service.yml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: devops-lab2-service
+ labels:
+ app: devops-lab2
+spec:
+ type: NodePort
+ selector:
+ app: devops-lab2
+ ports:
+ - name: http
+ protocol: TCP
+ port: 80
+ targetPort: http
+ nodePort: 30080
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..cc8291800d
--- /dev/null
+++ b/terraform/.tflint.hcl
@@ -0,0 +1,9 @@
+plugin "terraform" {
+ enabled = true
+}
+
+plugin "aws" {
+ enabled = true
+ version = "0.38.0"
+ 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..75d15f14aa
--- /dev/null
+++ b/terraform/github-import/.tflint.hcl
@@ -0,0 +1,3 @@
+plugin "terraform" {
+ enabled = true
+}
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..a0ac1c0c8c
--- /dev/null
+++ b/terraform/github-import/outputs.tf
@@ -0,0 +1,4 @@
+output "repository_full_name" {
+ description = "Full name of the imported GitHub repository"
+ 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"
+}