Compare commits
8 Commits
7d811fede0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e10b51c8e3 | |||
| 48c5731a8a | |||
| 5e79768394 | |||
| ef947d9888 | |||
| 7054ba7547 | |||
| 3a49d82d04 | |||
| c93f0ccda6 | |||
| 3eaed88074 |
97
.github/workflows/ci.yml
vendored
97
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
13
app/db/base_class.py
Normal 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)
|
||||||
28
app/main.py
28
app/main.py
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -21,21 +21,6 @@ http {
|
|||||||
'"$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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user