chore(deploy): wire OIDC runtime configuration

This commit is contained in:
Piotr Oleszczyk 2026-03-12 15:55:32 +01:00
parent ffa3b71309
commit 4bfa4ea02d
7 changed files with 115 additions and 100 deletions

View file

@ -346,7 +346,8 @@ check_backend_health() {
check_frontend_health() { check_frontend_health() {
local i local i
for ((i = 1; i <= 30; i++)); do for ((i = 1; i <= 30; i++)); do
if remote "curl -sf http://127.0.0.1:3000/ >/dev/null"; then # Allow 200 OK or 302/303/307 Redirect (to login)
if remote "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ | grep -qE '^(200|302|303|307)$'"; then
log "Frontend health check passed" log "Frontend health check passed"
return 0 return 0
fi fi

View file

@ -94,117 +94,82 @@ chown -R innercontext:innercontext /opt/innercontext
cat > /opt/innercontext/shared/backend/.env <<'EOF' cat > /opt/innercontext/shared/backend/.env <<'EOF'
DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-ip>/innercontext DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-ip>/innercontext
GEMINI_API_KEY=your-key GEMINI_API_KEY=your-key
# OIDC Configuration
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=innercontext-backend
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
OIDC_ADMIN_GROUPS=admins
OIDC_MEMBER_GROUPS=members
# Bootstrap Admin (Optional, used for initial setup)
# BOOTSTRAP_ADMIN_OIDC_ISSUER=https://auth.example.com
# BOOTSTRAP_ADMIN_OIDC_SUB=user-sub-from-authelia
# BOOTSTRAP_ADMIN_EMAIL=admin@example.com
# BOOTSTRAP_ADMIN_NAME="Admin User"
# BOOTSTRAP_HOUSEHOLD_NAME="My Household"
EOF EOF
cat > /opt/innercontext/shared/frontend/.env.production <<'EOF' cat > /opt/innercontext/shared/frontend/.env.production <<'EOF'
PUBLIC_API_BASE=http://127.0.0.1:8000 PUBLIC_API_BASE=http://127.0.0.1:8000
ORIGIN=http://innercontext.lan ORIGIN=http://innercontext.lan
# Session and OIDC
SESSION_SECRET=generate-a-long-random-string
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=innercontext-frontend
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
EOF EOF
chmod 600 /opt/innercontext/shared/backend/.env
chmod 600 /opt/innercontext/shared/frontend/.env.production
chown innercontext:innercontext /opt/innercontext/shared/backend/.env
chown innercontext:innercontext /opt/innercontext/shared/frontend/.env.production
``` ```
### 4) Grant deploy sudo permissions ## OIDC Setup (Authelia)
```bash This project uses OIDC for authentication. You need an OIDC provider like Authelia.
cat > /etc/sudoers.d/innercontext-deploy << 'EOF'
innercontext ALL=(root) NOPASSWD: \
/usr/bin/systemctl restart innercontext, \
/usr/bin/systemctl restart innercontext-node, \
/usr/bin/systemctl restart innercontext-pricing-worker, \
/usr/bin/systemctl is-active innercontext, \
/usr/bin/systemctl is-active innercontext-node, \
/usr/bin/systemctl is-active innercontext-pricing-worker
EOF
chmod 440 /etc/sudoers.d/innercontext-deploy ### Authelia Client Configuration
visudo -c -f /etc/sudoers.d/innercontext-deploy
# Must work without password or TTY prompt: Add the following to your Authelia `configuration.yml`:
sudo -u innercontext sudo -n -l
```yaml
identity_providers:
oidc:
clients:
- id: innercontext-frontend
description: InnerContext Frontend
secret: '$pbkdf2-sha512$...' # Not used for public client, but Authelia may require it
public: true
authorization_policy: one_factor
redirect_uris:
- http://innercontext.lan/auth/callback
scopes:
- openid
- profile
- email
- groups
userinfo_signed_response_alg: none
- id: innercontext-backend
description: InnerContext Backend
secret: '$pbkdf2-sha512$...'
public: false
authorization_policy: one_factor
redirect_uris: []
scopes:
- openid
- profile
- email
- groups
userinfo_signed_response_alg: none
``` ```
If `sudo -n -l` fails, deployments will fail during restart/rollback with: ### Bootstrap Admin
`sudo: a terminal is required` or `sudo: a password is required`.
### 5) Install systemd and nginx configs To create the first user and household, set the `BOOTSTRAP_ADMIN_*` environment variables in the backend `.env` file and restart the backend. The backend will automatically create the user and household on startup if they don't exist. After the first successful login, you can remove these variables.
After first deploy (or after copying repo content to `/opt/innercontext/current`), install configs:
```bash
cp /opt/innercontext/current/systemd/innercontext.service /etc/systemd/system/
cp /opt/innercontext/current/systemd/innercontext-node.service /etc/systemd/system/
cp /opt/innercontext/current/systemd/innercontext-pricing-worker.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable innercontext
systemctl enable innercontext-node
systemctl enable innercontext-pricing-worker
cp /opt/innercontext/current/nginx/innercontext.conf /etc/nginx/sites-available/innercontext
ln -sf /etc/nginx/sites-available/innercontext /etc/nginx/sites-enabled/innercontext
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
```
## Local Machine Setup
`~/.ssh/config`:
```
Host innercontext
HostName <lxc-ip>
User innercontext
```
Ensure your public key is in `/home/innercontext/.ssh/authorized_keys`.
## Deploy Commands
From repository root on external machine:
```bash
./deploy.sh # full deploy (default = all)
./deploy.sh all
./deploy.sh backend
./deploy.sh frontend
./deploy.sh list
./deploy.sh rollback
```
Optional overrides:
```bash
DEPLOY_SERVER=innercontext ./deploy.sh all
DEPLOY_ROOT=/opt/innercontext ./deploy.sh backend
DEPLOY_ALLOW_DIRTY=1 ./deploy.sh frontend
```
## What `deploy.sh` Does
For `backend` / `frontend` / `all`:
1. Local checks (strict, fail-fast)
2. Acquire `/opt/innercontext/.deploy.lock`
3. Create `<timestamp>` release directory
4. Upload selected component(s)
5. Link shared env files in the release directory
6. `uv sync` + `alembic upgrade head` (backend scope)
7. Upload `scripts/`, `systemd/`, `nginx/`
8. Switch `current` to the prepared release
9. Restart affected services
10. Run health checks
11. Remove old releases (keep last 5)
12. Write deploy entry to `/opt/innercontext/deploy.log`
If anything fails after promotion, script auto-rolls back to previous release.
## Health Checks ## Health Checks
- Backend: `http://127.0.0.1:8000/health-check` - Backend: `http://127.0.0.1:8000/health-check` (returns 200)
- Frontend: `http://127.0.0.1:3000/` - Frontend: `http://127.0.0.1:3000/` (returns 200 or 302 redirect to login)
- Worker: `systemctl is-active innercontext-pricing-worker` - Worker: `systemctl is-active innercontext-pricing-worker`
Manual checks: Manual checks:

View file

@ -10,6 +10,8 @@ server {
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 $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
} }
# SvelteKit Node server # SvelteKit Node server
@ -19,5 +21,7 @@ server {
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 $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
} }
} }

