diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..69a04601b2 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml new file mode 100644 index 0000000000..ad114efdf1 --- /dev/null +++ b/.github/workflows/python-ci.yaml @@ -0,0 +1,94 @@ +name: Python CI + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [ "master", "main", "lab03" ] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + + pull_request: + branches: [ "master" ] + paths: + - "app_python/**" + - ".github/workflows/python-ci.yml" + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: set up + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + app_python/requirements.txt + + - name: dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + + - name: snyk + uses: snyk/actions/python@master + continue-on-error: true + env: + SNYK_TOKEN: ${{ secrets.SNYK }} + with: + args: --severity-threshold=high + + - name: Linter + run: | + pip install flake8 + flake8 app_python/ --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 app_python/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with coverage + working-directory: app_python + run: pytest --cov=. --cov-report=xml --cov-fail-under=70 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: app_python/coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + + docker: + name: docker + needs: test + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab03' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Docker login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata (CalVer) + id: meta + uses: docker/metadata-action@v5 + with: + images: abrahambarrett228/devops-info-service + tags: | + type=raw,value={{date 'YYYY.MM'}} + type=raw,value={{date 'YYYY.MM'}}.${{ github.run_number }} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 30d74d2584..2b2a4cf446 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +venv \ No newline at end of file diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..71fd1af1d9 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +venv/ +.venv/ +env/ + +.git/ +.vscode/ +.idea/ + +*.log +pytest_cache/ +.coverage +htmlcov/ + +docs/ +tests/ \ No newline at end of file diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..4de420a8f7 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..e4fe9ff1dd --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ARG PORT=5000 + +WORKDIR /app/app_python + +COPY requirements.txt . +COPY app.py . + +RUN \ + pip install --no-cache-dir -r requirements.txt && \ + useradd --create-home --shell /usr/sbin/nologin appuser &&\ + chown -R appuser /app/app_python + +USER appuser + +EXPOSE ${PORT} + +CMD [ "python", "app.py" ] \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..85df6f9248 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,117 @@ +# Python Web Server + +## Overview + +This web server provides information about itself and its environment. + +## Prerequisites + +- Python 3.14.0 +- pip +- git +## Installation + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Running the Application + +You can create `.env` file storing all enviroment values. To run application simply type: + +```bash +python app.py +``` + +## Docker Support + +### Building from Source + +Create a Docker image locally from the application code: + +```bash +docker build -t : . +``` + +### Container Execution + +Run the application in an isolated container environment: + +```bash +docker run --rm -p : --name : +``` + + +### Using Pre-built Images + +The service is also available on Docker Hub for immediate deployment: + +```bash +# Download the published image +docker pull abrahambarrett228/lab02 + +# Run the downloaded image +docker run --rm -p 5000:5000 abrahambarrett228/lab02 +``` + +### Container Management + +Basic operations for container lifecycle management: + +```bash +# List running containers +docker ps + +# Stop a running container +docker stop + +# View container logs +docker logs + +# Interactive shell access +docker exec -it /bin/sh +``` + +## API Endpoints + +- `GET /` - service data +- `GET /health` - Health check + +## Configuration + +Supported enviroment values: + +- `HOST` - address of the application +- `PORT` - port of the application +- `DEBUG` - `[true/false]` do/don't enable debug features + + +## Testing + +### Running Tests + +Install dependencies +```bash +pip install -r requirements.txt +``` + +Run tests +```bash +pytest +``` + +Run with coverage (pytest-cov should be installed) +```bash +pytest --cov=app --cov-report=term +``` + +### Test Structure + +Tests are located in `app_python/tests/` directory. + + +- `app_python/tests/test_error_endpoint.py` - tests related to errors +- `app_python/tests/test_health_endpoint.py` tests related to health endpoint +- `app_python/tests/test_mainpage_endpoint.py` tests related to the root endpoint \ No newline at end of file diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..2492b62e48 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,208 @@ +import fastapi +from datetime import datetime, timezone +import uvicorn +import logging +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + + +app = fastapi.FastAPI() + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('app.log') + ] +) +logger = logging.getLogger(__name__) + +start_time = None + + +def get_uptime(): + global start_time + if start_time is None: + start_time = datetime.now() + delta = datetime.now() - 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_system(): + import platform + import socket + + return { + "hostname": socket.gethostname(), + "platform_name": platform.system(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + } + + +@app.middleware("http") +async def log_requests(request: fastapi.Request, call_next): + start_time_middleware = datetime.now() + + logger.info(f"Request started: {request.method} {request.url.path}") + logger.debug(f"Headers: {dict(request.headers)}") + + try: + response = await call_next(request) + process_time = ( + datetime.now() - start_time_middleware).total_seconds() * 1000 + + logger.info( + f"Request completed: {request.method} {request.url.path} " + f"- Status: {response.status_code} " + f"- Time: {process_time:.2f}ms" + ) + + return response + + except Exception as ex: + logger.error( + f"Request failed: {request.method} {request.url.path} - Error: {str(ex)}") + raise + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler( + request: fastapi.Request, + exc: StarletteHTTPException): + if exc.status_code == 404: + logger.warning( + f"404 Not Found: {request.method} {request.url.path} " + f"from {request.client.host if request.client else 'Unknown'}" + ) + + return JSONResponse( + status_code=404, + content={ + "error": "Not Found", + "message": ( + f"The requested URL {request.url.path} " + f"was not found on this server." + ), + "status_code": 404, + "timestamp": datetime.now( + timezone.utc).isoformat(), + }) + + logger.error(f"HTTP Error {exc.status_code}: {str(exc.detail)}") + return JSONResponse( + status_code=exc.status_code, + content={ + "error": exc.__class__.__name__, + "message": str(exc.detail), + "status_code": exc.status_code, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: fastapi.Request, + exc: RequestValidationError): + logger.warning( + f"Validation error: {exc.errors()} " + f"for request {request.method} {request.url.path}" + ) + return JSONResponse( + status_code=422, + content={ + "error": "Validation Error", + "message": "Invalid request parameters", + "details": exc.errors(), + "status_code": 422, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: fastapi.Request, exc: Exception): + logger.error( + f"Unhandled exception: {str(exc)} " + f"for request {request.method} {request.url.path}", + exc_info=True + ) + + return JSONResponse( + status_code=500, + content={ + "error": "Internal Server Error", + "message": "An unexpected error occurred. Please try again later.", + "status_code": 500, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) + + +@app.get("/") +def main_page(request: fastapi.Request): + logger.debug(f'Request: {request.method} {request.url.path}') + time = get_uptime() + return { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": get_system(), + "runtime": time, + "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") +def health(): + logger.debug("Health check requested") + return { + 'status': 'healthy', + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'uptime_seconds': get_uptime()['seconds'] + } + + +def start(): + import os + global start_time + + HOST = os.getenv('HOST', '0.0.0.0') + PORT = int(os.getenv("PORT", "5000")) + DEBUG = os.getenv("DEBUG", 'False').lower() == "true" + start_time = datetime.now() + + logger.info(f"Starting application on {HOST}:{PORT}") + logger.info(f"Debug mode: {DEBUG}") + + uvicorn.run( + app, + host=HOST, + port=PORT, + log_config=None + ) + + +if __name__ == "__main__": + start() diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..8627e666f6 --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,210 @@ +# Lab 01 + +## Chosing Web Framework + +### Choice: FastAPI + +Reasons: + +- High Performance +- Automatic Documentation ( Auto-generates OpenAPI/Swagger documentation) +- Data Validation - Built-in validation via Pydantic + + +### Comparison table on a five-point scale +| Criteria | FastApi | Flask | Django | +|-------------|----------|-----------|-----------| +|Performance | 5 | 3 | 3 | +|Documentation| 5 | 3 | 4 | +| Simplicity | 5 | 4 | 3 | +| Security | 5 | 3 | 4 | + +## Best Practices Applied + +- Clear function names like `get_uptime()` and `get_system()` - increase code readability + +Example: + +```python +def get_uptime(): + delta = datetime.now() - 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_system(): + import platform + import socket + + return { + "hostname": socket.gethostname(), + "platform_name": platform.system(), + "architecture": platform.machine(), + "python_version": platform.python_version(), + } +``` + +- Logging makes debugging easier + +Example: + +```python +@app.middleware("http") +async def log_requests(request: fastapi.Request, call_next): + start_time_middleware = datetime.now() + + logger.info(f"Request started: {request.method} {request.url.path}") + logger.debug(f"Headers: {dict(request.headers)}") + logger.debug( + f"Client IP: { + request.client.host if request.client else 'Unknown'}") + + try: + response = await call_next(request) + process_time = ( + datetime.now() - start_time_middleware).total_seconds() * 1000 + + logger.info( + f"Request completed: {request.method} {request.url.path} " + f"- Status: {response.status_code} " + f"- Time: {process_time:.2f}ms" + ) + + return response + + except Exception as ex: + logger.error( + f"Request failed: {request.method} {request.url.path} - Error: {str(ex)}") + raise +``` + +- Following PEP8 - increases code readability + +Example: + +```bash +autopep8 --in-place --aggressive --aggressive app_python/app.py + ``` + +- Error handling simplifies the process of understanding the cause of a malfunction in the code when making incorrect requests + +Example: + +```python +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: fastapi.Request, + exc: RequestValidationError): + logger.warning( + f"Validation error: { + exc.errors()} for request { + request.method} { + request.url.path}") + + return JSONResponse( + status_code=422, + content={ + "error": "Validation Error", + "message": "Invalid request parameters", + "details": exc.errors(), + "status_code": 422, + "timestamp": datetime.now(timezone.utc).isoformat() + } + ) +``` + +## API Documentation + +API documentation can be found on `http://[HOST]:[PORT]/docs` (defaults to ) + +***Request*** + +`curl http://localhost:5000/` + +***Response*** +```bash +{ + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "FastAPI" + }, + "system": { + "hostname": "Abrahams-Air", + "platform_name": "Darwin", + "architecture": "arm64", + "python_version": "3.14.0" + }, + "runtime": { + "seconds": 7, + "human": "0 hours, 0 minutes" + }, + "request": { + "client_ip": "127.0.0.1", + "user_agent": "curl/8.4.0", + "method": "GET", + "path": "/" + }, + "endpoints": [ + { + "path": "/", + "method": "GET", + "description": "Service information" + }, + { + "path": "/health", + "method": "GET", + "description": "Health check" + } + ] +} +``` +***Request*** + +`curl http://localhost:5000/health` + +***Response*** + +```bash +{ + "status": "healthy", + "timestamp": "2026-01-26T19:34:59.627298+00:00", + "uptime_seconds": 14 +} +``` +## Testing Evidence + +`/` endpoint: + +![/](./screenshots/Screenshot1.png) + +`/health` endpoint: + +![/health](./screenshots/Screenshot2.png) + +some terminal output: + +![output](./screenshots/Screenshot3.png) + +## GitHub Community + +Starring repositories is a simple yet powerful way to support open-source projects — it helps maintainers gauge popularity, increases project visibility for new contributors, and creates a personal bookmarking system for discovering useful tools. Following developers on GitHub fosters professional growth by exposing you to diverse coding styles and project management approaches, while also building connections that can lead to collaboration opportunities in team environments. + + +**Actions Required:** +1. **Star** the course repository [Done] +2. **Star** the [simple-container-com/api](https://github.com/simple-container-com/api) project — a promising open-source tool for container management [Done] +3. **Follow** your professor and TAs on GitHub: [Done] + - Professor: [@Cre-eD](https://github.com/Cre-eD) + - TA: [@marat-biriushev](https://github.com/marat-biriushev) + - TA: [@pierrepicaud](https://github.com/pierrepicaud) +4. **Follow** at least 3 classmates from the course + - https://github.com/asqarslanov + - https://github.com/FunnyFoXD + - https://github.com/Woolfer0097 \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..ee6621d50f --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,108 @@ +# Lab 2 Submission — Docker Containerization + + +This application is fully containerized for easy and consistent deployment. + +Build the Image Locally + +To build the Docker image from the source code and the provided Dockerfile, use the docker build command with appropriate tags. + +Run a Container + +To run the application inside a container from the built image, use the docker run command, ensuring you map the container's exposed port to a port on your host machine. + +Pull from Docker Hub + +The pre-built image is also available on Docker Hub. You can pull it directly using docker pull with the correct repository name and tag, and then run it as described above. + +## Docker Best Practices Applied + +- Using a non-root user (USER appuser): + + - Why it matters: Running containers as root is a significant security risk. If an attacker compromises the application, they gain root privileges in the container, which can be leveraged for further attacks (container escape, host system compromise). Creating and switching to a dedicated, unprivileged user (appuser) minimizes the potential impact of a security breach. + +```docker +RUN useradd --create-home --shell /usr/sbin/nologin appuser +USER appuser +``` + +- Layer Caching & Ordering: + + - Why it matters: Docker caches the result of each layer. By copying only requirements.txt first and installing dependencies before copying the entire application code (app.py), we optimize the build cache. Changes to app.py will not trigger a re-install of all Python packages, leading to much faster rebuilds. + +```docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +``` + +- Using .dockerignore: + + - Why it matters: The .dockerignore file prevents unnecessary files (like local virtual environments venv/, IDE config .vscode/, cache __pycache__/, or secrets) from being sent to the Docker daemon during COPY. This results in a smaller build context, faster builds, and a more secure image by avoiding accidental inclusion of sensitive data. + +- --no-cache-dir with pip: + + - Why it matters: This flag tells pip not to store the download cache. Since the installed packages are persisted in the Docker image layer, keeping the cache is redundant and only increases the final image size. + +```docker +RUN pip install --no-cache-dir -r requirements.txt +``` + +## Image Information & Decisions + +- Base Image: python:3.13-slim + +Justification: The slim variant provides a balance. It contains the essential Python runtime and common system libraries needed for most Python apps but strips out unnecessary extra packages (like common documentation) found in the default python:3.13 image. This leads to a significantly smaller and more secure image compared to the full alpine version, which might require additional steps to compile some Python dependencies. +- Final Image Size: 49 MB + +Assessment: This is a reasonable size for a Python application. The bulk comes from the python:slim base layer. Further reduction could be explored using multi-stage builds if the project had complex compilation steps, but for this simple Flask app, slim offers the best trade-off between size and ease of use. +- Layer Structure & Optimization: + +The Dockerfile is structured to leverage caching. The most stable dependencies (Python package list from requirements.txt) are copied and installed first. The more frequently changed application code (app.py) is copied in the final COPY instruction. This ensures that code changes don't invalidate the cached pip install layer, speeding up development cycles. + +## Build & Run Process + +1. Build command: + +```bash +docker build -t lab02:latest . +``` + + +2. Command for container running: + +```bash +docker run --rm -it -p 5001:5000 lab02 +``` + + +3. Docker Hub Repository URL : +``` +https://hub.docker.com/repository/docker/abrahambarrett228/lab02/general +``` + + P.S. Terminal output showing the successful push: + + ``` +abraham_barrett@Abrahams-MacBook-Air app_python % docker push abrahambarrett228/lab02:latest +The push refers to repository [docker.io/abrahambarrett228/lab02] +fe9a90620d58: Pushed +a6866fe8c3d2: Pushed +97fc85b49690: Pushed +ee8758c3eb7d: Pushed +4fa8698484f1: Pushed +3ea009573b47: Pushed +6fb77c4bfd96: Pushed +399cf5dc7b8b: Pushed +3de5fa5034b2: Pushed +latest: digest: sha256:ff4a7b2b082f8fa68caa395f865e938ed30671d377439092b6ecefe3b2873007 size: 856 + ``` +However, there were no difficult strategy of tagging images since for now I need to create only one push. However, in future, I may recreate a strategy with creating versioning (based on major/minor updates) + +## Technical Analysis + +- Dockerfile Logic: The file follows a logical flow: define the environment (FROM), set up the workspace (WORKDIR), install system-level dependencies if any (none here), install Python dependencies, set up security (non-root user), copy application code, declare runtime configuration (EXPOSE, CMD). This order maximizes layer cache efficiency and security. + +- Changing Layer Order: If we copied app.py before running pip install, every single change to the application code would force Docker to invalidate the cache for the pip install layer and all subsequent layers. This would result in re-downloading and re-installing all dependencies on every build, making the development process much slower. +- Security Considerations: The key security measure is running the process as a non-root user (appuser). Additionally, using an official, minimal base image (slim) reduces the attack surface by including fewer pre-installed packages that could contain vulnerabilities. The .dockerignore file is also a security feature, preventing local secrets from being baked into the image. +- .dockerignore Improvement: Beyond security, .dockerignore drastically speeds up the build process, especially for projects with large directories like node_modules/ or .git/. The Docker daemon doesn't have to process these files, leading to quicker context upload and layer creation. diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..d4f19f09af --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,108 @@ +# DevOps Info Service — CI/CD Pipeline Documentation + +## 1. Overview + +### Testing Framework: pytest + +### Why pytest? + +- Simple and intuitive syntax with minimal boilerplate code +- Powerful fixture system for test setup/teardown +- Excellent plugin ecosystem (pytest-cov for coverage, etc.) +- Industry standard for Python testing +- Better assertion introspection than unittest + + +### Test Coverage: + +| Endpoint | Tests | What's Tested | +|----------|-------|---------------| +| `GET /` | 7 tests | Status code, response structure, service info, system info, runtime progression, request info, endpoints list | +| `GET /health` | 2 tests | Status code, response structure (status, uptime, timestamp) | +| `404 Handler` | 1 test | Error response format and status code | + +![alt text](screenshots/image4.png) + +## CI Workflow Configuration + +### Triggers: + +- Push: master, main, lab03 branches +- Pull Request: master branch +- Path filters: Only runs when app_python/ or workflow files change +### Why these triggers? + +- Running on lab03 branch enables testing during development +- PR checks prevent merging broken code +- Path filters optimize resource usage — no need to run Python CI when only Go/Rust code changes +- Versioning Strategy: Calendar Versioning (CalVer) + +### Format: YYYY.MM.build-number (e.g., 2025.03.42) + +#### Rationale: + +This is a service, not a library — users don't need SemVer's breaking change semantics +CalVer provides immediate context about when the image was built +Dates are human-readable and eliminate version number debates +Perfect for continuous deployment workflow +Combined with build number ensures uniqueness +### Docker Tags: + +- abrahambarrett228/devops-info-service:2025.03 — Monthly release track +- abrahambarrett228/devops-info-service:2025.03.42 — Specific build +- abrahambarrett228/devops-info-service:latest — Latest stable build + + +2. Workflow Evidence + + Successful GitHub Actions Run + + Local Tests Passing + + ![alt text](screenshots/image4.png) + + Docker Hub Image + + + + 3. CI Best Practices Implemented + + | Practice | Implementation | Why It Matters | +|:---------|:---------------|:---------------| +| **1. Dependency Caching** | `actions/setup-python` with `cache: pip` | — Saves time per run by reusing pip cache | +| **2. Fail Fast** | `needs: test` in docker job | Prevents publishing broken images — if tests fail, Docker build never starts | +| **3. Conditional Deployment** | `if: github.ref == 'refs/heads/master'` | Only push Docker images from protected branches — prevents spam from feature branches | +| **4. Concurrency Control** | `concurrency.cancel-in-progress: true` | Cancels outdated workflow runs — saves resources on force-pushes | +| **5. Path Filtering** | `on.push.paths: ['app_python/**']` | CI runs only when relevant files change — saves 100% of runtime when editing docs | +| **6. Security Scanning** | Snyk with high severity threshold | 🔒 Catches vulnerable dependencies before production | +| **7. Coverage Threshold** | `--cov-fail-under=70` | Enforces minimum test coverage — prevents coverage regression | + + + +4. Key Decisions + +### Versioning Strategy: CalVer + +Why not SemVer? This is a web service, not a library. Users don't pin versions in requirements.txt — they pull the latest Docker image. CalVer tells me immediately when an image was built. The date format 2025.03 is instantly recognizable and eliminates debates about "is this a major or minor change?" For continuous deployment, dates make more sense than arbitrary version numbers. +Docker Tags + +CI generates three tags: latest, monthly version (2025.03), and specific build (2025.03.42). latest is for convenience, monthly tags provide stable release tracks, and build-specific tags enable rollbacks. The build number from GitHub Actions guarantees uniqueness even if multiple builds happen on the same day. + +### Test Coverage: + +Coverage is enforced at 70% but consistently runs at 75%. + +What's covered: + +All endpoint response structures and status codes +Dynamic behavior (uptime progression, timestamp formatting) +Error handling (404 responses) +Service metadata validation + + + +### Workflow Triggers + +I chose to run on both push to lab03/main and PRs to master. Running on development branches lets me catch issues early without creating PRs. PR checks act as a quality gate — no broken code reaches master. Path filters prevent wasting resources when I update only the Go app or documentation. +Test Coverage Strategy + diff --git a/app_python/docs/screenshots/Screenshot1.png b/app_python/docs/screenshots/Screenshot1.png new file mode 100644 index 0000000000..78b88acbf5 Binary files /dev/null and b/app_python/docs/screenshots/Screenshot1.png differ diff --git a/app_python/docs/screenshots/Screenshot2.png b/app_python/docs/screenshots/Screenshot2.png new file mode 100644 index 0000000000..afb6008cae Binary files /dev/null and b/app_python/docs/screenshots/Screenshot2.png differ diff --git a/app_python/docs/screenshots/Screenshot3.png b/app_python/docs/screenshots/Screenshot3.png new file mode 100644 index 0000000000..5f1e37f575 Binary files /dev/null and b/app_python/docs/screenshots/Screenshot3.png differ diff --git a/app_python/docs/screenshots/image4.png b/app_python/docs/screenshots/image4.png new file mode 100644 index 0000000000..feda8a3eb2 Binary files /dev/null and b/app_python/docs/screenshots/image4.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..45f5671a41 --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,27 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +certifi==2026.1.4 +click==8.3.1 +coverage==7.13.4 +fastapi==0.128.0 +flake8==7.3.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +mccabe==0.7.0 +packaging==26.0 +pluggy==1.6.0 +pycodestyle==2.14.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +pyflakes==3.4.0 +Pygments==2.19.2 +pytest==9.0.2 +pytest-cov==7.0.0 +starlette==0.50.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.40.0 \ No newline at end of file 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_error_endpoint.py b/app_python/tests/test_error_endpoint.py new file mode 100644 index 0000000000..e0bdcf059d --- /dev/null +++ b/app_python/tests/test_error_endpoint.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_error_handler(): + response = client.get("/non-existent-endpoint") + assert (response.json())["error"] == "Not Found" + assert response.status_code == 404 diff --git a/app_python/tests/test_health_endpoint.py b/app_python/tests/test_health_endpoint.py new file mode 100644 index 0000000000..6bd41e41da --- /dev/null +++ b/app_python/tests/test_health_endpoint.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_status_code(): + response = client.get("/health") + assert response.status_code == 200 + + +def test_response_structure(): + data = client.get("/health").json() + + assert data["status"] == "healthy" + assert isinstance(data["uptime_seconds"], int) + assert "timestamp" in data + + timestamp = data["timestamp"] + try: + parsed_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + assert parsed_time.tzinfo is not None + except (ValueError, AttributeError): + assert False diff --git a/app_python/tests/test_mainpage_endpoint.py b/app_python/tests/test_mainpage_endpoint.py new file mode 100644 index 0000000000..022b5a4ca9 --- /dev/null +++ b/app_python/tests/test_mainpage_endpoint.py @@ -0,0 +1,95 @@ +import re + +from fastapi.testclient import TestClient +from app import app + +client = TestClient(app) + + +def test_status_code(): + response = client.get("/") + assert response.status_code == 200 + + +def test_response_structure(): + response = client.get("/") + assert all(key in response.json() for key in ["service", "system", "runtime", "request", "endpoints"]) + + +def test_service_structure(): + data = client.get("/").json()["service"] + assert isinstance(data["version"], str) + assert data["name"] == "devops-info-service" + assert data["framework"] == "FastAPI" + + +def test_system_structure(): + response = client.get("/") + data = response.json() + system = data["system"] + expected_fields = {"hostname", "platform_name", "architecture", "python_version"} + assert expected_fields.issubset(system.keys()) + + assert isinstance(system["hostname"], str) + assert isinstance(system["platform_name"], str) + assert isinstance(system["architecture"], str) + assert isinstance(system["python_version"], str) + + assert re.match(r'^\d+\.\d+\.\d+', system["python_version"]) + + +def test_runtime_structure(): + response = client.get("/") + assert response.status_code == 200 + + data = response.json() + + assert "runtime" in data + + runtime = data["runtime"] + + assert "seconds" in runtime + assert "human" in runtime + + assert isinstance(runtime["seconds"], int) + assert isinstance(runtime["human"], str) + + assert runtime["seconds"] >= 0 + + human = runtime["human"] + assert "h" in human or "m" in human or "s" in human + assert any(char.isdigit() for char in human) + + +def test_request_info_structure(): + response = client.get("/") + data = response.json() + request_info = data["request"] + expected_fields = {"client_ip", "user_agent", "method", "path"} + + assert expected_fields.issubset(request_info.keys()) + + assert request_info["method"] == "GET" + assert request_info["path"] == "/" + assert isinstance(request_info["client_ip"], str) + assert request_info["user_agent"] is None or isinstance(request_info["user_agent"], str) + + +def test_endpoints_list(): + response = client.get("/") + data = response.json() + endpoints = data["endpoints"] + assert isinstance(endpoints, list) + assert len(endpoints) >= 2 + + for endpoint in endpoints: + assert "path" in endpoint + assert "method" in endpoint + assert "description" in endpoint + assert isinstance(endpoint["path"], str) + assert isinstance(endpoint["method"], str) + assert isinstance(endpoint["description"], str) + + endpoint_paths = {(e["path"], e["method"]) for e in endpoints} + assert ("/", "GET") in endpoint_paths + assert ("/health", "GET") in endpoint_paths