Compare commits

...

8 Commits

Author SHA1 Message Date
e10b51c8e3 Attempt to automate registry images
All checks were successful
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m25s
CI / Docker Build (push) Successful in 10m28s
2025-09-30 13:27:35 +01:00
48c5731a8a Updated CI.yml
All checks were successful
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m25s
CI / Docker Build (push) Successful in 1m12s
2025-09-30 13:15:40 +01:00
5e79768394 Updated CI.yml
Some checks failed
CI / Lint & Format (push) Successful in 3s
CI / Tests (Pytest + Alembic + Postgres) (push) Successful in 9m31s
CI / Docker Build (push) Failing after 3m14s
2025-09-30 12:48:39 +01:00
ef947d9888 Migrated CI health test to new JSON. Refactored NGINX config
Some checks failed
CI / Lint & Format (push) Successful in 3s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m44s
CI / Docker Build (push) Has been skipped
2025-09-30 12:39:49 +01:00
7054ba7547 Fixed NGINX Config for new z endpoints
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m45s
CI / Docker Build (push) Has been skipped
2025-09-30 12:24:51 +01:00
3a49d82d04 Added healthcheck, Added readyz, livez endpoints
Some checks failed
CI / Lint & Format (push) Successful in 6s
CI / Docker Build (push) Has been cancelled
CI / Tests (Pytest + Alembic + Postgres) (push) Has been cancelled
2025-09-30 12:20:11 +01:00
c93f0ccda6 Standardised Bases across models
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m45s
CI / Docker Build (push) Has been skipped
2025-09-23 19:38:54 +01:00
3eaed88074 Moved models to shared base 2025-09-23 19:27:08 +01:00
14 changed files with 153 additions and 57 deletions

View File

