Upload 42 files
Browse files- projects/k3s-test-app/.gitignore +14 -0
- projects/k3s-test-app/.python-version +1 -0
- projects/k3s-test-app/Containerfile +17 -0
- projects/k3s-test-app/DOCS/ARCHITECTURE.md +52 -0
- projects/k3s-test-app/DOCS/PHASES.md +40 -0
- projects/k3s-test-app/DOCS/PRD.md +31 -0
- projects/k3s-test-app/DOCS/RULES.md +40 -0
- projects/k3s-test-app/DOCS/TESTING.md +88 -0
- projects/k3s-test-app/LICENSE +21 -0
- projects/k3s-test-app/README.md +111 -0
- projects/k3s-test-app/k8s/configmap.yaml +8 -0
- projects/k3s-test-app/k8s/deployment.yaml +66 -0
- projects/k3s-test-app/k8s/hpa.yaml +18 -0
- projects/k3s-test-app/k8s/pvc.yaml +12 -0
- projects/k3s-test-app/k8s/secret.yaml +9 -0
- projects/k3s-test-app/k8s/service.yaml +15 -0
- projects/k3s-test-app/pyproject.toml +29 -0
- projects/k3s-test-app/pytest.ini +3 -0
- projects/k3s-test-app/src/k3s_test_app/__init__.py +1 -0
- projects/k3s-test-app/src/k3s_test_app/core/config.py +18 -0
- projects/k3s-test-app/src/k3s_test_app/core/logging.py +40 -0
- projects/k3s-test-app/src/k3s_test_app/main.py +51 -0
- projects/k3s-test-app/src/k3s_test_app/routers/__init__.py +1 -0
- projects/k3s-test-app/src/k3s_test_app/routers/config.py +38 -0
- projects/k3s-test-app/src/k3s_test_app/routers/health.py +21 -0
- projects/k3s-test-app/src/k3s_test_app/routers/identity.py +23 -0
- projects/k3s-test-app/src/k3s_test_app/routers/lifecycle.py +22 -0
- projects/k3s-test-app/src/k3s_test_app/routers/network.py +46 -0
- projects/k3s-test-app/src/k3s_test_app/routers/storage.py +38 -0
- projects/k3s-test-app/src/k3s_test_app/routers/stress.py +28 -0
- projects/k3s-test-app/src/k3s_test_app/routers/upload.py +22 -0
- projects/k3s-test-app/src/k3s_test_app/routers/version.py +15 -0
- projects/k3s-test-app/tests/__init__.py +1 -0
- projects/k3s-test-app/tests/conftest.py +12 -0
- projects/k3s-test-app/tests/test_phase1_health.py +41 -0
- projects/k3s-test-app/tests/test_phase2_config.py +54 -0
- projects/k3s-test-app/tests/test_phase3_identity_network.py +74 -0
- projects/k3s-test-app/tests/test_phase4_storage.py +39 -0
- projects/k3s-test-app/tests/test_phase5_resilience.py +51 -0
- projects/k3s-test-app/tests/test_phase6_realtime_upload.py +56 -0
- projects/k3s-test-app/tests/test_phase7_observability.py +15 -0
- projects/k3s-test-app/uv.lock +0 -0
projects/k3s-test-app/.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# agentic stuffs
|
| 13 |
+
.agent/
|
| 14 |
+
review.txt
|
projects/k3s-test-app/.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.14
|
projects/k3s-test-app/Containerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: build dependencies
|
| 2 |
+
FROM python:3.14-slim AS builder
|
| 3 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
COPY pyproject.toml uv.lock README.md ./
|
| 6 |
+
RUN uv sync --frozen --no-install-project --no-dev
|
| 7 |
+
COPY src/ src/
|
| 8 |
+
RUN uv sync --frozen --no-dev
|
| 9 |
+
|
| 10 |
+
# Stage 2: runtime
|
| 11 |
+
FROM python:3.14-slim
|
| 12 |
+
COPY --from=builder /app /app
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 15 |
+
EXPOSE 8000
|
| 16 |
+
USER nobody
|
| 17 |
+
CMD ["uvicorn", "k3s_test_app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "30"]
|
projects/k3s-test-app/DOCS/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ARCHITECTURE.md
|
| 2 |
+
|
| 3 |
+
## App Flow
|
| 4 |
+
Client → k3s Ingress (Traefik) → Service → Pod(s) → FastAPI app → (ConfigMap/Secret/PVC/other Service)
|
| 5 |
+
|
| 6 |
+
## Folder Structure
|
| 7 |
+
```
|
| 8 |
+
k3s-test-app/
|
| 9 |
+
├── app/
|
| 10 |
+
│ ├── main.py # app entrypoint, router includes
|
| 11 |
+
│ ├── routers/
|
| 12 |
+
│ │ ├── health.py # /healthz, /ready
|
| 13 |
+
│ │ ├── config.py # /config, /secret
|
| 14 |
+
│ │ ├── stress.py # /stress/cpu, /stress/mem
|
| 15 |
+
│ │ ├── lifecycle.py # /crash, /sleep, shutdown handler
|
| 16 |
+
│ │ ├── identity.py # /whoami
|
| 17 |
+
│ │ ├── storage.py # /storage
|
| 18 |
+
│ │ ├── network.py # /delay/{s}, /call-internal, /ws, /stream
|
| 19 |
+
│ │ ├── upload.py # /upload
|
| 20 |
+
│ │ └── version.py # /version
|
| 21 |
+
│ ├── core/
|
| 22 |
+
│ │ ├── config.py # pydantic Settings (env vars)
|
| 23 |
+
│ │ └── logging.py
|
| 24 |
+
│ └── metrics.py # prometheus_client instrumentation
|
| 25 |
+
├── k8s/
|
| 26 |
+
│ ├── deployment.yaml
|
| 27 |
+
│ ├── service.yaml
|
| 28 |
+
│ ├── ingress.yaml
|
| 29 |
+
│ ├── configmap.yaml
|
| 30 |
+
│ ├── secret.yaml
|
| 31 |
+
│ ├── pvc.yaml
|
| 32 |
+
│ └── hpa.yaml
|
| 33 |
+
├── Dockerfile
|
| 34 |
+
├── requirements.txt
|
| 35 |
+
├── PRD.md
|
| 36 |
+
├── ARCHITECTURE.md
|
| 37 |
+
├── RULES.md
|
| 38 |
+
└── PHASES.md
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
## Tech Stack
|
| 42 |
+
- **Runtime**: Python 3.12, FastAPI, Uvicorn (with `--timeout-graceful-shutdown`)
|
| 43 |
+
- **Metrics**: `prometheus-client` / `prometheus-fastapi-instrumentator`
|
| 44 |
+
- **Config**: `pydantic-settings` (env var driven)
|
| 45 |
+
- **Container**: multi-stage Dockerfile, non-root user
|
| 46 |
+
- **Orchestration**: k3s, Traefik ingress (built-in), standard Deployment/Service/PVC/HPA manifests
|
| 47 |
+
- **Load testing**: `hey` / `k6` (external, not part of app)
|
| 48 |
+
|
| 49 |
+
## Data Flow Notes
|
| 50 |
+
- `/storage` reads/writes to a path backed by a PVC (`local-path` provisioner default in k3s)
|
| 51 |
+
- `/call-internal` hits another Service by in-cluster DNS name (`svc.namespace.svc.cluster.local`)
|
| 52 |
+
- `/metrics` scraped by Prometheus (or manual `curl`) in Prometheus text format
|
projects/k3s-test-app/DOCS/PHASES.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PHASES.md
|
| 2 |
+
|
| 3 |
+
## Phase 1 — Core App + Health
|
| 4 |
+
- Scaffold FastAPI app, Dockerfile, requirements.txt
|
| 5 |
+
- `/healthz`, `/ready`, `/version`
|
| 6 |
+
- Deployment + Service manifests, deploy to k3s
|
| 7 |
+
|
| 8 |
+
## Phase 2 — Config & Secrets
|
| 9 |
+
- `/config` reads ConfigMap-injected env vars
|
| 10 |
+
- `/secret` reads mounted Secret
|
| 11 |
+
- ConfigMap + Secret manifests
|
| 12 |
+
|
| 13 |
+
## Phase 3 — Identity & Networking
|
| 14 |
+
- `/whoami` (hostname, pod IP)
|
| 15 |
+
- `/delay/{seconds}`
|
| 16 |
+
- `/call-internal` (in-cluster service-to-service call)
|
| 17 |
+
- Ingress manifest + Traefik routing test
|
| 18 |
+
|
| 19 |
+
## Phase 4 — Storage
|
| 20 |
+
- `/storage` read/write endpoint
|
| 21 |
+
- PVC manifest, verify persistence across pod restarts
|
| 22 |
+
|
| 23 |
+
## Phase 5 — Load & Resilience
|
| 24 |
+
- `/stress/cpu`, `/stress/mem`
|
| 25 |
+
- `/crash`, `/sleep`
|
| 26 |
+
- Resource limits + HPA manifest
|
| 27 |
+
- Graceful SIGTERM shutdown handler
|
| 28 |
+
|
| 29 |
+
## Phase 6 — Realtime & Uploads
|
| 30 |
+
- `/ws` or `/stream` (SSE)
|
| 31 |
+
- `/upload`
|
| 32 |
+
- Ingress body-size/timeout tuning
|
| 33 |
+
|
| 34 |
+
## Phase 7 — Observability
|
| 35 |
+
- `/metrics` (Prometheus format)
|
| 36 |
+
- Optional: Prometheus + Grafana deploy for scraping/dashboards
|
| 37 |
+
|
| 38 |
+
## Phase 8 — Rolling Update Validation
|
| 39 |
+
- `/version` bump workflow
|
| 40 |
+
- Rolling update + rollback test (`kubectl rollout`)
|
projects/k3s-test-app/DOCS/PRD.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PRD.md
|
| 2 |
+
|
| 3 |
+
## Project
|
| 4 |
+
k3s-test-app — FastAPI service for exercising k3s cluster behaviors (probes, config, scaling, storage, networking).
|
| 5 |
+
|
| 6 |
+
## What to Build
|
| 7 |
+
A single FastAPI app exposing test endpoints (health, config/secret readers, stress, crash, storage, delay, metrics, websocket, upload, graceful shutdown) to validate k3s deployment primitives end-to-end.
|
| 8 |
+
|
| 9 |
+
## Targeted User
|
| 10 |
+
Solo dev / infra engineer learning or validating a k3s cluster setup. Not a production consumer-facing app.
|
| 11 |
+
|
| 12 |
+
## Features
|
| 13 |
+
- [ ] Liveness/readiness probes (`/healthz`, `/ready`)
|
| 14 |
+
- [ ] Config/secret introspection (`/config`, `/secret`)
|
| 15 |
+
- [ ] Load/stress simulation (`/stress/cpu`, `/stress/mem`)
|
| 16 |
+
- [ ] Controlled crash/sleep (`/crash`, `/sleep`)
|
| 17 |
+
- [ ] Pod identity (`/whoami` — hostname, pod IP)
|
| 18 |
+
- [ ] Persistent volume read/write (`/storage`)
|
| 19 |
+
- [ ] Configurable delay (`/delay/{seconds}`)
|
| 20 |
+
- [ ] Build/version info (`/version`)
|
| 21 |
+
- [ ] Internal service call (`/call-internal`)
|
| 22 |
+
- [ ] WebSocket/SSE (`/ws`, `/stream`)
|
| 23 |
+
- [ ] File upload (`/upload`)
|
| 24 |
+
- [ ] Prometheus metrics (`/metrics`)
|
| 25 |
+
- [ ] Graceful SIGTERM shutdown
|
| 26 |
+
|
| 27 |
+
## Out of Scope
|
| 28 |
+
Auth, persistence beyond test PVC, multi-tenant concerns, UI.
|
| 29 |
+
|
| 30 |
+
## Success Criteria
|
| 31 |
+
Each endpoint proves one specific k3s behavior (see list above) reproducibly via `kubectl` observation.
|
projects/k3s-test-app/DOCS/RULES.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RULES.md
|
| 2 |
+
|
| 3 |
+
## Use
|
| 4 |
+
- FastAPI + Uvicorn (async endpoints where I/O bound)
|
| 5 |
+
- `pydantic-settings` for all config — no hardcoded values
|
| 6 |
+
- `prometheus-client` for metrics
|
| 7 |
+
- Structured logging (`logging` + JSON formatter), log to stdout only
|
| 8 |
+
- Type hints on all functions
|
| 9 |
+
- `httpx` for internal service calls (async)
|
| 10 |
+
|
| 11 |
+
## Preferred Libraries
|
| 12 |
+
| Purpose | Library |
|
| 13 |
+
|---|---|
|
| 14 |
+
| Web framework | fastapi |
|
| 15 |
+
| ASGI server | uvicorn[standard] |
|
| 16 |
+
| Config | pydantic-settings |
|
| 17 |
+
| HTTP client | httpx |
|
| 18 |
+
| Metrics | prometheus-fastapi-instrumentator |
|
| 19 |
+
| Testing | pytest, pytest-asyncio |
|
| 20 |
+
|
| 21 |
+
## Error Handling
|
| 22 |
+
- No bare `except:` — always catch specific exceptions
|
| 23 |
+
- Every endpoint returns proper HTTP status codes (4xx client, 5xx server)
|
| 24 |
+
- Use FastAPI `HTTPException` for expected failures; let unexpected ones 500 + log traceback
|
| 25 |
+
- All external calls (`/call-internal`, storage I/O) wrapped with timeout + explicit error response, never hang silently
|
| 26 |
+
- Startup/shutdown events must not crash the app — log and degrade gracefully
|
| 27 |
+
|
| 28 |
+
## AI Boundaries / Constraints
|
| 29 |
+
- AI may scaffold code, write endpoints matching PRD.md features, write k8s manifests
|
| 30 |
+
- AI must NOT introduce new libraries not listed in "Preferred Libraries" without explicit approval
|
| 31 |
+
- AI must NOT change project structure defined in ARCHITECTURE.md without approval
|
| 32 |
+
- AI must NOT add auth, persistence, or scope creep beyond PRD.md
|
| 33 |
+
- AI must ask before deleting files or modifying k8s manifests already applied to a live cluster
|
| 34 |
+
|
| 35 |
+
## Avoid
|
| 36 |
+
- Unapproved libraries (Flask, Django, requests in async context, etc.)
|
| 37 |
+
- Silent failures / swallowed exceptions
|
| 38 |
+
- Global mutable state outside app lifespan
|
| 39 |
+
- Hardcoded secrets/config (must come from env/ConfigMap/Secret)
|
| 40 |
+
- AI making architectural or scope decisions outside PRD.md/ARCHITECTURE.md without flagging it
|
projects/k3s-test-app/DOCS/TESTING.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TESTING.md
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
The test suite lives in `tests/` and is organized by phase, matching the implementation in `PHASES.md`. Tests use `pytest` with `pytest-asyncio` and `httpx`'s `AsyncClient` pointed at the ASGI app — no real server is started.
|
| 5 |
+
|
| 6 |
+
## Structure
|
| 7 |
+
```
|
| 8 |
+
tests/
|
| 9 |
+
├── __init__.py
|
| 10 |
+
├── conftest.py # Shared fixtures (async TestClient)
|
| 11 |
+
├── test_phase1_health.py # /healthz, /ready, /version
|
| 12 |
+
├── test_phase2_config.py # /config, /secret
|
| 13 |
+
├── test_phase3_identity_network.py # /whoami, /delay, /call-internal
|
| 14 |
+
├── test_phase4_storage.py # /storage
|
| 15 |
+
├── test_phase5_resilience.py # /stress/cpu, /stress/mem, /crash, /sleep
|
| 16 |
+
├── test_phase6_realtime_upload.py # /ws, /upload
|
| 17 |
+
└── test_phase7_observability.py # /metrics
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
## Running Tests
|
| 21 |
+
|
| 22 |
+
### Install dev dependencies
|
| 23 |
+
```bash
|
| 24 |
+
uv sync --group dev
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### Run all tests
|
| 28 |
+
```bash
|
| 29 |
+
uv run pytest
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Run a specific phase
|
| 33 |
+
```bash
|
| 34 |
+
uv run pytest tests/test_phase1_health.py
|
| 35 |
+
uv run pytest tests/test_phase5_resilience.py
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
### Run with verbose output
|
| 39 |
+
```bash
|
| 40 |
+
uv run pytest -v
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### Run with coverage (if you add pytest-cov later)
|
| 44 |
+
```bash
|
| 45 |
+
uv run pytest --cov=k3s_test_app --cov-report=term-missing
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## Test Philosophy
|
| 49 |
+
- Each test file covers one phase's endpoints.
|
| 50 |
+
- External I/O (cluster DNS, secret files) is mocked using `unittest.mock.patch` and `monkeypatch` — tests run fully offline without a cluster.
|
| 51 |
+
- Async endpoints are tested with `pytest-asyncio` in `asyncio_mode = auto` (configured in `pytest.ini`).
|
| 52 |
+
- Settings are patched inline using `monkeypatch.setattr` on `cfg_module.settings` — no env file hacks needed.
|
| 53 |
+
|
| 54 |
+
## Notable Patterns
|
| 55 |
+
|
| 56 |
+
### Async test client (conftest.py)
|
| 57 |
+
```python
|
| 58 |
+
@pytest_asyncio.fixture
|
| 59 |
+
async def client() -> AsyncClient:
|
| 60 |
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
| 61 |
+
yield ac
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### Patching settings at runtime
|
| 65 |
+
```python
|
| 66 |
+
from k3s_test_app.core import config as cfg_module
|
| 67 |
+
monkeypatch.setattr(cfg_module.settings, "ready", False)
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Mocking httpx calls for /call-internal
|
| 71 |
+
```python
|
| 72 |
+
with patch("k3s_test_app.routers.network.httpx.AsyncClient") as mock_http:
|
| 73 |
+
mock_http.return_value.__aenter__.return_value.get.side_effect = httpx.ConnectError("Name or service not known")
|
| 74 |
+
response = await client.get("/call-internal")
|
| 75 |
+
assert response.status_code == 502
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### Mocking built-in loops safely (test_phase5_resilience.py)
|
| 79 |
+
```python
|
| 80 |
+
def mock_time_gen():
|
| 81 |
+
t = 0
|
| 82 |
+
while True:
|
| 83 |
+
yield t
|
| 84 |
+
t += 10
|
| 85 |
+
|
| 86 |
+
with patch("k3s_test_app.routers.stress.time.time", side_effect=mock_time_gen()):
|
| 87 |
+
response = await client.get("/stress/cpu?seconds=5")
|
| 88 |
+
```
|
projects/k3s-test-app/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Gaurav5189
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
projects/k3s-test-app/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# k3s-test-app 🚀
|
| 2 |
+
|
| 3 |
+
`k3s-test-app` is a lightweight, purpose-built FastAPI application designed to validate and test various Kubernetes capabilities within a `k3s` environment. It provides a comprehensive suite of endpoints to simulate real-world behaviors—ranging from configuration injection and persistent storage to load-induced autoscaling and graceful degradation.
|
| 4 |
+
|
| 5 |
+
## Features ✨
|
| 6 |
+
|
| 7 |
+
This application is built in phases, exposing endpoints to test specific k8s features:
|
| 8 |
+
|
| 9 |
+
- **Core & Probes**: `/healthz` and `/ready` for liveness and readiness probes.
|
| 10 |
+
- **Configuration & Secrets**: `/config` and `/secret` to validate ConfigMap environment variable injection and Secret volume mounting.
|
| 11 |
+
- **Identity & Networking**: `/whoami` to verify Downward API (`POD_IP`) injection, `/delay` for timeout testing, and `/call-internal` for cluster DNS resolution.
|
| 12 |
+
- **Storage Persistence**: `GET /storage` and `POST /storage` to validate read/write operations against PVCs.
|
| 13 |
+
- **Load & Resilience**: `/stress/cpu` and `/stress/mem` to trigger Horizontal Pod Autoscalers (HPA), and `/crash` / `/sleep` to test pod restart policies and event loop blocks.
|
| 14 |
+
- **Realtime & Uploads**: `/ws` for long-lived WebSocket connections and `/upload` to test ingress body size limits.
|
| 15 |
+
- **Observability**: `/metrics` exposes Prometheus-formatted application metrics automatically.
|
| 16 |
+
|
| 17 |
+
## Tech Stack 🛠️
|
| 18 |
+
|
| 19 |
+
- **Framework**: FastAPI + Uvicorn
|
| 20 |
+
- **Configuration**: Pydantic Settings
|
| 21 |
+
- **Testing**: Pytest, Pytest-Asyncio, HTTPX
|
| 22 |
+
- **Containerization**: Podman + Containerfile (Multi-stage build)
|
| 23 |
+
- **Deployment**: Kubernetes Manifests (Deployment, Service, Ingress, ConfigMap, Secret, PVC, HPA)
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## Local Development & Testing 💻
|
| 28 |
+
|
| 29 |
+
This project uses `uv` for fast Python dependency management.
|
| 30 |
+
|
| 31 |
+
### Setup
|
| 32 |
+
```bash
|
| 33 |
+
# Sync dependencies (including dev dependencies)
|
| 34 |
+
uv sync --group dev
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
### Running the App Locally
|
| 38 |
+
```bash
|
| 39 |
+
uv run uvicorn k3s_test_app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### Running Tests
|
| 43 |
+
The project includes a full test suite built with `pytest` that mocks out infrastructure dependencies (filesystem, DNS, delays) to run instantly without a real cluster.
|
| 44 |
+
```bash
|
| 45 |
+
# Run the entire test suite
|
| 46 |
+
uv run pytest -v
|
| 47 |
+
|
| 48 |
+
# Run tests for a specific phase
|
| 49 |
+
uv run pytest -v tests/test_phase5_resilience.py
|
| 50 |
+
```
|
| 51 |
+
> 📚 See [DOCS/TESTING.md](DOCS/TESTING.md) for more details on the testing architecture.
|
| 52 |
+
|
| 53 |
+
---
|
| 54 |
+
|
| 55 |
+
## Deployment to k3s 🚢
|
| 56 |
+
|
| 57 |
+
All Kubernetes manifests are located in the `k8s/` directory.
|
| 58 |
+
|
| 59 |
+
### 1. Build and Push the Image
|
| 60 |
+
We use `podman` and a `Containerfile` to build the image.
|
| 61 |
+
```bash
|
| 62 |
+
podman build -t k3s-test-app:latest -f Containerfile .
|
| 63 |
+
podman push docker.io/<username>/k3s-test-app:latest
|
| 64 |
+
```
|
| 65 |
+
*(Remember to update the `image:` reference in `k8s/deployment.yaml` if you push to a custom registry).*
|
| 66 |
+
|
| 67 |
+
### 2. Ensure Metrics Server is Installed (HPA Requirement)
|
| 68 |
+
The Horizontal Pod Autoscaler (HPA) requires the `metrics.k8s.io` API to scale based on CPU utilization.
|
| 69 |
+
If your cluster does not have `metrics-server` installed (some k3s distributions include it by default, some don't), install it first:
|
| 70 |
+
```bash
|
| 71 |
+
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### 3. Apply the Manifests
|
| 75 |
+
```bash
|
| 76 |
+
kubectl apply -f k8s/
|
| 77 |
+
```
|
| 78 |
+
This will deploy the application, configure Traefik Ingress (including Middlewares), mount secrets, provision local-path storage, and set up HPA rules.
|
| 79 |
+
|
| 80 |
+
### 4. Access the Application
|
| 81 |
+
The `Ingress` is configured to listen on `test.local`. You can test it locally by passing the `Host` header:
|
| 82 |
+
```bash
|
| 83 |
+
curl -H "Host: test.local" http://<k3s-node-ip>/healthz
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## API Reference 📖
|
| 89 |
+
|
| 90 |
+
Once the application is running, you can view the auto-generated Swagger documentation at:
|
| 91 |
+
- `http://<host>:8000/docs`
|
| 92 |
+
|
| 93 |
+
### Key Endpoints:
|
| 94 |
+
| Endpoint | Method | Purpose |
|
| 95 |
+
|----------|--------|---------|
|
| 96 |
+
| `/healthz` | GET | Liveness probe (Always returns 200) |
|
| 97 |
+
| `/ready` | GET | Readiness probe (Fails if `READY=false`) |
|
| 98 |
+
| `/version` | GET | Application version info |
|
| 99 |
+
| `/config` | GET | Returns environment configuration |
|
| 100 |
+
| `/secret` | GET | Reads from the mounted k8s Secret |
|
| 101 |
+
| `/whoami` | GET | Returns Pod IP and hostname |
|
| 102 |
+
| `/storage` | POST/GET | Writes/reads to the PVC |
|
| 103 |
+
| `/stress/cpu` | GET | Spikes CPU utilization (tests HPA) |
|
| 104 |
+
| `/metrics` | GET | Prometheus scraping endpoint |
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## Documentation Links
|
| 109 |
+
- [Architecture & Requirements](DOCS/ARCHITECTURE.md)
|
| 110 |
+
- [Implementation Phases](DOCS/PHASES.md)
|
| 111 |
+
- [Agent Memory/Changelog](.agent/MEMORY.md)
|
projects/k3s-test-app/k8s/configmap.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v1
|
| 2 |
+
kind: ConfigMap
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app-config
|
| 5 |
+
data:
|
| 6 |
+
APP_NAME: "k3s-test-app"
|
| 7 |
+
LOG_LEVEL: "info"
|
| 8 |
+
CUSTOM_MESSAGE: "Hello from k3s ConfigMap!"
|
projects/k3s-test-app/k8s/deployment.yaml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: apps/v1
|
| 2 |
+
kind: Deployment
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app
|
| 5 |
+
labels:
|
| 6 |
+
app: k3s-test-app
|
| 7 |
+
spec:
|
| 8 |
+
replicas: 1
|
| 9 |
+
selector:
|
| 10 |
+
matchLabels:
|
| 11 |
+
app: k3s-test-app
|
| 12 |
+
template:
|
| 13 |
+
metadata:
|
| 14 |
+
labels:
|
| 15 |
+
app: k3s-test-app
|
| 16 |
+
spec:
|
| 17 |
+
containers:
|
| 18 |
+
- name: k3s-test-app
|
| 19 |
+
image: gaurav0s/k3s-test-app:latest
|
| 20 |
+
imagePullPolicy: Always
|
| 21 |
+
ports:
|
| 22 |
+
- containerPort: 8000
|
| 23 |
+
livenessProbe:
|
| 24 |
+
httpGet:
|
| 25 |
+
path: /healthz
|
| 26 |
+
port: 8000
|
| 27 |
+
initialDelaySeconds: 10
|
| 28 |
+
periodSeconds: 15
|
| 29 |
+
timeoutSeconds: 3
|
| 30 |
+
failureThreshold: 3
|
| 31 |
+
readinessProbe:
|
| 32 |
+
httpGet:
|
| 33 |
+
path: /ready
|
| 34 |
+
port: 8000
|
| 35 |
+
initialDelaySeconds: 5
|
| 36 |
+
periodSeconds: 10
|
| 37 |
+
timeoutSeconds: 3
|
| 38 |
+
failureThreshold: 2
|
| 39 |
+
env:
|
| 40 |
+
- name: POD_IP
|
| 41 |
+
valueFrom:
|
| 42 |
+
fieldRef:
|
| 43 |
+
fieldPath: status.podIP
|
| 44 |
+
envFrom:
|
| 45 |
+
- configMapRef:
|
| 46 |
+
name: k3s-test-app-config
|
| 47 |
+
volumeMounts:
|
| 48 |
+
- name: test-secret-volume
|
| 49 |
+
mountPath: /etc/secrets/test-secret
|
| 50 |
+
readOnly: true
|
| 51 |
+
- name: storage-volume
|
| 52 |
+
mountPath: /data
|
| 53 |
+
resources:
|
| 54 |
+
requests:
|
| 55 |
+
cpu: 50m
|
| 56 |
+
memory: 64Mi
|
| 57 |
+
limits:
|
| 58 |
+
cpu: 500m
|
| 59 |
+
memory: 256Mi
|
| 60 |
+
volumes:
|
| 61 |
+
- name: test-secret-volume
|
| 62 |
+
secret:
|
| 63 |
+
secretName: k3s-test-app-secret
|
| 64 |
+
- name: storage-volume
|
| 65 |
+
persistentVolumeClaim:
|
| 66 |
+
claimName: k3s-test-app-pvc
|
projects/k3s-test-app/k8s/hpa.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: autoscaling/v2
|
| 2 |
+
kind: HorizontalPodAutoscaler
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app-hpa
|
| 5 |
+
spec:
|
| 6 |
+
scaleTargetRef:
|
| 7 |
+
apiVersion: apps/v1
|
| 8 |
+
kind: Deployment
|
| 9 |
+
name: k3s-test-app
|
| 10 |
+
minReplicas: 1
|
| 11 |
+
maxReplicas: 2
|
| 12 |
+
metrics:
|
| 13 |
+
- type: Resource
|
| 14 |
+
resource:
|
| 15 |
+
name: cpu
|
| 16 |
+
target:
|
| 17 |
+
type: Utilization
|
| 18 |
+
averageUtilization: 60
|
projects/k3s-test-app/k8s/pvc.yaml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v1
|
| 2 |
+
kind: PersistentVolumeClaim
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app-pvc
|
| 5 |
+
spec:
|
| 6 |
+
accessModes:
|
| 7 |
+
- ReadWriteOnce
|
| 8 |
+
resources:
|
| 9 |
+
requests:
|
| 10 |
+
storage: 100Mi
|
| 11 |
+
# k3s uses local-path provisioner by default
|
| 12 |
+
storageClassName: local-path
|
projects/k3s-test-app/k8s/secret.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v1
|
| 2 |
+
kind: Secret
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app-secret
|
| 5 |
+
type: Opaque
|
| 6 |
+
data:
|
| 7 |
+
# NOTE: This is a dummy non-sensitive test fixture to validate secret volume mounting.
|
| 8 |
+
# Do not use this pattern for real production secrets.
|
| 9 |
+
api-key: c3VwZXItc2VjcmV0LWtleQ== # 'super-secret-key' in base64
|
projects/k3s-test-app/k8s/service.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: v1
|
| 2 |
+
kind: Service
|
| 3 |
+
metadata:
|
| 4 |
+
name: k3s-test-app
|
| 5 |
+
labels:
|
| 6 |
+
app: k3s-test-app
|
| 7 |
+
spec:
|
| 8 |
+
type: NodePort
|
| 9 |
+
selector:
|
| 10 |
+
app: k3s-test-app
|
| 11 |
+
ports:
|
| 12 |
+
- protocol: TCP
|
| 13 |
+
port: 80
|
| 14 |
+
targetPort: 8000
|
| 15 |
+
nodePort: 30080
|
projects/k3s-test-app/pyproject.toml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "k3s-test-app"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
authors = [
|
| 7 |
+
{ name = "Gaurav5189", email = "gauravsethi4627@gmail.com" }
|
| 8 |
+
]
|
| 9 |
+
requires-python = ">=3.14"
|
| 10 |
+
dependencies = [
|
| 11 |
+
"fastapi[standard]>=0.141.1",
|
| 12 |
+
"uvicorn[standard]>=0.34.0",
|
| 13 |
+
"pydantic-settings>=2.9.1",
|
| 14 |
+
"httpx>=0.28.1",
|
| 15 |
+
"prometheus-fastapi-instrumentator>=7.0.2",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = [
|
| 20 |
+
"pytest>=8.0",
|
| 21 |
+
"pytest-asyncio>=0.25",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
[project.scripts]
|
| 25 |
+
k3s-test-app = "k3s_test_app:main"
|
| 26 |
+
|
| 27 |
+
[build-system]
|
| 28 |
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
| 29 |
+
build-backend = "uv_build"
|
projects/k3s-test-app/pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_mode = auto
|
| 3 |
+
testpaths = tests
|
projects/k3s-test-app/src/k3s_test_app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.1.0"
|
projects/k3s-test-app/src/k3s_test_app/core/config.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
app_name: str = "k3s-test-app"
|
| 6 |
+
app_version: str = "0.1.0"
|
| 7 |
+
log_level: str = "info"
|
| 8 |
+
storage_path: str = "/data"
|
| 9 |
+
ready: bool = True
|
| 10 |
+
custom_message: str = "default-message"
|
| 11 |
+
secret_path: str = "/etc/secrets/test-secret/api-key"
|
| 12 |
+
|
| 13 |
+
model_config = SettingsConfigDict(
|
| 14 |
+
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
settings = Settings()
|
projects/k3s-test-app/src/k3s_test_app/core/logging.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
from typing import Any, Dict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class JSONFormatter(logging.Formatter):
|
| 8 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 9 |
+
log_data: Dict[str, Any] = {
|
| 10 |
+
"level": record.levelname,
|
| 11 |
+
"message": record.getMessage(),
|
| 12 |
+
"name": record.name,
|
| 13 |
+
"time": self.formatTime(record, self.datefmt),
|
| 14 |
+
}
|
| 15 |
+
if record.exc_info:
|
| 16 |
+
log_data["exc_info"] = self.formatException(record.exc_info)
|
| 17 |
+
return json.dumps(log_data)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def setup_logging(log_level_str: str) -> None:
|
| 21 |
+
level = getattr(logging, log_level_str.upper(), logging.INFO)
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger("k3s_test_app")
|
| 24 |
+
logger.setLevel(level)
|
| 25 |
+
|
| 26 |
+
# Avoid duplicate logs if setup_logging is called multiple times
|
| 27 |
+
logger.handlers.clear()
|
| 28 |
+
|
| 29 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 30 |
+
handler.setFormatter(JSONFormatter())
|
| 31 |
+
logger.addHandler(handler)
|
| 32 |
+
|
| 33 |
+
# Configure uvicorn loggers to use the same formatter if they exist
|
| 34 |
+
for uvicorn_logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
|
| 35 |
+
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
|
| 36 |
+
uvicorn_logger.handlers.clear()
|
| 37 |
+
uvicorn_logger.addHandler(handler)
|
| 38 |
+
|
| 39 |
+
# Don't propagate to root logger
|
| 40 |
+
uvicorn_logger.propagate = False
|
projects/k3s-test-app/src/k3s_test_app/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from prometheus_fastapi_instrumentator import Instrumentator
|
| 6 |
+
|
| 7 |
+
from k3s_test_app import __version__
|
| 8 |
+
from k3s_test_app.core.config import settings
|
| 9 |
+
from k3s_test_app.core.logging import setup_logging
|
| 10 |
+
from k3s_test_app.routers import (
|
| 11 |
+
health,
|
| 12 |
+
version,
|
| 13 |
+
config,
|
| 14 |
+
identity,
|
| 15 |
+
network,
|
| 16 |
+
storage,
|
| 17 |
+
stress,
|
| 18 |
+
lifecycle,
|
| 19 |
+
upload,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger("k3s_test_app")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@asynccontextmanager
|
| 26 |
+
async def lifespan(app: FastAPI):
|
| 27 |
+
setup_logging(settings.log_level)
|
| 28 |
+
logger.info(f"Starting {settings.app_name} v{__version__}")
|
| 29 |
+
yield
|
| 30 |
+
logger.info(f"Shutting down {settings.app_name}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = FastAPI(
|
| 34 |
+
title=settings.app_name,
|
| 35 |
+
version=__version__,
|
| 36 |
+
lifespan=lifespan,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Instrument the app for Prometheus metrics
|
| 40 |
+
Instrumentator().instrument(app).expose(app)
|
| 41 |
+
|
| 42 |
+
# Include routers
|
| 43 |
+
app.include_router(health.router, tags=["Health"])
|
| 44 |
+
app.include_router(version.router, tags=["Info"])
|
| 45 |
+
app.include_router(config.router, tags=["Config"])
|
| 46 |
+
app.include_router(identity.router, tags=["Identity"])
|
| 47 |
+
app.include_router(network.router, tags=["Network"])
|
| 48 |
+
app.include_router(storage.router, tags=["Storage"])
|
| 49 |
+
app.include_router(stress.router, tags=["Stress"])
|
| 50 |
+
app.include_router(lifecycle.router, tags=["Lifecycle"])
|
| 51 |
+
app.include_router(upload.router, tags=["Upload"])
|
projects/k3s-test-app/src/k3s_test_app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routers package
|
projects/k3s-test-app/src/k3s_test_app/routers/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from k3s_test_app.core.config import settings
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
logger = logging.getLogger("k3s_test_app")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/config")
|
| 11 |
+
async def get_config() -> dict:
|
| 12 |
+
"""Return non-sensitive configuration values to verify ConfigMap injection."""
|
| 13 |
+
return {
|
| 14 |
+
"app_name": settings.app_name,
|
| 15 |
+
"log_level": settings.log_level,
|
| 16 |
+
"custom_message": settings.custom_message,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/secret")
|
| 21 |
+
async def get_secret() -> dict:
|
| 22 |
+
"""Read and return the mounted secret file to verify volume mounting."""
|
| 23 |
+
try:
|
| 24 |
+
with open(settings.secret_path, "r") as f:
|
| 25 |
+
f.read(1)
|
| 26 |
+
return {"secret_mounted": True}
|
| 27 |
+
except FileNotFoundError:
|
| 28 |
+
logger.error(f"Secret file not found at {settings.secret_path}")
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=404,
|
| 31 |
+
detail="Secret file not found. Ensure it is mounted correctly."
|
| 32 |
+
)
|
| 33 |
+
except Exception as e:
|
| 34 |
+
logger.error(f"Error reading secret file: {e}")
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=500,
|
| 37 |
+
detail="Error reading secret file."
|
| 38 |
+
)
|
projects/k3s-test-app/src/k3s_test_app/routers/health.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Response, status
|
| 2 |
+
from k3s_test_app.core.config import settings
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.get("/healthz", status_code=status.HTTP_200_OK)
|
| 8 |
+
async def healthz() -> dict:
|
| 9 |
+
"""Liveness probe endpoint."""
|
| 10 |
+
return {"status": "alive"}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/ready")
|
| 14 |
+
async def ready(response: Response) -> dict:
|
| 15 |
+
"""Readiness probe endpoint."""
|
| 16 |
+
if settings.ready:
|
| 17 |
+
response.status_code = status.HTTP_200_OK
|
| 18 |
+
return {"status": "ready"}
|
| 19 |
+
|
| 20 |
+
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
| 21 |
+
return {"status": "not_ready"}
|
projects/k3s-test-app/src/k3s_test_app/routers/identity.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import socket
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.get("/whoami")
|
| 9 |
+
async def whoami() -> dict:
|
| 10 |
+
"""Return pod identity information (hostname and IP)."""
|
| 11 |
+
hostname = socket.gethostname()
|
| 12 |
+
# Try to get the IP from the Downward API first, fallback to socket resolution
|
| 13 |
+
pod_ip = os.getenv("POD_IP")
|
| 14 |
+
if not pod_ip:
|
| 15 |
+
try:
|
| 16 |
+
pod_ip = socket.gethostbyname(hostname)
|
| 17 |
+
except Exception:
|
| 18 |
+
pod_ip = "unknown"
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
"hostname": hostname,
|
| 22 |
+
"pod_ip": pod_ip,
|
| 23 |
+
}
|
projects/k3s-test-app/src/k3s_test_app/routers/lifecycle.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
logger = logging.getLogger("k3s_test_app")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/crash")
|
| 11 |
+
async def crash() -> dict:
|
| 12 |
+
"""Intentionally crash the pod to test liveness probe and restart policy."""
|
| 13 |
+
logger.critical("Intentional crash triggered! Exiting process.")
|
| 14 |
+
os._exit(1)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.get("/sleep")
|
| 18 |
+
async def sleep_block(seconds: int = 30) -> dict:
|
| 19 |
+
"""Block the event loop using time.sleep to simulate a stuck process (tests liveness probe)."""
|
| 20 |
+
logger.warning(f"Blocking the main thread for {seconds} seconds.")
|
| 21 |
+
time.sleep(seconds)
|
| 22 |
+
return {"message": "Awoke from sleep."}
|
projects/k3s-test-app/src/k3s_test_app/routers/network.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import httpx
|
| 3 |
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.websocket("/ws")
|
| 9 |
+
async def websocket_endpoint(websocket: WebSocket):
|
| 10 |
+
"""WebSocket endpoint that echoes messages back (tests long-lived connections)."""
|
| 11 |
+
await websocket.accept()
|
| 12 |
+
try:
|
| 13 |
+
while True:
|
| 14 |
+
data = await websocket.receive_text()
|
| 15 |
+
await websocket.send_text(f"Echo: {data}")
|
| 16 |
+
except WebSocketDisconnect:
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/delay/{seconds}")
|
| 21 |
+
async def delay(seconds: int) -> dict:
|
| 22 |
+
"""Simulate a delayed response for testing timeouts."""
|
| 23 |
+
await asyncio.sleep(seconds)
|
| 24 |
+
return {"message": f"Delayed for {seconds} seconds."}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("/call-internal")
|
| 28 |
+
async def call_internal() -> dict:
|
| 29 |
+
"""Make an internal service-to-service call to validate cluster DNS."""
|
| 30 |
+
# This URL targets the service's internal cluster DNS name
|
| 31 |
+
url = "http://k3s-test-app.default.svc.cluster.local:80/healthz"
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 35 |
+
response = await client.get(url)
|
| 36 |
+
response.raise_for_status()
|
| 37 |
+
return {
|
| 38 |
+
"status": "success",
|
| 39 |
+
"target_url": url,
|
| 40 |
+
"target_response": response.json(),
|
| 41 |
+
}
|
| 42 |
+
except httpx.HTTPError as e:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=502,
|
| 45 |
+
detail=f"Failed to communicate with internal service: {e}"
|
| 46 |
+
)
|
projects/k3s-test-app/src/k3s_test_app/routers/storage.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import APIRouter, HTTPException
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from k3s_test_app.core.config import settings
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class StorageData(BaseModel):
|
| 10 |
+
content: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/storage")
|
| 14 |
+
async def write_storage(data: StorageData) -> dict:
|
| 15 |
+
"""Write data to the persistent volume."""
|
| 16 |
+
file_path = os.path.join(settings.storage_path, "test-file.txt")
|
| 17 |
+
try:
|
| 18 |
+
os.makedirs(settings.storage_path, exist_ok=True)
|
| 19 |
+
with open(file_path, "w") as f:
|
| 20 |
+
f.write(data.content)
|
| 21 |
+
return {"status": "success", "file": file_path}
|
| 22 |
+
except Exception as e:
|
| 23 |
+
raise HTTPException(status_code=500, detail=f"Failed to write to storage: {e}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/storage")
|
| 27 |
+
async def read_storage() -> dict:
|
| 28 |
+
"""Read data from the persistent volume."""
|
| 29 |
+
file_path = os.path.join(settings.storage_path, "test-file.txt")
|
| 30 |
+
if not os.path.exists(file_path):
|
| 31 |
+
raise HTTPException(status_code=404, detail="File not found in storage.")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
with open(file_path, "r") as f:
|
| 35 |
+
content = f.read()
|
| 36 |
+
return {"content": content, "file": file_path}
|
| 37 |
+
except Exception as e:
|
| 38 |
+
raise HTTPException(status_code=500, detail=f"Failed to read from storage: {e}")
|
projects/k3s-test-app/src/k3s_test_app/routers/stress.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
logger = logging.getLogger("k3s_test_app")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/stress/cpu")
|
| 10 |
+
async def stress_cpu(seconds: int = 5) -> dict:
|
| 11 |
+
"""Stress the CPU to trigger HPA scaling."""
|
| 12 |
+
logger.info(f"Stressing CPU for {seconds} seconds.")
|
| 13 |
+
end_time = time.time() + seconds
|
| 14 |
+
while time.time() < end_time:
|
| 15 |
+
pass
|
| 16 |
+
return {"message": f"Completed CPU stress for {seconds} seconds."}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/stress/mem")
|
| 20 |
+
async def stress_mem(mb: int = 50) -> dict:
|
| 21 |
+
"""Stress memory allocation. Do not allocate too much or OOMKilled will trigger."""
|
| 22 |
+
logger.info(f"Allocating {mb}MB of memory.")
|
| 23 |
+
# Allocate bytearray
|
| 24 |
+
data = bytearray(mb * 1024 * 1024)
|
| 25 |
+
# Give the system a second to register the memory usage before releasing
|
| 26 |
+
time.sleep(1)
|
| 27 |
+
del data
|
| 28 |
+
return {"message": f"Successfully allocated and freed {mb}MB."}
|
projects/k3s-test-app/src/k3s_test_app/routers/upload.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File, HTTPException
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.post("/upload")
|
| 7 |
+
async def upload_file(file: UploadFile = File(...)) -> dict:
|
| 8 |
+
"""Accept a file upload and return its metadata to test body size limits."""
|
| 9 |
+
size_bytes = 0
|
| 10 |
+
max_upload_bytes = 10 * 1024 * 1024 # 10MB hard limit in the app
|
| 11 |
+
|
| 12 |
+
while chunk := await file.read(1024 * 1024):
|
| 13 |
+
size_bytes += len(chunk)
|
| 14 |
+
if size_bytes > max_upload_bytes:
|
| 15 |
+
raise HTTPException(status_code=413, detail="Upload exceeds the 10MB app size limit.")
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
"filename": file.filename,
|
| 19 |
+
"content_type": file.content_type,
|
| 20 |
+
"size_bytes": size_bytes,
|
| 21 |
+
"message": f"Successfully received {size_bytes} bytes."
|
| 22 |
+
}
|
projects/k3s-test-app/src/k3s_test_app/routers/version.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
from k3s_test_app.core.config import settings
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@router.get("/version")
|
| 9 |
+
async def version() -> dict:
|
| 10 |
+
"""Return version and environment information."""
|
| 11 |
+
return {
|
| 12 |
+
"app_name": settings.app_name,
|
| 13 |
+
"version": settings.app_version,
|
| 14 |
+
"python_version": sys.version,
|
| 15 |
+
}
|
projects/k3s-test-app/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Tests package
|
projects/k3s-test-app/tests/conftest.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared pytest fixtures for the k3s-test-app test suite."""
|
| 2 |
+
import pytest
|
| 3 |
+
import pytest_asyncio
|
| 4 |
+
from httpx import AsyncClient, ASGITransport
|
| 5 |
+
from k3s_test_app.main import app
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest_asyncio.fixture
|
| 9 |
+
async def client() -> AsyncClient:
|
| 10 |
+
"""Provide an async test client for the FastAPI app."""
|
| 11 |
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
| 12 |
+
yield ac
|
projects/k3s-test-app/tests/test_phase1_health.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 1: health and version endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
from httpx import AsyncClient
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.mark.asyncio
|
| 7 |
+
async def test_healthz_returns_200(client: AsyncClient) -> None:
|
| 8 |
+
"""Liveness probe should always return 200 with status=alive."""
|
| 9 |
+
response = await client.get("/healthz")
|
| 10 |
+
assert response.status_code == 200
|
| 11 |
+
assert response.json() == {"status": "alive"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.mark.asyncio
|
| 15 |
+
async def test_ready_returns_200_by_default(client: AsyncClient) -> None:
|
| 16 |
+
"""Readiness probe should return 200 when READY env var is true (default)."""
|
| 17 |
+
response = await client.get("/ready")
|
| 18 |
+
assert response.status_code == 200
|
| 19 |
+
assert response.json() == {"status": "ready"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_ready_returns_503_when_not_ready(client: AsyncClient, monkeypatch) -> None:
|
| 24 |
+
"""Readiness probe should return 503 when settings.ready is False."""
|
| 25 |
+
from k3s_test_app.core import config as cfg_module
|
| 26 |
+
monkeypatch.setattr(cfg_module.settings, "ready", False)
|
| 27 |
+
response = await client.get("/ready")
|
| 28 |
+
assert response.status_code == 503
|
| 29 |
+
assert response.json() == {"status": "not_ready"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@pytest.mark.asyncio
|
| 33 |
+
async def test_version_returns_expected_fields(client: AsyncClient) -> None:
|
| 34 |
+
"""/version should return app_name, version, and python_version."""
|
| 35 |
+
response = await client.get("/version")
|
| 36 |
+
assert response.status_code == 200
|
| 37 |
+
data = response.json()
|
| 38 |
+
assert "app_name" in data
|
| 39 |
+
assert "version" in data
|
| 40 |
+
assert "python_version" in data
|
| 41 |
+
assert data["app_name"] == "k3s-test-app"
|
projects/k3s-test-app/tests/test_phase2_config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 2: config and secret endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
import tempfile
|
| 4 |
+
import os
|
| 5 |
+
from httpx import AsyncClient
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.mark.asyncio
|
| 9 |
+
async def test_config_returns_expected_fields(client: AsyncClient) -> None:
|
| 10 |
+
"""/config should return app_name, log_level, and custom_message."""
|
| 11 |
+
response = await client.get("/config")
|
| 12 |
+
assert response.status_code == 200
|
| 13 |
+
data = response.json()
|
| 14 |
+
assert "app_name" in data
|
| 15 |
+
assert "log_level" in data
|
| 16 |
+
assert "custom_message" in data
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.mark.asyncio
|
| 20 |
+
async def test_config_custom_message_is_overridable(client: AsyncClient, monkeypatch) -> None:
|
| 21 |
+
"""CUSTOM_MESSAGE setting should be reflected in /config response."""
|
| 22 |
+
from k3s_test_app.core import config as cfg_module
|
| 23 |
+
monkeypatch.setattr(cfg_module.settings, "custom_message", "override-value")
|
| 24 |
+
response = await client.get("/config")
|
| 25 |
+
assert response.status_code == 200
|
| 26 |
+
assert response.json()["custom_message"] == "override-value"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_secret_returns_404_when_file_missing(client: AsyncClient, monkeypatch) -> None:
|
| 31 |
+
"""/secret should return 404 when the secret file does not exist."""
|
| 32 |
+
from k3s_test_app.core import config as cfg_module
|
| 33 |
+
monkeypatch.setattr(cfg_module.settings, "secret_path", "/nonexistent/path/secret")
|
| 34 |
+
response = await client.get("/secret")
|
| 35 |
+
assert response.status_code == 404
|
| 36 |
+
assert "not found" in response.json()["detail"].lower()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.mark.asyncio
|
| 40 |
+
async def test_secret_returns_content_when_file_exists(client: AsyncClient, monkeypatch) -> None:
|
| 41 |
+
"""/secret should return the file contents when the secret file exists."""
|
| 42 |
+
from k3s_test_app.core import config as cfg_module
|
| 43 |
+
|
| 44 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix="-api-key", delete=False) as f:
|
| 45 |
+
f.write("my-test-secret")
|
| 46 |
+
tmp_path = f.name
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
monkeypatch.setattr(cfg_module.settings, "secret_path", tmp_path)
|
| 50 |
+
response = await client.get("/secret")
|
| 51 |
+
assert response.status_code == 200
|
| 52 |
+
assert response.json() == {"secret_mounted": True}
|
| 53 |
+
finally:
|
| 54 |
+
os.unlink(tmp_path)
|
projects/k3s-test-app/tests/test_phase3_identity_network.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 3: identity and network endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
import socket
|
| 4 |
+
from unittest.mock import AsyncMock, patch, MagicMock
|
| 5 |
+
from httpx import AsyncClient
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.mark.asyncio
|
| 10 |
+
async def test_whoami_returns_hostname_and_ip(client: AsyncClient) -> None:
|
| 11 |
+
"""/whoami should return hostname and pod_ip fields."""
|
| 12 |
+
response = await client.get("/whoami")
|
| 13 |
+
assert response.status_code == 200
|
| 14 |
+
data = response.json()
|
| 15 |
+
assert "hostname" in data
|
| 16 |
+
assert "pod_ip" in data
|
| 17 |
+
# hostname should match the real system hostname
|
| 18 |
+
assert data["hostname"] == socket.gethostname()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.asyncio
|
| 22 |
+
async def test_whoami_uses_pod_ip_env_var(client: AsyncClient, monkeypatch) -> None:
|
| 23 |
+
"""/whoami should use the POD_IP env var when set (Downward API)."""
|
| 24 |
+
monkeypatch.setenv("POD_IP", "10.42.0.99")
|
| 25 |
+
response = await client.get("/whoami")
|
| 26 |
+
assert response.status_code == 200
|
| 27 |
+
assert response.json()["pod_ip"] == "10.42.0.99"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.mark.asyncio
|
| 31 |
+
async def test_delay_responds_after_sleep(client: AsyncClient) -> None:
|
| 32 |
+
"""/delay/1 should return a success message (tests that it doesn't error)."""
|
| 33 |
+
# We patch asyncio.sleep to avoid actually waiting
|
| 34 |
+
with patch("k3s_test_app.routers.network.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
| 35 |
+
response = await client.get("/delay/3")
|
| 36 |
+
mock_sleep.assert_awaited_once_with(3)
|
| 37 |
+
assert response.status_code == 200
|
| 38 |
+
assert response.json() == {"message": "Delayed for 3 seconds."}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.mark.asyncio
|
| 42 |
+
async def test_call_internal_returns_502_when_dns_fails(client: AsyncClient) -> None:
|
| 43 |
+
"""/call-internal should return 502 when the internal service is unreachable."""
|
| 44 |
+
# In a local test environment, the cluster DNS won't resolve — this is expected
|
| 45 |
+
with patch("k3s_test_app.routers.network.httpx.AsyncClient") as mock_http:
|
| 46 |
+
mock_instance = AsyncMock()
|
| 47 |
+
mock_http.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
|
| 48 |
+
mock_http.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 49 |
+
mock_instance.get.side_effect = httpx.ConnectError("Name or service not known")
|
| 50 |
+
|
| 51 |
+
response = await client.get("/call-internal")
|
| 52 |
+
assert response.status_code == 502
|
| 53 |
+
assert "Failed to communicate with internal service" in response.json()["detail"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.mark.asyncio
|
| 57 |
+
async def test_call_internal_returns_success_when_service_responds(client: AsyncClient) -> None:
|
| 58 |
+
"""/call-internal should return 200 with target_response when the service is up."""
|
| 59 |
+
mock_response = MagicMock()
|
| 60 |
+
mock_response.raise_for_status = MagicMock()
|
| 61 |
+
mock_response.json.return_value = {"status": "alive"}
|
| 62 |
+
|
| 63 |
+
with patch("k3s_test_app.routers.network.httpx.AsyncClient") as mock_http:
|
| 64 |
+
mock_instance = AsyncMock()
|
| 65 |
+
mock_http.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
|
| 66 |
+
mock_http.return_value.__aexit__ = AsyncMock(return_value=False)
|
| 67 |
+
mock_instance.get.return_value = mock_response
|
| 68 |
+
|
| 69 |
+
response = await client.get("/call-internal")
|
| 70 |
+
assert response.status_code == 200
|
| 71 |
+
data = response.json()
|
| 72 |
+
assert data["status"] == "success"
|
| 73 |
+
assert data["target_response"] == {"status": "alive"}
|
| 74 |
+
assert "k3s-test-app.default.svc.cluster.local" in data["target_url"]
|
projects/k3s-test-app/tests/test_phase4_storage.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 4: storage endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
import shutil
|
| 6 |
+
from httpx import AsyncClient
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def temp_storage_dir(monkeypatch):
|
| 11 |
+
"""Fixture to mock settings.storage_path with a temporary directory."""
|
| 12 |
+
temp_dir = tempfile.mkdtemp()
|
| 13 |
+
from k3s_test_app.core import config as cfg_module
|
| 14 |
+
monkeypatch.setattr(cfg_module.settings, "storage_path", temp_dir)
|
| 15 |
+
yield temp_dir
|
| 16 |
+
shutil.rmtree(temp_dir)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.mark.asyncio
|
| 20 |
+
async def test_read_storage_not_found(client: AsyncClient, temp_storage_dir: str) -> None:
|
| 21 |
+
"""GET /storage should return 404 if nothing has been written yet."""
|
| 22 |
+
response = await client.get("/storage")
|
| 23 |
+
assert response.status_code == 404
|
| 24 |
+
assert "not found" in response.json()["detail"].lower()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.mark.asyncio
|
| 28 |
+
async def test_write_and_read_storage(client: AsyncClient, temp_storage_dir: str) -> None:
|
| 29 |
+
"""POST /storage should write data, and GET /storage should read it back."""
|
| 30 |
+
# Write data
|
| 31 |
+
post_response = await client.post("/storage", json={"content": "hello storage"})
|
| 32 |
+
assert post_response.status_code == 200
|
| 33 |
+
assert post_response.json()["status"] == "success"
|
| 34 |
+
|
| 35 |
+
# Read data
|
| 36 |
+
get_response = await client.get("/storage")
|
| 37 |
+
assert get_response.status_code == 200
|
| 38 |
+
assert get_response.json()["content"] == "hello storage"
|
| 39 |
+
assert get_response.json()["file"].startswith(temp_storage_dir)
|
projects/k3s-test-app/tests/test_phase5_resilience.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 5: load and resilience endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
from httpx import AsyncClient
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def mock_time_gen():
|
| 8 |
+
t = 0
|
| 9 |
+
while True:
|
| 10 |
+
yield t
|
| 11 |
+
t += 10
|
| 12 |
+
|
| 13 |
+
@pytest.mark.asyncio
|
| 14 |
+
async def test_stress_cpu(client: AsyncClient) -> None:
|
| 15 |
+
"""GET /stress/cpu should return 200 (mocking the time.time() to finish instantly)."""
|
| 16 |
+
with patch("k3s_test_app.routers.stress.time.time", side_effect=mock_time_gen()):
|
| 17 |
+
response = await client.get("/stress/cpu?seconds=5")
|
| 18 |
+
assert response.status_code == 200
|
| 19 |
+
assert "Completed CPU stress" in response.json()["message"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_stress_mem(client: AsyncClient) -> None:
|
| 24 |
+
"""GET /stress/mem should return 200."""
|
| 25 |
+
with patch("k3s_test_app.routers.stress.time.sleep"):
|
| 26 |
+
response = await client.get("/stress/mem?mb=1")
|
| 27 |
+
assert response.status_code == 200
|
| 28 |
+
assert "allocated and freed" in response.json()["message"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.mark.asyncio
|
| 32 |
+
async def test_crash(client: AsyncClient) -> None:
|
| 33 |
+
"""GET /crash should call os._exit(1)."""
|
| 34 |
+
class IntentionalCrash(Exception): pass
|
| 35 |
+
|
| 36 |
+
with patch("k3s_test_app.routers.lifecycle.os._exit", side_effect=IntentionalCrash) as mock_exit:
|
| 37 |
+
try:
|
| 38 |
+
await client.get("/crash")
|
| 39 |
+
except IntentionalCrash:
|
| 40 |
+
pass # Expected
|
| 41 |
+
mock_exit.assert_called_once_with(1)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_sleep_block(client: AsyncClient) -> None:
|
| 46 |
+
"""GET /sleep should call time.sleep and block the thread."""
|
| 47 |
+
with patch("k3s_test_app.routers.lifecycle.time.sleep") as mock_sleep:
|
| 48 |
+
response = await client.get("/sleep?seconds=2")
|
| 49 |
+
mock_sleep.assert_called_once_with(2)
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
assert response.json()["message"] == "Awoke from sleep."
|
projects/k3s-test-app/tests/test_phase6_realtime_upload.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 6: realtime and upload endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
from httpx import AsyncClient
|
| 4 |
+
import io
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@pytest.mark.asyncio
|
| 8 |
+
async def test_websocket_echo() -> None:
|
| 9 |
+
"""Test the WebSocket /ws endpoint."""
|
| 10 |
+
# We use a synchronous TestClient from fastapi.testclient for websockets
|
| 11 |
+
# because httpx AsyncClient doesn't have built-in WS support yet
|
| 12 |
+
from fastapi.testclient import TestClient
|
| 13 |
+
from k3s_test_app.main import app
|
| 14 |
+
|
| 15 |
+
client = TestClient(app)
|
| 16 |
+
with client.websocket_connect("/ws") as websocket:
|
| 17 |
+
websocket.send_text("Hello k3s")
|
| 18 |
+
data = websocket.receive_text()
|
| 19 |
+
assert data == "Echo: Hello k3s"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
async def test_upload_file(client: AsyncClient) -> None:
|
| 24 |
+
"""POST /upload should accept a file and return its size."""
|
| 25 |
+
file_content = b"This is a test file for upload."
|
| 26 |
+
files = {"file": ("test.txt", io.BytesIO(file_content), "text/plain")}
|
| 27 |
+
|
| 28 |
+
response = await client.post("/upload", files=files)
|
| 29 |
+
assert response.status_code == 200
|
| 30 |
+
|
| 31 |
+
data = response.json()
|
| 32 |
+
assert data["filename"] == "test.txt"
|
| 33 |
+
assert data["size_bytes"] == len(file_content)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.asyncio
|
| 37 |
+
async def test_upload_file_exceeds_limit(client: AsyncClient) -> None:
|
| 38 |
+
"""POST /upload should return 413 if the file exceeds the 10MB limit."""
|
| 39 |
+
# We can create a fake file-like object that returns data to simulate a large upload
|
| 40 |
+
# without allocating 10MB+ in memory all at once.
|
| 41 |
+
class LargeMockFile:
|
| 42 |
+
def __init__(self, size):
|
| 43 |
+
self.remaining = size
|
| 44 |
+
def read(self, size=-1):
|
| 45 |
+
if self.remaining <= 0:
|
| 46 |
+
return b""
|
| 47 |
+
chunk_size = size if size > 0 else self.remaining
|
| 48 |
+
chunk_size = min(chunk_size, self.remaining)
|
| 49 |
+
self.remaining -= chunk_size
|
| 50 |
+
return b"0" * chunk_size
|
| 51 |
+
|
| 52 |
+
files = {"file": ("huge.txt", LargeMockFile(11 * 1024 * 1024), "text/plain")}
|
| 53 |
+
|
| 54 |
+
response = await client.post("/upload", files=files)
|
| 55 |
+
assert response.status_code == 413
|
| 56 |
+
assert "exceeds" in response.json()["detail"].lower()
|
projects/k3s-test-app/tests/test_phase7_observability.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Phase 7: observability endpoints."""
|
| 2 |
+
import pytest
|
| 3 |
+
from httpx import AsyncClient
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.mark.asyncio
|
| 7 |
+
async def test_metrics_endpoint_returns_prometheus_data(client: AsyncClient) -> None:
|
| 8 |
+
"""GET /metrics should return Prometheus metrics in text format."""
|
| 9 |
+
response = await client.get("/metrics")
|
| 10 |
+
assert response.status_code == 200
|
| 11 |
+
|
| 12 |
+
# Prometheus metrics are plain text
|
| 13 |
+
content = response.text
|
| 14 |
+
assert "TYPE" in content or "HELP" in content
|
| 15 |
+
assert "fastapi_" in content or "python_" in content or "process_" in content
|
projects/k3s-test-app/uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|