Skip to content

Commit 348458e

Browse files
baijumclaude
andcommitted
feat: add structured logging, rate limiting, read-only filesystem, reusable workflows
- Structured JSON logging via python-json-logger with request middleware - Rate limiting via slowapi (60/min, /health exempt) - Read-only container filesystem with tmpfs for /tmp and __pycache__ - All workflows now call reusable workflows from towlion/.github - Dockerfile uses --no-access-log (structured middleware replaces it) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d1f62c commit 348458e

9 files changed

Lines changed: 88 additions & 303 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
11
name: CI
2+
23
on:
34
push:
45
pull_request:
56

67
jobs:
78
test:
8-
runs-on: ubuntu-latest
9-
steps:
10-
- uses: actions/checkout@v4
11-
- uses: actions/setup-python@v5
12-
with:
13-
python-version: "3.12"
14-
cache: 'pip'
15-
- name: Run tests
16-
run: |
17-
pip install -r requirements.txt
18-
pip install pytest
19-
if python -m pytest --collect-only -q >/dev/null 2>&1; then
20-
pytest --tb=short
21-
else
22-
echo "No tests found, skipping"
23-
fi
9+
uses: towlion/.github/.github/workflows/test-python.yml@main

.github/workflows/deploy.yml

Lines changed: 13 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -6,139 +6,20 @@ on:
66

77
jobs:
88
test:
9-
runs-on: ubuntu-latest
10-
steps:
11-
- uses: actions/checkout@v4
12-
13-
- uses: actions/setup-python@v5
14-
with:
15-
python-version: "3.12"
16-
cache: 'pip'
17-
18-
- name: Run tests
19-
run: |
20-
pip install -r requirements.txt
21-
pip install pytest
22-
if python -m pytest --collect-only -q >/dev/null 2>&1; then
23-
pytest --tb=short
24-
else
25-
echo "No tests found, skipping"
26-
fi
9+
uses: towlion/.github/.github/workflows/test-python.yml@main
2710

