diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..b0e851dbed --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,56 @@ +name: python-lab03 + +on: + pull_request: + types: [opened, reopened] + branches: + - master + paths: + - app_python/ + - '!app_python/docs/**' + - '!app_python/README.md' + +jobs: + Check-code-and-docker-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: '3.14' + cache: pip + + - name: Install dependencies + run: cd app_python/; pip install -r requirements.txt + + - name: Lint + run: flake8 app_python/app.py app_python/tests/ + + - name: Test + run: pytest app_python/tests/ + + # snyk can't find required packages, even after installing dependencies, don't know the reason + # - name: Check for vulnerabilities + # uses: snyk/actions/python@master + # env: + # SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + # with: + # args: app_python/ + + - name: Generate version + id: gen-ver + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_OUTPUT + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + - name: Docker build and push + uses: docker/build-push-action@v6 + with: + context: ./app_python + push: true + tags: kosmogor/devops:latest,kosmogor/devops:${{ steps.gen-ver.outputs.VERSION }} \ No newline at end of file diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000000..5527008b30 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,53 @@ +name: rust-lab03 + +on: + pull_request: + types: [opened, reopened] + branches: + - master + paths: + - app_rust/ + - '!app_rust/docs/**' + - '!app_rust/README.md' + +jobs: + Check-rust-code-and-docker-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app_rust/target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --manifest-path app_rust/Cargo.toml --verbose + + - name: Lint + run: cargo clippy --manifest-path app_rust/Cargo.toml + + - name: Test + run: cargo test --manifest-path app_rust/Cargo.toml --verbose + + - name: Generate version + id: gen-ver + run: echo "VERSION=$(date +%Y.%m.%d)" >> $GITHUB_OUTPUT + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + - name: Docker build and push + uses: docker/build-push-action@v6 + with: + context: ./app_rust + push: true + tags: kosmogor/devops_rust:latest,kosmogor/devops_rust:${{ steps.gen-ver.outputs.VERSION }} \ No newline at end of file diff --git a/app_python/.gitignore b/app_python/.gitignore index 2599bafe22..14896bb3f2 100644 --- a/app_python/.gitignore +++ b/app_python/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .venv/ *.py[cod] *.log +.pytest_cache/ # IDE .vscode/ \ No newline at end of file diff --git a/app_python/README.md b/app_python/README.md index dab572f0dc..9bf6129cfb 100644 --- a/app_python/README.md +++ b/app_python/README.md @@ -1,5 +1,9 @@ # Python Web Server +[![lab03](https://github.com/KOSMOGOR/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/KOSMOGOR/DevOps-Core-Course/actions/workflows/python-ci.yml) + +--- + ## Overview This is a web application providing detailed information about itself and its runtime environment @@ -46,6 +50,14 @@ To run simply run: docker run devops:latest ``` +## Testing + +App can be easily tested after installation by simply running: + +```bash +pytest /tests +``` + ## API Endpoints - `GET /` - Service and system information diff --git a/app_python/app.py b/app_python/app.py index dfe11586fb..4aabd54540 100644 --- a/app_python/app.py +++ b/app_python/app.py @@ -38,7 +38,8 @@ def get_time_info(): delta = now - start_time return { "uptime_seconds": delta.seconds, - "uptime_human": f"{delta.seconds // 3600} hour, {delta.seconds % 3600 // 60} minutes", + "uptime_human": f"{delta.seconds // 3600} hour,\ +{delta.seconds % 3600 // 60} minutes", "current_time": datetime.now(timezone.utc).isoformat(), "timezone": "UTC" } @@ -77,7 +78,8 @@ def app_root(request: Request): "path": request.url.path }, "endpoints": [ - {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/", "method": "GET", + "description": "Service information"}, {"path": "/health", "method": "GET", "description": "Health check"} ] } @@ -106,7 +108,7 @@ def not_found(request: Request, exception: Exception): @app.exception_handler(500) -def not_found(request: Request, exception: Exception): +def internal_server_error(request: Request, exception: Exception): return JSONResponse( { "error": "Internal Server Error", @@ -117,5 +119,5 @@ def not_found(request: Request, exception: Exception): if __name__ == "__main__": - logger.info('Application starting...') + logger.info("Application starting...") uvicorn.run("app:app", host=HOST, port=PORT, reload=DEBUG) diff --git a/app_python/docs/lab01.md b/app_python/docs/lab01.md index b49957cd8c..0985f0ed5d 100644 --- a/app_python/docs/lab01.md +++ b/app_python/docs/lab01.md @@ -23,13 +23,13 @@ API documentation can be found on `http://[HOST]:[PORT]/docs` (defaults to + +Screenshot: + +![Workflow](./screenshots/lab03/successful-actions-workflow.png) + +## Continious Integration + +### Successful working badge + +![badge](/docs/screenshots/lab03/successful-actions-badge.png) + +### Caching Implemantation + +Caching was implemented using `cache: pip` + +### Best Practices + +I used practices like: + +- Optimize pipeline stages - I optimized pipeline stages +- Use failures to improve processes - tasks can fail due to not compliting some requirements +- Use secrets - I used GitHub secrets for tokens in workflow + +### Snyk Integration Results + +Snyk found vulnarability in package `python-multipart` and suggested upgrading it, and I have done it. diff --git a/app_python/docs/screenshots/01-main-endpoint.png b/app_python/docs/screenshots/lab01/01-main-endpoint.png similarity index 100% rename from app_python/docs/screenshots/01-main-endpoint.png rename to app_python/docs/screenshots/lab01/01-main-endpoint.png diff --git a/app_python/docs/screenshots/02-health-check.png b/app_python/docs/screenshots/lab01/02-health-check.png similarity index 100% rename from app_python/docs/screenshots/02-health-check.png rename to app_python/docs/screenshots/lab01/02-health-check.png diff --git a/app_python/docs/screenshots/03-formatted-output.png b/app_python/docs/screenshots/lab01/03-formatted-output.png similarity index 100% rename from app_python/docs/screenshots/03-formatted-output.png rename to app_python/docs/screenshots/lab01/03-formatted-output.png diff --git a/app_python/docs/screenshots/lab03/successful-actions-badge.png b/app_python/docs/screenshots/lab03/successful-actions-badge.png new file mode 100644 index 0000000000..11254c931b Binary files /dev/null and b/app_python/docs/screenshots/lab03/successful-actions-badge.png differ diff --git a/app_python/docs/screenshots/lab03/successful-actions-workflow.png b/app_python/docs/screenshots/lab03/successful-actions-workflow.png new file mode 100644 index 0000000000..8985e91eb5 Binary files /dev/null and b/app_python/docs/screenshots/lab03/successful-actions-workflow.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt index bfdd17b6e1..9cba943148 100644 --- a/app_python/requirements.txt +++ b/app_python/requirements.txt @@ -10,25 +10,33 @@ fastapi==0.128.0 fastapi-cli==0.0.20 fastapi-cloud-cli==0.11.0 fastar==0.8.0 +flake8==7.3.0 h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 httpx==0.28.1 idna==3.11 +iniconfig==2.3.0 Jinja2==3.1.6 markdown-it-py==4.0.0 MarkupSafe==3.0.3 +mccabe==0.7.0 mdurl==0.1.2 +packaging==26.0 +pluggy==1.6.0 +pycodestyle==2.14.0 pydantic==2.12.5 pydantic-extra-types==2.11.0 pydantic-settings==2.12.0 pydantic_core==2.41.5 +pyflakes==3.4.0 Pygments==2.19.2 +pytest==9.0.2 python-dotenv==1.2.1 -python-multipart==0.0.21 +python-multipart==0.0.22 PyYAML==6.0.3 rich==14.3.0 -rich-toolkit==0.17.1 +rich-toolkit==0.18.1 rignore==0.7.6 sentry-sdk==2.50.0 shellingham==1.5.4 diff --git a/app_python/tests/test_endpoints.py b/app_python/tests/test_endpoints.py new file mode 100644 index 0000000000..20c3eaa78e --- /dev/null +++ b/app_python/tests/test_endpoints.py @@ -0,0 +1,39 @@ +from fastapi.testclient import TestClient +from app import app + + +client = TestClient(app) + + +def test_root(): + response = client.get("/") + assert response.status_code == 200 + res_json: dict = response.json() + assert "service" in res_json + assert all(x in res_json["service"] for x in + ["name", "version", "description", "framework"]) + assert "system" in res_json + assert all(x in res_json["system"] for x in + ["hostname", "platform", "platform_version", + "architecture", "cpu_count", "python_version"]) + assert "runtime" in res_json + assert all(x in res_json["runtime"] for x in + ["uptime_seconds", "uptime_human", "current_time", "timezone"]) + assert "request" in res_json + assert all(x in res_json["request"] for x in + ["client_ip", "user_agent", "method", "path"]) + assert "endpoints" in res_json + assert type(res_json["endpoints"]) is list + + +def test_health(): + response = client.get("/health") + assert response.status_code == 200 + res_json: dict = response.json() + assert all(x in res_json for x in + ["status", "timestamp", "uptime_seconds"]) + + +def test_404(): + response = client.get("/definitely/wrong/path") + assert response.status_code == 404 diff --git a/app_rust/docs/lab01.md b/app_rust/docs/lab01.md index 4e02a182a0..cfc164cda0 100644 --- a/app_rust/docs/lab01.md +++ b/app_rust/docs/lab01.md @@ -22,13 +22,13 @@ As web framework I chose Actix Web for some reasons: ## Testing Evidence `/` endpoint: -![/](./screenshots/01-main-endpoint.png) +![/](./screenshots/lab01/01-main-endpoint.png) `/health` endpoint: -![/health](./screenshots/02-health-check.png) +![/health](./screenshots/lab01/02-health-check.png) some terminal output: -![output](./screenshots/03-formatted-output.png) +![output](./screenshots/lab01/03-formatted-output.png) ## Challenges & Solutions diff --git a/app_rust/docs/lab02.md b/app_rust/docs/lab02.md index 1045e18e37..86003762f2 100644 --- a/app_rust/docs/lab02.md +++ b/app_rust/docs/lab02.md @@ -1,4 +1,4 @@ -# Lab 01 Bonus Task +# Lab 02 Bonus Task ## Multi-Stage Build Strategy diff --git a/app_rust/docs/lab03.md b/app_rust/docs/lab03.md new file mode 100644 index 0000000000..f0428e9435 --- /dev/null +++ b/app_rust/docs/lab03.md @@ -0,0 +1,23 @@ +# Lab 03 Bonus Task + +## Workflow Implementation + +Rust workflow uses: + +- `actions/cache` for caching dependencies +- `cargo build` for building +- `cargo clippy` for linting +- `cargo test` for testing +- and same workflow for building and pushing image as `app_python/` (same version strategy using current date) + +## Path Config + +Both workflows checks their directory (`app_pytho/` or `app_rust/`) and exludes changes made to `docs/**` or `README.md` + +This makes workflow better, since it won't trigger pointlessly, if not important file to workflow will be changed. + +## Proofs + +Both workflows run independently: + +![workflows](./screenshots/lab03/workflows.png) diff --git a/app_rust/docs/screenshots/01-main-endpoint.png b/app_rust/docs/screenshots/lab01/01-main-endpoint.png similarity index 100% rename from app_rust/docs/screenshots/01-main-endpoint.png rename to app_rust/docs/screenshots/lab01/01-main-endpoint.png diff --git a/app_rust/docs/screenshots/02-health-check.png b/app_rust/docs/screenshots/lab01/02-health-check.png similarity index 100% rename from app_rust/docs/screenshots/02-health-check.png rename to app_rust/docs/screenshots/lab01/02-health-check.png diff --git a/app_rust/docs/screenshots/03-formatted-output.png b/app_rust/docs/screenshots/lab01/03-formatted-output.png similarity index 100% rename from app_rust/docs/screenshots/03-formatted-output.png rename to app_rust/docs/screenshots/lab01/03-formatted-output.png diff --git a/app_rust/docs/screenshots/lab03/workflows.png b/app_rust/docs/screenshots/lab03/workflows.png new file mode 100644 index 0000000000..5ada86f59c Binary files /dev/null and b/app_rust/docs/screenshots/lab03/workflows.png differ diff --git a/app_rust/src/main.rs b/app_rust/src/main.rs index f0cda677c3..4385b9f382 100644 --- a/app_rust/src/main.rs +++ b/app_rust/src/main.rs @@ -82,7 +82,7 @@ async fn app_root(req: HttpRequest) -> impl Responder { }, request: RequestInfo { client_ip: req.connection_info().host().to_string(), - user_agent: req.headers().get("user-agent").unwrap().to_str().unwrap().to_string(), + user_agent: req.headers().get("user-agent").map(|h| h.to_str().unwrap()).unwrap_or("None").to_string(), method: req.method().to_string(), path: req.path().to_string() }, @@ -147,3 +147,30 @@ async fn main() -> std::io::Result<()> { .run() .await } + +#[cfg(test)] +mod tests { + use actix_web::{http::header::ContentType, test, App}; + + use super::*; + + #[actix_web::test] + async fn test_root_get() { + let app = test::init_service(App::new().service(app_root)).await; + let req = test::TestRequest::default() + .insert_header(ContentType::plaintext()) + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + } + + #[actix_web::test] + async fn test_health_get() { + let app = test::init_service(App::new().service(app_health)).await; + let req = test::TestRequest::with_uri("/health") + .insert_header(ContentType::plaintext()) + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + } +}