Compare commits

...

10 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
7d811fede0 Added SETUP.md
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m46s
CI / Docker Build (push) Has been skipped
2025-09-23 19:08:05 +01:00
f8776911dc Appended .env.example
Some checks failed
CI / Lint & Format (push) Successful in 4s
CI / Tests (Pytest + Alembic + Postgres) (push) Failing after 4m55s
CI / Docker Build (push) Has been skipped
2025-09-23 18:57:26 +01:00
16 changed files with 187 additions and 61 deletions

View File

@@ -1,10 +1,24 @@
DOCKERDIR=/opt/compose/keywarden
KEYWARDEN_SECRET_KEY=
# PostgreSQL Connection (These are default values, unneeded if matching environment)
## Local Auth
KEYWARDEN_SECRET_KEY=<!GENERATE SECRET HERE!>
KEYWARDEN_ALLOW_LOCAL_LOGIN=true
KEYWARDEN_ACCESS_TOKEN_EXPIRE_MINUTES=60
## Optional OIDC
# KEYWARDEN_OIDC_ENABLED=true
# KEYWARDEN_OIDC_ISSUER=https://auth.example.com/application/o/<slug>
# KEYWARDEN_OIDC_CLIENT_ID=keywarden
# KEYWARDEN_OIDC_AUDIENCE=keywarden-api
# KEYWARDEN_OIDC_JWKS_URL=https://auth.example.com/application/o/<slug>/jwks
## Policy toggles
# KEYWARDEN_REQUIRE_SSO=false # if true, local login is disabled
# KEYWARDEN_AUTO_PROVISION_OIDC=true # JIT user creation
## Postgres
KEYWARDEN_POSTGRES_USER="postgres"
KEYWARDEN_POSTGRES_PASSWORD="postgres"
KEYWARDEN_POSTGRES_HOST="keywarden-db"
KEYWARDEN_POSTGRES_PORT=5432
KEYWARDEN_POSTGRES_DB="keywarden"
KEYWARDEN_ACCESS_TOKEN_EXPIRE_MINUTES=60
KEYWARDEN_POSTGRES_DB="keywarden"

View File

@@ -11,6 +11,7 @@ permissions:
env:
PYTHON_VERSION: "3.11"
IMAGE_NAME: keywarden-api
# Used by tests / alembic; matches docker compose environment
KEYWARDEN_POSTGRES_USER: postgres
KEYWARDEN_POSTGRES_PASSWORD: postgres
@@ -71,31 +72,69 @@ jobs:
uses: actions/cache@v4
with:
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: |
pip-${{ runner.os }}-${{ env.PYTHON_VERSION }}-
- name: Install dependencies
- name: Install deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# optional: extras/dev
if [ -f pyproject.toml ]; then pip install -e .[dev] || pip install -e . ; fi
- name: Set PYTHONPATH
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: |
printf "KEYWARDEN_POSTGRES_DSN=%s\nKEYWARDEN_SECRET_KEY=%s\n" \
"${{ env.TEST_POSTGRES_DSN }}" "testsecret" > .env
if [ "${ACT:-}" = "true" ]; then
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
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
- name: Pytest
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: |
pytest -q tests
docker-build:
@@ -106,15 +145,47 @@ jobs:
- name: Checkout
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
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
with:
context: .
push: false
tags: keywarden:ci
# speeds up builds by caching layers on GH Actions
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: |
${{ env.GT_IMAGE }}:${{ github.ref_name }}
${{ env.GT_IMAGE }}:sha-${{ github.sha }}
${{ env.GT_IMAGE }}:latest

16
SETUP.md Normal file
View File

@@ -0,0 +1,16 @@
# NGINX - External Proxy
For setups behind an external reverse proxy (heavily recommended), using a local CA and self-signed certificates is not required, but also recommended.
After installing `mkcert` through your system package manager:
```bash
mkcert -install
mkcert abc.domain.xyz, bcd.domain.xyz
mv domain.xyz+X-key.pem nginx/certs/key.pem
mv domain.xyz.pem nginx/certs/certificate.pem
```
NGINX will find these certificates automatically and use them when proxying the application. Unless you know what you are doing, editing files under `nginx/configs/` is not recommended.
If preferred, NGINX can be used as a reverse proxy, however an additional `certbot/certbot:latest` container would be required unless other valid SSL certificates are provided under `nginx/certs`.

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)
DB_USER = os.getenv("KEYWARDEN_POSTGRES_USER", "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_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.audit import AuditEvent # noqa: F401
from app.models.server import Server # noqa: F401
from app.models.sshkey import SSHKey # 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 sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from starlette.responses import JSONResponse
from app.api.v1 import auth, keys
from app.core.config import settings
from app.db.session import AsyncSessionLocal
app = FastAPI(
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(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")
def healthz():
return {"ok": True}
async def healthz():
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.orm import Mapped, mapped_column
from app.models.user import Base
from app.db.base_class import Base
class AccessRequest(Base):

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
from datetime import datetime, timezone # noqa: F401
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):

View File

@@ -1,10 +1,8 @@
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.session import engine # noqa: F401
from app.db.base_class import Base
Base = declarative_base()
class User(Base):
__tablename__ = "users"

View File

@@ -43,6 +43,11 @@ services:
# ports:
# - "8000: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:
pgdata:

View File

@@ -20,22 +20,7 @@ http {
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
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;
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf;
types_hash_bucket_size 128;

View File

@@ -24,20 +24,25 @@ server {
location / {
}
location /docs {
proxy_pass http://api:8000;
}
location /openapi.json {
proxy_pass http://api:8000;
# NOT FOR PROD vvv
location ~ ^/(docs|openapi.json)$ {
proxy_pass http://keywarden-api:8000;
}
## REMOVE IN PRODUCTION BUILDS ^^^
location /api/v1/ {
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 $http_x_forwarded_proto;
}
location /healthz {
proxy_pass http://api:8000;
location ~ ^/(healthz|readyz|livez)$ {
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():
client = TestClient(app)
r = client.get("/healthz")
r = client.get("/readyz")
assert r.status_code == 200
assert r.json() == {"ok": True}
assert r.json() == {"status": "ok", "db": "up"}