@@ -11,6 +11,7 @@ permissions:
env: env:
PYTHON_VERSION: "3.11" PYTHON_VERSION: "3.11"
IMAGE_NAME: keywarden-api
# Used by tests / alembic; matches docker compose environment # Used by tests / alembic; matches docker compose environment
KEYWARDEN_POSTGRES_USER: postgres KEYWARDEN_POSTGRES_USER: postgres
KEYWARDEN_POSTGRES_PASSWORD: postgres KEYWARDEN_POSTGRES_PASSWORD: postgres
@@ -71,31 +72,69 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/requirements.txt') }} key: pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('**/requirements*.txt', 'pyproject.toml') }}
restore-keys: | restore-keys: |
pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}- pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-
- name: Install dependencies - name: Install deps
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
# optional: extras/dev
if [ -f pyproject.toml ]; then pip install -e .[dev] || pip install -e . ; fi
- name: Set PYTHONPATH - name: Set PYTHONPATH
run: echo "PYTHONPATH=${GITHUB_WORKSPACE}" >> $GITHUB_ENV run: echo "PYTHONPATH=${GITHUB_WORKSPACE}" >> $GITHUB_ENV
- name: Create .env for tests (optional for app runtime) # Gitea ACT Runner needs this for some reason.
- name: Select Postgres host for runner
run: | run: |
printf "KEYWARDEN_POSTGRES_DSN=%s\nKEYWARDEN_SECRET_KEY=%s\n" \ if [ "${ACT:-}" = "true" ]; then
"${{ env.TEST_POSTGRES_DSN }}" "testsecret" > .env echo "KEYWARDEN_POSTGRES_HOST=postgres" >> "$GITHUB_ENV" # Gitea (act_runner)
else
echo "KEYWARDEN_POSTGRES_HOST=127.0.0.1" >> "$GITHUB_ENV" # GitHub Actions
fi
- name: Echo DB target
run: echo "DB → ${KEYWARDEN_POSTGRES_HOST:-unset}:${{ env.KEYWARDEN_POSTGRES_PORT }}"
# Explicit wait (removes race on act_runner)
- name: Wait for Postgres
run: |
for i in {1..60}; do
python - <<'PY'
import os, socket, sys
h=os.environ.get("KEYWARDEN_POSTGRES_HOST","127.0.0.1"); p=int(os.environ.get("KEYWARDEN_POSTGRES_PORT","5432"))
s=socket.socket(); s.settimeout(1)
try:
s.connect((h,p)); print("Postgres is up:", h,p); sys.exit(0)
except Exception as e:
print("waiting:", e); sys.exit(1)
finally:
s.close()
PY
if [ $? -eq 0 ]; then break; fi
sleep 2
done
- name: Run Alembic migrations - name: Run Alembic migrations
env: env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }} KEYWARDEN_POSTGRES_USER: ${{ env.KEYWARDEN_POSTGRES_USER }}
KEYWARDEN_POSTGRES_PASSWORD: ${{ env.KEYWARDEN_POSTGRES_PASSWORD }}
KEYWARDEN_POSTGRES_HOST: ${{ env.KEYWARDEN_POSTGRES_HOST }}
KEYWARDEN_POSTGRES_PORT: ${{ env.KEYWARDEN_POSTGRES_PORT }}
KEYWARDEN_POSTGRES_DB: ${{ env.KEYWARDEN_POSTGRES_DB }}
KEYWARDEN_POSTGRES_SSL: ${{ env.KEYWARDEN_POSTGRES_SSL }}
run: alembic upgrade head run: alembic upgrade head
- name: Pytest - name: Pytest
env: env:
KEYWARDEN_POSTGRES_DSN: ${{ env.TEST_POSTGRES_DSN }} KEYWARDEN_POSTGRES_USER: ${{ env.KEYWARDEN_POSTGRES_USER }}
KEYWARDEN_POSTGRES_PASSWORD: ${{ env.KEYWARDEN_POSTGRES_PASSWORD }}
KEYWARDEN_POSTGRES_HOST: ${{ env.KEYWARDEN_POSTGRES_HOST }}
KEYWARDEN_POSTGRES_PORT: ${{ env.KEYWARDEN_POSTGRES_PORT }}
KEYWARDEN_POSTGRES_DB: ${{ env.KEYWARDEN_POSTGRES_DB }}
KEYWARDEN_POSTGRES_SSL: ${{ env.KEYWARDEN_POSTGRES_SSL }}
run: | run: |
pytest -q tests pytest -q tests
docker-build: docker-build:
@@ -106,15 +145,47 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Choose Buildx cache backend: gha on GitHub, local on act_runner
- name: Select Buildx cache backend
run: |
if [ "${ACT:-}" = "true" ]; then
echo "CACHE_TO=type=local,dest=/tmp/.buildx-cache,mode=max" >> $GITHUB_ENV
echo "CACHE_FROM=type=local,src=/tmp/.buildx-cache" >> $GITHUB_ENV
else
echo "CACHE_TO=type=gha,mode=max" >> $GITHUB_ENV
echo "CACHE_FROM=type=gha" >> $GITHUB_ENV
fi
- name: Prepare local cache dir (act_runner only)
if: ${{ env.ACT == 'true' }}
run: mkdir -p /tmp/.buildx-cache
- name: Set image reference (Gitea)
run: |
echo "GT_IMAGE=${{ secrets.GITEA_REGISTRY }}/${{ secrets.GITEA_NAMESPACE }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: Set up QEMU (optional)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build image (no push) - name: Set image reference
run: echo "GT_IMAGE=${{ secrets.REGISTRY_HOST }}/${{ secrets.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}" >> $GITHUB_ENV
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: false push: true
tags: keywarden:ci tags: |
# speeds up builds by caching layers on GH Actions ${{ env.GT_IMAGE }}:${{ github.ref_name }}
cache-from: type=gha ${{ env.GT_IMAGE }}:sha-${{ github.sha }}
cache-to: type=gha,mode=max ${{ env.GT_IMAGE }}:latest

View File

@@ -27,7 +27,7 @@ target_metadata = Base.metadata
# Get DB URL from env (prefer KEYWARDEN_ prefix, fall back to unprefixed, then a sane default for local) # Get DB URL from env (prefer KEYWARDEN_ prefix, fall back to unprefixed, then a sane default for local)
DB_USER = os.getenv("KEYWARDEN_POSTGRES_USER", "postgres") DB_USER = os.getenv("KEYWARDEN_POSTGRES_USER", "postgres")
DB_PASS = os.getenv("KEYWARDEN_POSTGRES_PASSWORD", "postgres") DB_PASS = os.getenv("KEYWARDEN_POSTGRES_PASSWORD", "postgres")
DB_HOST = os.getenv("KEYWARDEN_POSTGRES_HOST", "localhost") DB_HOST = os.getenv("KEYWARDEN_POSTGRES_HOST", "keywarden-db")
DB_PORT = os.getenv("KEYWARDEN_POSTGRES_PORT", "5432") DB_PORT = os.getenv("KEYWARDEN_POSTGRES_PORT", "5432")
DB_NAME = os.getenv("KEYWARDEN_POSTGRES_DB", "keywarden") DB_NAME = os.getenv("KEYWARDEN_POSTGRES_DB", "keywarden")

View File