2811
deploy:
2912
needs: test
3013
if: github.repository != 'towlion/app-template'
31-
runs-on: ubuntu-latest
32-
steps:
33-
- uses: actions/checkout@v4
34-
35-
- name: Create deployment
36-
id: deployment
37-
uses: actions/github-script@v7
38-
with:
39-
script: |
40-
const deployment = await github.rest.repos.createDeployment({
41-
owner: context.repo.owner,
42-
repo: context.repo.repo,
43-
ref: context.sha,
44-
environment: 'production',
45-
auto_merge: false,
46-
required_contexts: [],
47-
description: `Deploy ${context.sha.substring(0, 7)} to production`
48-
});
49-
return deployment.data.id;
50-
51-
- name: Deploy to server
52-
uses: appleboy/ssh-action@v1
53-
with:
54-
host: ${{ secrets.SERVER_HOST }}
55-
username: ${{ secrets.SERVER_USER }}
56-
key: ${{ secrets.SERVER_SSH_KEY }}
57-
script: |
58-
APP_NAME="${{ github.event.repository.name }}"
59-
cd /opt/apps/${APP_NAME}
60-
git pull origin main
61-
62-
# Verify app .env exists
63-
if [ ! -f deploy/.env ]; then
64-
echo "ERROR: deploy/.env not found. Create it from deploy/env.template first."
65-
exit 1
66-
fi
67-
68-
# Create app-specific database if it doesn't exist
69-
APP_DB=$(echo "${APP_NAME}" | tr '-' '_')_db
70-
docker compose -f /opt/platform/docker-compose.yml exec -T postgres \
71-
psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '${APP_DB}'" | grep -q 1 \
72-
|| docker compose -f /opt/platform/docker-compose.yml exec -T postgres \
73-
psql -U postgres -c "CREATE DATABASE ${APP_DB}"
74-
75-
# Source per-app credentials and update deploy/.env if available
76-
CREDENTIALS_FILE="/opt/platform/credentials/${APP_NAME}.env"
77-
if [ -f "$CREDENTIALS_FILE" ]; then
78-
echo "Using per-app credentials from $CREDENTIALS_FILE"
79-
source "$CREDENTIALS_FILE"
80-
# Update DATABASE_URL with per-app user credentials
81-
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${APP_DB}|" deploy/.env
82-
# Update S3 credentials with per-app MinIO user
83-
sed -i "s|^S3_ACCESS_KEY=.*|S3_ACCESS_KEY=${S3_ACCESS_KEY}|" deploy/.env
84-
sed -i "s|^S3_SECRET_KEY=.*|S3_SECRET_KEY=${S3_SECRET_KEY}|" deploy/.env
85-
# Update S3 bucket name
86-
sed -i "s|^S3_BUCKET=.*|S3_BUCKET=${APP_NAME}-uploads|" deploy/.env
87-
echo "deploy/.env updated with per-app credentials"
88-
else
89-
echo "WARNING: Per-app credentials not found at $CREDENTIALS_FILE"
90-
echo "Run create-app-credentials.sh ${APP_NAME} for isolated credentials."
91-
echo "Falling back to existing deploy/.env credentials."
92-
fi
93-
94-
# Build and start app containers (project name = app name for predictable container names)
95-
docker compose -p ${APP_NAME} -f deploy/docker-compose.yml up -d --build
96-
97-
# Scan built image for vulnerabilities (non-blocking)
98-
APP_IMAGE=$(docker compose -p ${APP_NAME} -f deploy/docker-compose.yml images app --format "{{.Repository}}:{{.Tag}}" | head -1)
99-
if command -v trivy &>/dev/null; then
100-
echo "Scanning ${APP_IMAGE} for vulnerabilities..."
101-
trivy image --severity HIGH,CRITICAL --exit-code 0 --no-progress "${APP_IMAGE}" || true
102-
fi
103-
104-
# Run database migrations
105-
docker compose -p ${APP_NAME} -f deploy/docker-compose.yml exec -T app alembic -c app/alembic.ini upgrade head
106-
107-
# Health check
108-
bash scripts/health-check.sh https://${{ secrets.APP_DOMAIN }}/health
109-
110-
# Generate rendered Caddyfile for platform Caddy
111-
cat > /opt/platform/caddy-apps/${APP_NAME}.caddy <<CADDYEOF
112-
${{ secrets.APP_DOMAIN }} {
113-
import security_headers
114-
reverse_proxy ${APP_NAME}-app-1:8000
115-
}
116-
CADDYEOF
117-
docker compose -f /opt/platform/docker-compose.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile
118-
119-
- name: Update deployment status (success)
120-
if: success()
121-
uses: actions/github-script@v7
122-
with:
123-
script: |
124-
await github.rest.repos.createDeploymentStatus({
125-
owner: context.repo.owner,
126-
repo: context.repo.repo,
127-
deployment_id: ${{ steps.deployment.outputs.result }},
128-
state: 'success',
129-
environment_url: `https://${{ secrets.APP_DOMAIN }}`,
130-
description: 'Deployment succeeded'
131-
});
132-
133-
- name: Update deployment status (failure)
134-
if: failure()
135-
uses: actions/github-script@v7
136-
with:
137-
script: |
138-
await github.rest.repos.createDeploymentStatus({
139-
owner: context.repo.owner,
140-
repo: context.repo.repo,
141-
deployment_id: ${{ steps.deployment.outputs.result }},
142-
state: 'failure',
143-
description: 'Deployment failed'
144-
});
14+
uses: towlion/.github/.github/workflows/deploy.yml@main
15+
with:
16+
caddyfile-template: |
17+
__APP_DOMAIN__ {
18+
import security_headers
19+
reverse_proxy __APP_NAME__-app-1:8000
20+
}
21+
secrets:
22+
SERVER_HOST: ${{ secrets.SERVER_HOST }}
23+
SERVER_USER: ${{ secrets.SERVER_USER }}
24+
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
25+
APP_DOMAIN: ${{ secrets.APP_DOMAIN }}

.github/workflows/preview.yml

Lines changed: 14 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -5,137 +5,17 @@ on:
55
types: [opened, synchronize, reopened, closed]
66