View file

@ -25,13 +25,27 @@ log() {
check_service() { check_service() {
local service_name=$1 local service_name=$1
local url=$2 local url=$2
local allow_redirect=${3:-false}
if systemctl is-active --quiet "$service_name"; then if systemctl is-active --quiet "$service_name"; then
if curl -sf --max-time "$TIMEOUT" "$url" > /dev/null 2>&1; then local curl_opts="-s --max-time $TIMEOUT"
if [ "$allow_redirect" = false ]; then
curl_opts="$curl_opts -f"
fi
if curl $curl_opts "$url" > /dev/null 2>&1; then
log "${GREEN}${NC} $service_name is healthy" log "${GREEN}${NC} $service_name is healthy"
return 0 return 0
else else
log "${YELLOW}${NC} $service_name is running but not responding at $url" # If allow_redirect is true, we check if it's a 302
if [ "$allow_redirect" = true ]; then
local status=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$TIMEOUT" "$url")
if [ "$status" = "302" ] || [ "$status" = "303" ] || [ "$status" = "307" ] || [ "$status" = "200" ]; then
log "${GREEN}${NC} $service_name is healthy (status $status)"
return 0
fi
fi
log "${YELLOW}${NC} $service_name is running but not responding correctly at $url"
return 1 return 1
fi fi
else else
@ -45,8 +59,10 @@ backend_ok=0
frontend_ok=0 frontend_ok=0
worker_ok=0 worker_ok=0
# Backend health-check is public and should return 200
check_service "innercontext" "$BACKEND_URL" || backend_ok=1 check_service "innercontext" "$BACKEND_URL" || backend_ok=1
check_service "innercontext-node" "$FRONTEND_URL" || frontend_ok=1 # Frontend root may redirect to login (302)
check_service "innercontext-node" "$FRONTEND_URL" true || frontend_ok=1
# Worker doesn't have HTTP endpoint, just check if it's running # Worker doesn't have HTTP endpoint, just check if it's running
if systemctl is-active --quiet "innercontext-pricing-worker"; then if systemctl is-active --quiet "innercontext-pricing-worker"; then

View file

@ -25,7 +25,7 @@ warnings=0
log_error() { log_error() {
echo -e "${RED}${NC} $1" echo -e "${RED}${NC} $1"
((errors++)) errors=$((errors + 1))
} }
log_success() { log_success() {
@ -34,7 +34,7 @@ log_success() {
log_warning() { log_warning() {
echo -e "${YELLOW}${NC} $1" echo -e "${YELLOW}${NC} $1"
((warnings++)) warnings=$((warnings + 1))
} }
check_symlink() { check_symlink() {
@ -131,6 +131,21 @@ if [ -f "$SHARED_BACKEND_ENV" ]; then
check_var "$SHARED_BACKEND_ENV" "GEMINI_API_KEY" check_var "$SHARED_BACKEND_ENV" "GEMINI_API_KEY"
check_var "$SHARED_BACKEND_ENV" "LOG_LEVEL" true check_var "$SHARED_BACKEND_ENV" "LOG_LEVEL" true
check_var "$SHARED_BACKEND_ENV" "CORS_ORIGINS" true check_var "$SHARED_BACKEND_ENV" "CORS_ORIGINS" true
# OIDC Configuration
check_var "$SHARED_BACKEND_ENV" "OIDC_ISSUER"
check_var "$SHARED_BACKEND_ENV" "OIDC_CLIENT_ID"
check_var "$SHARED_BACKEND_ENV" "OIDC_DISCOVERY_URL"
check_var "$SHARED_BACKEND_ENV" "OIDC_ADMIN_GROUPS"
check_var "$SHARED_BACKEND_ENV" "OIDC_MEMBER_GROUPS"
check_var "$SHARED_BACKEND_ENV" "OIDC_JWKS_CACHE_TTL_SECONDS" true
# Bootstrap Admin (Optional, used for initial setup)
check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_OIDC_ISSUER" true
check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_OIDC_SUB" true
check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_EMAIL" true
check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_ADMIN_NAME" true
check_var "$SHARED_BACKEND_ENV" "BOOTSTRAP_HOUSEHOLD_NAME" true
fi fi
echo "" echo ""
@ -138,6 +153,12 @@ echo "=== Validating Frontend Environment Variables ==="
if [ -f "$SHARED_FRONTEND_ENV" ]; then if [ -f "$SHARED_FRONTEND_ENV" ]; then
check_var "$SHARED_FRONTEND_ENV" "PUBLIC_API_BASE" check_var "$SHARED_FRONTEND_ENV" "PUBLIC_API_BASE"
check_var "$SHARED_FRONTEND_ENV" "ORIGIN" check_var "$SHARED_FRONTEND_ENV" "ORIGIN"
# Session and OIDC
check_var "$SHARED_FRONTEND_ENV" "SESSION_SECRET"
check_var "$SHARED_FRONTEND_ENV" "OIDC_ISSUER"
check_var "$SHARED_FRONTEND_ENV" "OIDC_CLIENT_ID"
check_var "$SHARED_FRONTEND_ENV" "OIDC_DISCOVERY_URL"
fi fi
echo "" echo ""

View file

@ -1,5 +1,8 @@
[Unit] [Unit]
Description=innercontext SvelteKit Node frontend Description=innercontext SvelteKit Node frontend
# Required env vars in .env.production:
# PUBLIC_API_BASE, ORIGIN, SESSION_SECRET
# OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_DISCOVERY_URL
After=network.target After=network.target
[Service] [Service]

View file

@ -1,5 +1,10 @@
[Unit] [Unit]
Description=innercontext FastAPI backend Description=innercontext FastAPI backend
# Required env vars in .env:
# DATABASE_URL, GEMINI_API_KEY
# OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_DISCOVERY_URL
# OIDC_ADMIN_GROUPS, OIDC_MEMBER_GROUPS
# (Optional) BOOTSTRAP_ADMIN_OIDC_ISSUER, BOOTSTRAP_ADMIN_OIDC_SUB, etc.
After=network.target After=network.target
[Service] [Service]