@@ -1,9 +1,6 @@
from sqlalchemy.orm import declarative_base from app.db.base_class import Base # noqa: F401
from app.models.access_request import AccessRequest # noqa: F401 from app.models.access_request import AccessRequest # noqa: F401
from app.models.audit import AuditEvent # noqa: F401 from app.models.audit import AuditEvent # noqa: F401
from app.models.server import Server # noqa: F401 from app.models.server import Server # noqa: F401
from app.models.sshkey import SSHKey # noqa: F401 from app.models.sshkey import SSHKey # noqa: F401
from app.models.user import User # noqa: F401 from app.models.user import User # noqa: F401
Base = declarative_base()

13
app/db/base_class.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlalchemy import MetaData
from sqlalchemy.orm import declarative_base
# Optional: naming convention keeps Alembic diffs stable
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=convention)
Base = declarative_base(metadata=metadata)

View File

@@ -1,7 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from starlette.responses import JSONResponse
from app.api.v1 import auth, keys from app.api.v1 import auth, keys
from app.core.config import settings from app.core.config import settings
from app.db.session import AsyncSessionLocal
app = FastAPI( app = FastAPI(
title=settings.PROJECT_NAME title=settings.PROJECT_NAME
@@ -9,7 +13,25 @@ app = FastAPI(
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"]) app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
app.include_router(keys.router, prefix=f"{settings.API_V1_STR}/keys", tags=["keys"]) app.include_router(keys.router, prefix=f"{settings.API_V1_STR}/keys", tags=["keys"])
# Health endpoint (useful for docker, agent and uptime) # Is the API running?
@app.get("/livez")
async def livez():
return {"status": "ok"}
# Is the application ready (including db)?
@app.get("/readyz")
async def readyz():
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return {"status": "ok", "db": "up"}
except SQLAlchemyError:
return JSONResponse(
status_code=503,
content={"status": "degraded", "db": "down"},
)
# Is the application healthy (ready)?
@app.get("/healthz") @app.get("/healthz")
def healthz(): async def healthz():
return {"ok": True} return await readyz() # alias

View File

@@ -1,9 +1,9 @@
from datetime import datetime, timezone #noqa from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.user import Base from app.db.base_class import Base
class AccessRequest(Base): class AccessRequest(Base):

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.user import Base from app.db.base_class import Base
class AuditEvent(Base): class AuditEvent(Base):

View File

@@ -1,7 +1,7 @@
from sqlalchemy import JSON, Boolean, Integer, String from sqlalchemy import JSON, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.user import Base from app.db.base_class import Base
class Server(Base): class Server(Base):

View File

@@ -1,9 +1,9 @@
from datetime import datetime, timezone # noqa: F401 from datetime import datetime, timezone # noqa: F401
from sqlalchemy import Boolean, DateTime, ForeignKey, String from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship # noqa: F401 from sqlalchemy.orm import Mapped, mapped_column
from app.models.user import Base from app.db.base_class import Base
class SSHKey(Base): class SSHKey(Base):

View File

@@ -1,10 +1,8 @@
from sqlalchemy import Boolean, String from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, declarative_base, mapped_column from sqlalchemy.orm import Mapped, mapped_column
# only for Alembic discovery, not used here from app.db.base_class import Base
from app.db.session import engine # noqa: F401
Base = declarative_base()
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"

View File

@@ -43,6 +43,11 @@ services:
# ports: # ports:
# - "8000:8000" # - "8000:8000"
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 command: uvicorn app.main:app --host 0.0.0.0 --port 8000
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
pgdata: pgdata:

View File

@@ -20,22 +20,7 @@ http {
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
server_tokens off;
sendfile on;
tcp_nopush on;
keepalive_timeout 60;
tcp_nodelay on;
client_body_timeout 15;
gzip on;
gzip_vary on;
gzip_min_length 1k;
client_max_body_size 10G;
proxy_request_buffering off;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
types_hash_bucket_size 128; types_hash_bucket_size 128;

View File

@@ -24,20 +24,25 @@ server {
location / { location / {
} }
location /docs {
proxy_pass http://api:8000; # NOT FOR PROD vvv
} location ~ ^/(docs|openapi.json)$ {
location /openapi.json { proxy_pass http://keywarden-api:8000;
proxy_pass http://api:8000;
} }
## REMOVE IN PRODUCTION BUILDS ^^^
location /api/v1/ { location /api/v1/ {
proxy_pass http://api:8000; proxy_pass http://keywarden-api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
} }
location /healthz { location ~ ^/(healthz|readyz|livez)$ {
proxy_pass http://api:8000; proxy_pass http://keywarden-api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }

View File

@@ -8,6 +8,6 @@ from app.main import app
def test_healthz(): def test_healthz():
client = TestClient(app) client = TestClient(app)
r = client.get("/healthz") r = client.get("/readyz")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"ok": True} assert r.json() == {"status": "ok", "db": "up"}