77
jobs:
8-
deploy-preview:
9-
if: github.event.action != 'closed'
10-
runs-on: ubuntu-latest
11-
steps:
12-
- uses: actions/checkout@v4
13-
14-
- name: Deploy preview environment
15-
uses: appleboy/ssh-action@v1
16-
with:
17-
host: ${{ secrets.SERVER_HOST }}
18-
username: ${{ secrets.SERVER_USER }}
19-
key: ${{ secrets.SERVER_SSH_KEY }}
20-
script: |
21-
set -e
22-
APP_NAME="${{ github.event.repository.name }}"
23-
PR_NUMBER="${{ github.event.number }}"
24-
PREVIEW_DOMAIN="pr-${PR_NUMBER}.preview.${{ secrets.PREVIEW_DOMAIN }}"
25-
APP_DB=$(echo "${APP_NAME}" | tr '-' '_')_db
26-
SCHEMA_NAME="pr_${PR_NUMBER}"
27-
28-
PREVIEW_DIR="/opt/apps/${APP_NAME}-pr-${PR_NUMBER}"
29-
30-
# Clone the repo into a dedicated preview directory (or pull if it exists)
31-
if [ -d "${PREVIEW_DIR}" ]; then
32-
cd "${PREVIEW_DIR}"
33-
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER} --force
34-
git checkout pr-${PR_NUMBER}
35-
else
36-
git clone /opt/apps/${APP_NAME} "${PREVIEW_DIR}"
37-
cd "${PREVIEW_DIR}"
38-
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER} --force
39-
git checkout pr-${PR_NUMBER}
40-
fi
41-
42-
# Create preview schema in the app's database
43-
docker compose -f /opt/platform/docker-compose.yml exec -T postgres \
44-
psql -U postgres -d ${APP_DB} -c "CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME}"
45-
46-
# Copy production .env as base for preview config
47-
if [ ! -f /opt/apps/${APP_NAME}/deploy/.env ]; then
48-
echo "ERROR: /opt/apps/${APP_NAME}/deploy/.env not found. Deploy production first."
49-
exit 1
50-
fi
51-
52-
# Generate preview .env with schema-aware DATABASE_URL
53-
sed "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://postgres:$(grep POSTGRES_PASSWORD /opt/platform/.env | cut -d= -f2)@postgres:5432/${APP_DB}?options=-csearch_path%3D${SCHEMA_NAME}|" \
54-
/opt/apps/${APP_NAME}/deploy/.env > deploy/.env.pr-${PR_NUMBER}
55-
56-
# Build and start the app container (skip optional services like celery-worker)
57-
docker compose -p ${APP_NAME}-pr-${PR_NUMBER} -f deploy/docker-compose.yml \
58-
--env-file deploy/.env.pr-${PR_NUMBER} up -d --build app
59-
60-
# Run database migrations against the preview schema
61-
docker compose -p ${APP_NAME}-pr-${PR_NUMBER} -f deploy/docker-compose.yml \
62-
exec -T app alembic -c app/alembic.ini upgrade head
63-
64-
# Generate Caddyfile for preview domain
65-
cat > /opt/platform/caddy-apps/${APP_NAME}-pr-${PR_NUMBER}.caddy <<CADDYEOF
66-
${PREVIEW_DOMAIN} {
67-
import security_headers
68-
reverse_proxy ${APP_NAME}-pr-${PR_NUMBER}-app-1:8000
69-
}
70-
CADDYEOF
71-
72-
# Reload Caddy to pick up new route
73-
docker compose -f /opt/platform/docker-compose.yml exec -T caddy \
74-
caddy reload --config /etc/caddy/Caddyfile
75-
76-
- name: Post preview URL
77-
uses: actions/github-script@v7
78-
with:
79-
script: |
80-
const previewDomain = `pr-${{ github.event.number }}.preview.${{ secrets.PREVIEW_DOMAIN }}`;
81-
const body = `Preview deployed: https://${previewDomain}\n\nHealth check: https://${previewDomain}/health`;
82-
const { data: comments } = await github.rest.issues.listComments({
83-
owner: context.repo.owner,
84-
repo: context.repo.repo,
85-
issue_number: context.issue.number,
86-
});
87-
const existing = comments.find(c => c.body.startsWith('Preview deployed:'));
88-
if (existing) {
89-
await github.rest.issues.updateComment({
90-
owner: context.repo.owner,
91-
repo: context.repo.repo,
92-
comment_id: existing.id,
93-
body,
94-
});
95-
} else {
96-
await github.rest.issues.createComment({
97-
owner: context.repo.owner,
98-
repo: context.repo.repo,
99-
issue_number: context.issue.number,
100-
body,
101-
});
102-
}
103-
104-
cleanup-preview:
105-
if: github.event.action == 'closed'
106-
runs-on: ubuntu-latest
107-
steps:
108-
- name: Clean up preview environment
109-
uses: appleboy/ssh-action@v1
110-
with:
111-
host: ${{ secrets.SERVER_HOST }}
112-
username: ${{ secrets.SERVER_USER }}
113-
key: ${{ secrets.SERVER_SSH_KEY }}
114-
script: |
115-
set -e
116-
APP_NAME="${{ github.event.repository.name }}"
117-
PR_NUMBER="${{ github.event.number }}"
118-
APP_DB=$(echo "${APP_NAME}" | tr '-' '_')_db
119-
SCHEMA_NAME="pr_${PR_NUMBER}"
120-
121-
PREVIEW_DIR="/opt/apps/${APP_NAME}-pr-${PR_NUMBER}"
122-
123-
# Stop and remove preview containers
124-
if [ -d "${PREVIEW_DIR}" ]; then
125-
cd "${PREVIEW_DIR}"
126-
docker compose -p ${APP_NAME}-pr-${PR_NUMBER} -f deploy/docker-compose.yml down --rmi local || true
127-
fi
128-
129-
# Drop the preview schema
130-
docker compose -f /opt/platform/docker-compose.yml exec -T postgres \
131-
psql -U postgres -d ${APP_DB} -c "DROP SCHEMA IF EXISTS ${SCHEMA_NAME} CASCADE"
132-
133-
# Remove preview Caddyfile
134-
rm -f /opt/platform/caddy-apps/${APP_NAME}-pr-${PR_NUMBER}.caddy
135-
136-
# Reload Caddy
137-
docker compose -f /opt/platform/docker-compose.yml exec -T caddy \
138-
caddy reload --config /etc/caddy/Caddyfile
139-
140-
# Remove the preview directory entirely
141-
rm -rf "${PREVIEW_DIR}"
8+
preview:
9+
if: github.repository != 'towlion/app-template'
10+
uses: towlion/.github/.github/workflows/preview.yml@main
11+
with:
12+
caddyfile-template: |
13+
__APP_DOMAIN__ {
14+
import security_headers
15+
reverse_proxy __APP_NAME__-app-1:8000
16+
}
17+
secrets:
18+
SERVER_HOST: ${{ secrets.SERVER_HOST }}
19+
SERVER_USER: ${{ secrets.SERVER_USER }}
20+
SERVER_SSH_KEY: ${{ secrets.SERVER_SSH_KEY }}
21+
PREVIEW_DOMAIN: ${{ secrets.PREVIEW_DOMAIN }}

.github/workflows/validate.yml

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,4 @@ on:
66

77
jobs:
88
validate:
9-
runs-on: ubuntu-latest
10-
steps:
11-
- uses: actions/checkout@v4
12-
13-
- uses: actions/setup-python@v5
14-
with:
15-
python-version: "3.12"
16-
17-
- uses: actions/checkout@v4
18-
with:
19-
repository: towlion/platform
20-
path: _platform
21-
continue-on-error: true
22-
23-
- name: Run spec validator
24-
if: success()
25-
run: python _platform/validator/validate.py --tier 2 --dir .
9+
uses: towlion/.github/.github/workflows/validate.yml@main

app/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ ENV PYTHONPATH=/app
1515

1616
EXPOSE 8000
1717

18-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
18+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]

0 commit comments

Comments
 (0)