diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..8f9b7095ff --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,87 @@ +name: Python CI (app_python) + +on: + push: + branches: ["master"] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + pull_request: + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +concurrency: + group: python-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: app_python + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: | + app_python/requirements.txt + app_python/requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Lint (ruff) + run: | + ruff check . + + - name: Run tests (pytest) + run: | + pytest -q + + - name: Install Snyk CLI + run: npm install -g snyk + + - name: Snyk scan (dependencies) + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + run: snyk test --file=requirements.txt --severity-threshold=high || true + + + docker-build-and-push: + runs-on: ubuntu-latest + needs: test-and-lint + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Generate CalVer version + run: | + echo "VERSION=$(date -u +%Y.%m.%d)-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./app_python + file: ./app_python/Dockerfile + push: true + tags: | + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:${{ env.VERSION }} + ${{ secrets.DOCKERHUB_USERNAME }}/devops-info-service:latest diff --git a/.gitignore b/.gitignore index 30d74d2584..e8384ee8c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ -test \ No newline at end of file +test +# Terraform +terraform/.terraform/ +terraform/.terraform.lock.hcl +terraform/terraform.tfstate +terraform/terraform.tfstate.backup +terraform/*.tfvars + +# Yandex Cloud SA key +.yc/ + +venv/ +*/venv/ +__pycache__/ +.terraform/ +.pulumi/ +*.tfstate +*.tfstate.backup diff --git a/ansible/.gitignore b/ansible/.gitignore new file mode 100644 index 0000000000..ff6e09c2c4 --- /dev/null +++ b/ansible/.gitignore @@ -0,0 +1,6 @@ + +# Ansible +*.retry +.vault_pass +__pycache__/ +ansible/.venv/ diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..1dae3b014c --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,13 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = vboxuser +retry_files_enabled = False +forks = 10 +timeout = 30 + +[privilege_escalation] +become = True +become_method = sudo +become_user = root diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..4b04caad81 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,157 @@ +# LAB05 — Ansible Fundamentals + +## 1. Architecture Overview + +- **Ansible Version:** 2.16+ +- **Target OS:** Ubuntu 24.04 LTS +- **Cloud Provider:** Yandex Cloud +- **Application:** DevOps Info Service (FastAPI) +- **Container Runtime:** Docker + +This lab implements a fully automated, role-based infrastructure provisioning and container deployment system using Ansible. + +### Why Roles? + +Roles were used instead of monolithic playbooks to achieve: + +- Modularity +- Reusability +- Separation of concerns +- Clean project structure +- Easier scalability and maintenance + +--- + +## 2. Role Structure + +### common role +Purpose: Basic system preparation + +Tasks: +- Update APT cache +- Install essential packages (curl, git, vim, htop, python3-pip) +- Configure timezone + +Idempotency ensured using: +- `apt` module with `state: present` +- `timezone` module + +--- + +### docker role +Purpose: Install and configure Docker + +Tasks: +- Install Docker from Ubuntu repository +- Enable and start docker service +- Add user to docker group +- Install python3-docker for Ansible Docker modules + +Handlers: +- Restart Docker service (if needed) + +All tasks are state-based and idempotent. + +--- + +### app_deploy role +Purpose: Deploy containerized application securely + +Tasks: +- Pull Docker image +- Remove old container if exists +- Run container with restart policy +- Wait for application port +- Perform health check via HTTP + +Security: +- Docker Hub credentials stored in encrypted Vault file +- `no_log: true` used for sensitive tasks + +--- + +## 3. Idempotency Demonstration + +### First Run + +Initial execution resulted in multiple `changed` tasks because packages and services were installed. + +### Second Run + +Second execution showed: + + +changed=0 + + +This confirms idempotency. + +Idempotency is achieved by: +- Using declarative modules +- Avoiding raw shell commands +- Defining desired system state explicitly + +--- + +## 4. Application Deployment Verification + +After deployment: + +- Container is running (`docker ps`) +- Port 5000 is exposed publicly +- Health endpoint returns HTTP 200 +- Root endpoint returns system metadata + +Public URL: + +http://93.77.190.119:5000 + +Health endpoint: + +http://93.77.190.119:5000/health + +--- + +## 5. Ansible Vault + +Sensitive variables are stored in: + + +group_vars/all.yml + + +File is encrypted using: + + +$ANSIBLE_VAULT;1.1;AES256 + + +Vault ensures: +- Secrets are not stored in plaintext +- Safe version control +- Secure automation + +--- + +## 6. Key DevOps Principles Applied + +- Infrastructure as Code +- Idempotent configuration management +- Secure secret management +- Containerized deployment +- Automated verification +- Role-based modular architecture + +--- + +## 7. Conclusion + +The system successfully provisions infrastructure, installs Docker, and deploys a containerized application using Ansible roles. + +The solution is: + +- Idempotent +- Secure +- Modular +- Reproducible +- Production-ready diff --git a/ansible/docs/screenshots/curl-health.png b/ansible/docs/screenshots/curl-health.png new file mode 100644 index 0000000000..6b59e3ef28 Binary files /dev/null and b/ansible/docs/screenshots/curl-health.png differ diff --git a/ansible/docs/screenshots/provision-first.png b/ansible/docs/screenshots/provision-first.png new file mode 100644 index 0000000000..e0365e7b13 Binary files /dev/null and b/ansible/docs/screenshots/provision-first.png differ diff --git a/ansible/docs/screenshots/provision-second.png b/ansible/docs/screenshots/provision-second.png new file mode 100644 index 0000000000..a21735c389 Binary files /dev/null and b/ansible/docs/screenshots/provision-second.png differ diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..649ba87324 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,19 @@ +$ANSIBLE_VAULT;1.1;AES256 +32356462313737333333373263633564396636643732653235326563636663386333656433356634 +3231343764306434303939316564373334623765323934610a396632613631303061363339643061 +30613132343937343461373936633532343937643635393130333730643633353235336562613733 +3539373561303261650a653739656565623434316664346162623833393566306538663837316263 +33393134643665393033313339653562363938653064313739393735393131613361366161396236 +37383065353531336334646135383530636463303135316436646637646330353365363665366436 +36666538646530396161636166373130313334383332613866386535333734323462613337323265 +62303634626336356134316461656666373165666631376231326439393862333337666662616131 +62356136633630373965356463366362373365393832626362373637356533336635383337656561 +38306466663430626431623735653463373337396364666236366433313332376466356234663535 +32656261383164613437303332646532336537343833343932323337636239383534326664356665 +30616166616534396235656437343465346163376234366232643232663765386531623238653735 +39646430333830343664373939333766326431376638336161613630373332646138306639653439 +66366333363332356532343065646237653562643937633163346165643966623638633235393030 +38353735653636366264303164326230666339623039643933373036306233333637656339643733 +38333435303966646536663865653666303166346339346330316338343935633361663634303839 +61396264316132353762323931346130353239613366656531343735653464396536306664623130 +3030333933376564363832626232623763653961313135386465 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..531a2a996e --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,5 @@ +[webservers] +lab04-vm ansible_host=93.77.190.119 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_ed25519 + +[webservers:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..fcc372661d --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy application container + hosts: webservers + become: yes + + collections: + - community.docker + + roles: + - app_deploy diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..f53efb0248 --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: yes + + roles: + - common + - docker diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..cd3de83b87 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,21 @@ +--- +# Defaults for app_deploy role + +# Docker Hub username (can be overridden via Vault) +dockerhub_username: "fayzullin" # Поставь свой логин, если другой + +# App / image name +app_name: "devops-info-service" + +# Full image name and tag +docker_image: "{{ dockerhub_username }}/{{ app_name }}" +docker_image_tag: "latest" + +# Container and port +app_port: 5000 +app_container_name: "{{ app_name }}" +app_restart_policy: "unless-stopped" + +docker_restart_policy: "{{ app_restart_policy }}" +# Environment variables for the container (if needed) +app_env: {} diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..e7e8259b12 --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart app container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: restarted diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..5abb2d162e --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,42 @@ +--- +###- name: Log in to Docker Hub +### community.docker.docker_login: + ### username: "{{ dockerhub_username }}" + ### password: "{{ dockerhub_password }}" + ###no_log: true + +- name: Pull application image + community.docker.docker_image: + name: "{{ docker_image }}:{{ docker_image_tag }}" + source: pull + +- name: Ensure old container is absent + community.docker.docker_container: + name: "{{ app_container_name }}" + state: absent + ignore_errors: true + +- name: Run application container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image }}:{{ docker_image_tag }}" + published_ports: + - "{{ app_port }}:{{ app_port }}" + env: "{{ app_env }}" + restart_policy: "{{ docker_restart_policy }}" + state: started + +- name: Wait for application port to become open + ansible.builtin.wait_for: + host: "{{ ansible_host | default(inventory_hostname) }}" + port: "{{ app_port }}" + delay: 3 + timeout: 60 + +- name: Check health endpoint + ansible.builtin.uri: + url: "http://{{ ansible_host | default(inventory_hostname) }}:{{ app_port }}/health" + method: GET + status_code: 200 + register: health_result + failed_when: health_result.status != 200 diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..49824977e8 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,13 @@ +--- +common_timezone: "Etc/UTC" + +common_packages: + - python3-pip + - curl + - git + - vim + - htop + - ca-certificates + - apt-transport-https + - software-properties-common + - gnupg diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..f90bf70284 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Ensure apt cache is up to date + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + +- name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + +- name: Set timezone + community.general.timezone: + name: "{{ common_timezone }}" + when: common_timezone is defined diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d1761730cb --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,5 @@ +--- +docker_packages: + - docker.io + +docker_user: "ubuntu" diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..1a5058da5e --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..b72e0b72e5 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,27 @@ +--- +- name: Ensure apt cache is up to date + ansible.builtin.apt: + update_cache: yes + cache_valid_time: 3600 + +- name: Install Docker package from Ubuntu repo + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + +- name: Install python3-docker for Ansible docker modules + ansible.builtin.apt: + name: python3-docker + state: present + +- name: Ensure docker service is enabled and running + ansible.builtin.service: + name: docker + state: started + enabled: yes + +- name: Ensure user is added to docker group + ansible.builtin.user: + name: "{{ docker_user }}" + groups: docker + append: yes diff --git a/ansible/{censored: b/ansible/{censored: new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ansible/{changed: b/ansible/{changed: new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..5db5bf582e --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,13 @@ +pycache/ +*.py[cod] +venv/ +.env +*.log + +.vscode/ +.idea/ +.git +.gitignore + +docs/ +tests/ diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..3ca35248a3 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.env +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store + diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..c4f33cf3af --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN adduser --disabled-password --gecos "" appuser + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +USER appuser + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..18ae98e060 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,80 @@ +# DevOps Info Service (FastAPI) + +[![Python CI (app_python)](https://github.com/fayz131/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/fayzullin/DevOps-Core-Course/actions/workflows/python-ci.yml) + + +## Overview +DevOps Info Service is a web application that provides information about the running service and the system it is running on. The application is designed as a foundation for future DevOps labs, including containerization, CI/CD, and monitoring. + +## Prerequisites +- Python 3.11 or newer +- pip +- Python virtual environment (venv) + +## Installation +Navigate to the application directory: + +cd app_python + +Create and activate a virtual environment: + +python3 -m venv venv +source venv/bin/activate + +Install dependencies: + +pip install -r requirements.txt + +## Running the Application +Start the application: + +python app.py + +Run with custom configuration: + +HOST=127.0.0.1 PORT=8080 python app.py + +## API Endpoints + +GET / +Returns service, system, runtime, and request information. + +GET /health +Returns application health status and uptime. + +## Configuration + +Environment variables: + +HOST — server host (default: 0.0.0.0) +PORT — server port (default: 5000) + +## Docker + +### Build image + +```bash +docker build -t devops-info-service:lab2 . +``` + +Run container +```bash +docker run --rm -p 5000:5000 devops-info-service:lab2 +``` + +From Docker Hub +```bash +docker pull fayzullin/devops-info-service:lab2 +docker run --rm -p 5000:5000 fayzullin/devops-info-service:lab2 +``` + +## Testing + +Install dev dependencies and run tests: + +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + + diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..29cb4e95d9 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,122 @@ +""" +DevOps Info Service +FastAPI web application providing system and runtime information. +""" + +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +import uvicorn + + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +START_TIME = datetime.now(timezone.utc) +app = FastAPI(title="DevOps Info Service") + +logger.info("Application initialized") + + +def get_uptime(): + """Calculate application uptime.""" + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return seconds, f"{hours} hours, {minutes} minutes" + + +def get_system_info(): + """Collect system information.""" + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.release(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +@app.get("/") +async def index(request: Request): + """Main endpoint returning service and system information.""" + logger.info("Handling request to '/'") + + uptime_seconds, uptime_human = get_uptime() + + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI", + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime_seconds, + "uptime_human": uptime_human, + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": { + "client_ip": request.client.host, + "user_agent": request.headers.get("user-agent"), + "method": request.method, + "path": request.url.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + +@app.get("/health") +async def health(): + """Health check endpoint for monitoring.""" + logger.info("Health check requested") + + uptime_seconds, _ = get_uptime() + return { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime_seconds, + } + + +@app.exception_handler(404) +async def not_found(request: Request, exc): + """Handle 404 errors.""" + return JSONResponse( + status_code=404, + content={"error": "Not Found", "message": "Endpoint does not exist"}, + ) + + +@app.exception_handler(500) +async def internal_error(request: Request, exc): + """Handle unexpected server errors.""" + logger.error(f"Internal server error: {exc}") + return JSONResponse( + status_code=500, + content={"error": "Internal Server Error", "message": "An unexpected error occurred"}, + ) + +if __name__ == "__main__": + logger.info(f"Starting server on {HOST}:{PORT}") + uvicorn.run("app:app", host=HOST, port=PORT) + diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..4157c81f1f --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,80 @@ +# LAB01 — DevOps Info Service + +## Framework Selection +For this lab, FastAPI was chosen as the web framework due to its modern design, +high performance, and built-in support for OpenAPI documentation. This makes it a suitable choice for building production-ready services and for future DevOps labs. + +| Framework | Advantages | Disadvantages | +|----------|------------|---------------| +| Flask | Simple and lightweight | No built-in API docs | +| FastAPI | Async, automatic docs, fast | Slight learning curve | +| Django | Full-featured framework | Overkill for small services | + +--- + +## Best Practices Applied +The following best practices were applied during development: + +- Clear and simple project structure +- Environment-based configuration using `HOST` and `PORT` +- Separation of logic into helper functions +- Use of UTC timezone for all runtime timestamps +- Dependency management using `requirements.txt` +- Virtual environment usage +- Handling of invalid endpoints using a custom 404 handler + +These practices improve readability, portability, and reliability of the application. + +--- + +## API Documentation + +### Main Endpoint — `GET /` +Returns detailed information about the service, system, runtime state, request metadata, and available endpoints. + +Example request: +```bash +curl http://localhost:5000/ +``` +The response includes: +- Service metadata (name, version, framework) +- System information (hostname, OS, CPU, Python version) +- Runtime information (uptime, current UTC time) +- Request details (client IP, user agent, HTTP method) +- List of available endpoints + +--- + +### Health Check — `GET /health` + +Returns the current health status of the application and uptime in seconds. + +Example request: +```bash +curl http://localhost:5000/health +``` +--- + +## Testing Evidence + +To confirm correct application behavior, the following screenshots were taken: + +- `01-main-endpoint.png` — response from the main endpoint (`GET /`) +- `02-health-check.png` — response from the health check endpoint (`GET /health`) +- `03-formatted-output.png` — formatted JSON output in the terminal + +All screenshots are located in the `docs/screenshots` directory. + +--- + +## Challenges & Solutions + +One of the challenges encountered was handling requests to non-existent endpoints. +This was solved by implementing a custom 404 error handler that returns a clear JSON response instead of a default HTML error page. + +--- + +## GitHub Community + +Starring repositories on GitHub helps support open-source maintainers and makes it easier to keep track of useful projects. +Following developers allows learning from their work, staying updated on new technologies, and building professional connections within the developer community. diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..37021d5f61 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,86 @@ +# Lab 2 — Docker Containerization + +## Docker Best Practices Applied + +- **Specific base image version** + Used `python:3.12-slim` as a lightweight official Python image. Using a specific version makes builds reproducible and avoids unexpected changes when the latest tag is updated. + +- **Layer caching with requirements.txt** + `requirements.txt` is copied and dependencies are installed before copying the application code. This allows Docker to reuse the dependency layer when only the code changes, speeding up rebuilds. + +- **Non-root user** + A dedicated non-root user `appuser` is created and the application is started under this user. Running containers as non-root reduces the impact of potential security vulnerabilities. + +- **Minimal file copy** + Only the files required at runtime are copied into the image (`requirements.txt` and `app.py`). Test files, documentation, and development artifacts are excluded via `.dockerignore`. This reduces image size and attack surface. + +- **Environment variables for Python** + `PYTHONDONTWRITEBYTECODE` and `PYTHONUNBUFFERED` are set to prevent `.pyc` creation and to ensure unbuffered output, which is useful for logging in containers. + +## Image Information & Decisions + +- **Base image:** `python:3.12-slim` + Chosen as a good balance between size and compatibility. The slim image is smaller than the full Python image but still based on Debian. + +- **Layer structure:** + 1. Pull base image + 2. Set environment variables + 3. Set working directory + 4. Create non-root user + 5. Copy `requirements.txt` and install dependencies + 6. Copy application code + 7. Switch to non-root user + 8. Set default command + +- **Optimization choices:** + - `--no-cache-dir` for pip + - `.dockerignore` excludes `venv`, `.git`, `docs`, `tests`, etc. + - Running as non-root user + +## Build & Run Process + +### Build + +```bash +docker build -t devops-info-service:lab2 . +``` + +### Run locally + +```bash +docker run --rm -p 5000:5000 devops-info-service:lab2 +``` + +### Test endpoints + +```bash +curl http://localhost:5000/ +curl http://localhost:5000/health +``` + +### Docker Hub repository + +Image is available at: +https://hub.docker.com/r/fayzullin/devops-info-service + + +Tag used: +```bash +fayzullin/devops-info-service:lab2 +``` + +### Technical Analysis + +The Dockerfile installs dependencies before copying the application code. If the order was reversed, any code change would force dependencies to be reinstalled on every build. Running as a non-root user improves security, and .dockerignore reduces the build context size, making builds faster and images smaller. Additionally, running the container as a non-root user reduces the potential impact of container escape vulnerabilities and follows Docker security best practices. + + +### Challenges & Solutions + +**Challenge:** Understanding how layer caching influences build speed. +**Solution:** Reordered layers so that dependency installation is separated from application code. + +**Challenge:** Running the app as a non-root user. +**Solution:** Created a dedicated appuser user and switched to it using the USER directive. + +**Challenge:** Reducing image size. +**Solution:** Used python:3.12-slim, disabled pip cache, and excluded unnecessary files via .dockerignore. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..9aebd42ef7 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,83 @@ +# Lab 3 — Continuous Integration (CI/CD) + +## Overview + +This lab introduces automated testing and CI/CD using GitHub Actions for the FastAPI DevOps Info Service. + +The pipeline performs: +- Linting (ruff) +- Unit testing (pytest) +- Security scanning (Snyk) +- Docker image build and push to Docker Hub + +## Testing Framework + +**Framework used:** pytest + +Pytest was chosen because: +- Simple and readable assertions +- Great integration with FastAPI +- Industry standard in modern Python projects + +### Tests Implemented + +- `GET /` — validates response structure and required fields +- `GET /health` — validates health check structure +- `404 handler` — validates JSON error response + +### Run tests locally + +```bash +pip install -r requirements.txt -r requirements-dev.txt +pytest +``` + +## CI Workflow + +Workflow file: +.github/workflows/python-ci.yml + +### Trigger Strategy + +Workflow runs on: + +* Pull requests affecting app_python/** +* Push to master affecting app_python/** + +Path filters prevent unnecessary runs in monorepo. + +### Versioning Strategy + +Strategy: Calendar Versioning (CalVer) + +Format: +YYYY.MM.DD- + +Docker tags created: + +* fayzullin/devops-info-service: + +* fayzullin/devops-info-service:latest + +This is suitable for continuously deployed services. + +## CI Best Practices Applied + +Fail fast — Docker build runs only if tests pass. + +Dependency caching — pip cache speeds up builds. + +Path filters — workflow runs only when app_python changes. + +Concurrency control — cancels outdated runs. + +## Security Scanning + +Snyk is integrated to scan dependencies. +Build fails only on high severity vulnerabilities + +## Evidence + +GitHub Actions run: (add link after successful run) + +Docker Hub: https://hub.docker.com/r/fayzullin/devops-info-service diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/01-main-endpoint.png new file mode 100644 index 0000000000..690563c723 Binary files /dev/null and b/app_python/docs/screenshots/01-main-endpoint.png differ diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/02-health-check.png new file mode 100644 index 0000000000..1719f04a17 Binary files /dev/null and b/app_python/docs/screenshots/02-health-check.png differ diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/03-formatted-output.png new file mode 100644 index 0000000000..de12345346 Binary files /dev/null and b/app_python/docs/screenshots/03-formatted-output.png differ diff --git a/app_python/requirements-dev.txt b/app_python/requirements-dev.txt new file mode 100644 index 0000000000..34d28434a1 --- /dev/null +++ b/app_python/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest==8.3.3 +httpx==0.27.2 +ruff==0.7.2 + diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..ebc98913e8 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.8 +uvicorn[standard]==0.32.0 diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..066aa56152 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,60 @@ +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_root_returns_required_structure(): + response = client.get("/", headers={"User-Agent": "pytest"}) + assert response.status_code == 200 + + data = response.json() + + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["version"] == "1.0.0" + assert service["framework"] == "FastAPI" + + system = data["system"] + for key in ["hostname", "platform", "platform_version", "architecture", "cpu_count", "python_version"]: + assert key in system + + runtime = data["runtime"] + assert isinstance(runtime["uptime_seconds"], int) + assert runtime["uptime_seconds"] >= 0 + assert isinstance(runtime["uptime_human"], str) + assert isinstance(runtime["current_time"], str) + assert runtime["timezone"] == "UTC" + + req = data["request"] + assert req["method"] == "GET" + assert req["path"] == "/" + assert isinstance(req["user_agent"], (str, type(None))) + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + assert isinstance(data["timestamp"], str) + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +def test_404_returns_json(): + response = client.get("/does-not-exist") + assert response.status_code == 404 + + data = response.json() + assert data["error"] == "Not Found" + assert "message" in data + diff --git a/pulumi/lab04-yc/.gitignore b/pulumi/lab04-yc/.gitignore new file mode 100644 index 0000000000..a3807e5bdb --- /dev/null +++ b/pulumi/lab04-yc/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv/ diff --git a/pulumi/lab04-yc/Pulumi.yaml b/pulumi/lab04-yc/Pulumi.yaml new file mode 100644 index 0000000000..82f52af683 --- /dev/null +++ b/pulumi/lab04-yc/Pulumi.yaml @@ -0,0 +1,7 @@ +name: lab04-yc +description: A minimal Python Pulumi program +runtime: python +config: + pulumi:tags: + value: + pulumi:template: python diff --git a/pulumi/lab04-yc/__main__.py b/pulumi/lab04-yc/__main__.py new file mode 100644 index 0000000000..5703481991 --- /dev/null +++ b/pulumi/lab04-yc/__main__.py @@ -0,0 +1,128 @@ +import os +import pathlib + +import pulumi +import pulumi_yandex as yandex + + + +ZONE = "ru-central1-a" + + +FOLDER_ID = "b1g1cmmbss046n25oln3" + +SSH_PUBLIC_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519.pub") + +SSH_USERNAME = "ubuntu" + + + +def read_ssh_public_key(path: str) -> str: + p = pathlib.Path(path) + if not p.exists(): + raise FileNotFoundError(f"SSH public key not found: {p}") + return p.read_text().strip() + + +ssh_pub = read_ssh_public_key(SSH_PUBLIC_KEY_PATH) + + + +net = yandex.VpcNetwork( + "lab-network", + folder_id=FOLDER_ID, +) + +subnet = yandex.VpcSubnet( + "lab-subnet", + folder_id=FOLDER_ID, + network_id=net.id, + zone=ZONE, + v4_cidr_blocks=["10.0.0.0/24"], +) + + + +sg = yandex.VpcSecurityGroup( + "lab-sg", + folder_id=FOLDER_ID, + network_id=net.id, + description="Security group for lab04 VM (SSH, HTTP, app port)", + egresses=[ + yandex.VpcSecurityGroupEgressArgs( + protocol="ANY", + description="Allow all outbound", + v4_cidr_blocks=["0.0.0.0/0"], + from_port=0, + to_port=65535, + ) + ], + ingresses=[ + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="SSH", + v4_cidr_blocks=["0.0.0.0/0"], + port=22, + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="HTTP", + v4_cidr_blocks=["0.0.0.0/0"], + port=80, + ), + yandex.VpcSecurityGroupIngressArgs( + protocol="TCP", + description="App port 5000", + v4_cidr_blocks=["0.0.0.0/0"], + port=5000, + ), + ], +) + + + +image = yandex.get_compute_image( + family="ubuntu-2004-lts", + folder_id="standard-images", +) + + + +vm = yandex.ComputeInstance( + "lab-vm", + folder_id=FOLDER_ID, + zone=ZONE, + platform_id="standard-v2", + resources=yandex.ComputeInstanceResourcesArgs( + cores=2, + memory=1, + core_fraction=20, + ), + boot_disk=yandex.ComputeInstanceBootDiskArgs( + initialize_params=yandex.ComputeInstanceBootDiskInitializeParamsArgs( + image_id=image.id, + size=10, + type="network-hdd", + ) + ), + network_interfaces=[ + yandex.ComputeInstanceNetworkInterfaceArgs( + subnet_id=subnet.id, + nat=True, + security_group_ids=[sg.id], + ) + ], + metadata={ + "ssh-keys": f"{SSH_USERNAME}:{ssh_pub}", + }, + labels={ + "lab": "lab04", + "tool": "pulumi", + }, +) + + +pulumi.export("external_ip", vm.network_interfaces[0].nat_ip_address) +pulumi.export("zone", vm.zone) +pulumi.export("subnet_id", subnet.id) +pulumi.export("security_group_id", sg.id) diff --git a/pulumi/lab04-yc/requirements.txt b/pulumi/lab04-yc/requirements.txt new file mode 100644 index 0000000000..bc4e43087b --- /dev/null +++ b/pulumi/lab04-yc/requirements.txt @@ -0,0 +1 @@ +pulumi>=3.0.0,<4.0.0 diff --git a/terraform/docs/LAB04.md b/terraform/docs/LAB04.md new file mode 100644 index 0000000000..72ff4cee9d --- /dev/null +++ b/terraform/docs/LAB04.md @@ -0,0 +1,212 @@ +# LAB04 — Infrastructure as Code (Terraform & Pulumi) + +## 1. Cloud Provider & Infrastructure + +**Cloud Provider:** Yandex Cloud +**Folder ID:** b1g1cmmbss046n25oln3 +**Region / Zone:** ru-central1-a +**Instance Type:** standard-v2 (2 vCPU with 20% core fraction, 1 GB RAM) +**Disk:** 10 GB HDD +**Operating System:** Ubuntu 24.04 LTS + +The smallest available instance type compatible with Yandex Cloud free tier was selected to minimize cost. + +### Security Configuration + +The following ports are opened in the security group: + +- TCP 22 — SSH (restricted access for remote management) +- TCP 80 — HTTP (future deployment) +- TCP 5000 — Application port (DevOps Info Service from previous labs) + +### Created Resources + +- VPC Network (`lab-network`) +- Subnet (`lab-subnet`) +- Security Group (`lab-sg`) +- Virtual Machine (`lab-vm`) +- Public IP Address + +Estimated cost: **0 RUB** (free tier usage). +Terraform Version: 1.9.8 +Pulumi Version: 3.222.0 + +--- + +## 2. Terraform Implementation + +### Terraform Version +Terraform CLI 1.9.x (Ubuntu Linux) + +### Project Structure + +terraform/ +├── main.tf +├── variables.tf +├── outputs.tf +└── docs/LAB04.md + + +### Authentication + +Authentication was configured using a Yandex Cloud service account JSON key: +~/.yc/terraform-key.json + + +Provider configuration: + +```hcl +provider "yandex" { + service_account_key_file = pathexpand("~/.yc/terraform-key.json") + folder_id = var.folder_id + zone = var.zone +} +``` + +### Workflow + +``` +terraform init +terraform fmt +terraform validate +terraform plan +terraform apply +``` + + +Example output from terraform plan: +Plan: 3 to add, 0 to change, 0 to destroy. + +Example output from terraform apply: +Apply complete! Resources: 3 added, 0 changed, 0 destroyed. + +Outputs: + +external_ip = "X.X.X.X" + + +SSH Verification +ssh ubuntu@ + + +SSH connection was successful. + +Cleanup + +After verifying functionality, Terraform resources were destroyed: + +terraform destroy + + +All resources created by Terraform were removed successfully to avoid duplication and unnecessary usage. + +## 3. Pulumi Implementation +Pulumi Version + +Pulumi CLI v3.222.0 + +Language + +Python + +Project Structure +pulumi/lab04-yc/ + ├── Pulumi.yaml + ├── Pulumi.dev.yaml + ├── requirements.txt + ├── __main__.py + └── venv/ + +Authentication + +Pulumi uses the same Yandex Cloud service account key: + +export YC_SERVICE_ACCOUNT_KEY_FILE=/home/vboxuser/.yc/terraform-key.json + +Resources Created + +The same infrastructure was recreated using Pulumi: + +VpcNetwork + +VpcSubnet + +VpcSecurityGroup + +ComputeInstance + +Public IP + +Pulumi Commands +pulumi preview +pulumi up + + +Preview example: + ++ yandex:index:VpcNetwork ++ yandex:index:VpcSubnet ++ yandex:index:VpcSecurityGroup ++ yandex:index:ComputeInstance + + +Apply output: + +Outputs: + external_ip : "93.77.190.119" + zone : "ru-central1-a" + +SSH Verification +ssh ubuntu@93.77.190.119 + + +SSH access was successful. + +## 4. Terraform vs Pulumi Comparison +Ease of Learning + +Terraform was easier to start with due to extensive documentation and straightforward declarative syntax. Pulumi required more setup (virtual environments, Python dependencies). + +Code Readability + +Terraform configurations are compact and declarative, making them easy to read for simple infrastructure. Pulumi provides more flexibility but adds programming complexity. + +Debugging + +Terraform errors are generally clear during plan and apply. Pulumi provides Python stack traces, which can be more detailed but sometimes harder to interpret. + +Documentation + +Terraform has broader documentation and community examples. Pulumi documentation is solid but less extensive for Yandex Cloud specifically. + +Use Case Preference + +Terraform is preferable for straightforward infrastructure definitions. +Pulumi is more powerful when complex logic, loops, or programming constructs are required. + +## 5. Lab 5 Preparation & Cleanup + +For Lab 5 (Ansible), the VM created using Pulumi will be kept active. + +Active VM: + +IP Address: 93.77.190.119 +Zone: ru-central1-a +User: ubuntu + +Terraform resources were destroyed. +Pulumi-managed VM remains running for future configuration management tasks. + +No secrets or state files were committed to Git. + +Infrastructure can be recreated at any time using: + +terraform apply + + +or + +pulumi up + +Terraform state was stored locally. The file terraform.tfstate was added to .gitignore and not committed to the repository. + diff --git a/terraform/docs/screenshots/pulumi-up.png b/terraform/docs/screenshots/pulumi-up.png new file mode 100644 index 0000000000..a6e37bd63d Binary files /dev/null and b/terraform/docs/screenshots/pulumi-up.png differ diff --git a/terraform/docs/screenshots/terraform-apply.png b/terraform/docs/screenshots/terraform-apply.png new file mode 100644 index 0000000000..e9c3bc3afd Binary files /dev/null and b/terraform/docs/screenshots/terraform-apply.png differ diff --git a/terraform/docs/screenshots/yc-vm-running.png b/terraform/docs/screenshots/yc-vm-running.png new file mode 100644 index 0000000000..7a87ed9991 Binary files /dev/null and b/terraform/docs/screenshots/yc-vm-running.png differ diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..62ed0354df --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,56 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + } + } +} + +provider "yandex" { + service_account_key_file = "/home/vboxuser/.yc/terraform-key.json" + cloud_id = "b1g0qsmtu1cheeq79i0d" + folder_id = "b1g1cmmbss046n25oln3" + zone = "ru-central1-a" +} +resource "yandex_vpc_network" "lab_network" { + name = "lab-network" +} + +resource "yandex_vpc_subnet" "lab_subnet" { + name = "lab-subnet" + zone = "ru-central1-a" + network_id = yandex_vpc_network.lab_network.id + v4_cidr_blocks = ["10.10.0.0/24"] +} + +resource "yandex_compute_instance" "lab_vm" { + name = "lab-vm" + zone = "ru-central1-a" + platform_id = "standard-v2" + + resources { + cores = 2 + memory = 1 + core_fraction = 20 + } + + boot_disk { + initialize_params { + image_id = "fd80bm0rh4rkepi5ksdi" # Ubuntu 24.04 LTS + size = 10 + } + } + + network_interface { + subnet_id = yandex_vpc_subnet.lab_subnet.id + nat = true + } + + metadata = { + ssh-keys = "ubuntu:${file("/home/vboxuser/.ssh/id_ed25519.pub")}" + } +} + +output "external_ip" { + value = yandex_compute_instance.lab_vm.network_interface.0.nat_ip_address +}