Skip to content

Commit 141203f

Browse files
committed
fix(deploy): resolve exit code 3 — robust paths, --preload, log capture
- config.py: Use absolute path /home/site/wwwroot/data for SQLite on Azure, with /tmp fallback if mkdir fails (prevents PermissionError crash) - startup.sh: Add 'exec > >(tee -a /home/LogFiles/startup.log) 2>&1' to capture ALL output to a persistent log file for debugging - startup.sh: Add --preload to gunicorn so import errors are visible before worker forking (exit code 3 = worker boot failure) - startup.sh: Test src.config import separately before src.api_server to isolate the failing layer - deploy-azure.yml: Use absolute path 'bash /home/site/wwwroot/startup.sh' for startup command - deploy-azure.yml: Increase health check retries to 5 with 20s delay - deploy-azure.yml: Download and print Azure logs on health check failure
1 parent 484bdeb commit 141203f

3 files changed

Lines changed: 65 additions & 31 deletions

File tree

.github/workflows/deploy-azure.yml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,32 @@ jobs:
8787
app-name: ${{ env.AZURE_WEBAPP_NAME }}
8888
package: backend-deploy.zip
8989
clean: true
90-
startup-command: startup.sh
90+
startup-command: "bash /home/site/wwwroot/startup.sh"
9191

9292
- name: Test deployment
9393
run: |
9494
echo "Waiting for deployment to stabilize..."
95-
sleep 30
96-
for i in 1 2 3; do
95+
sleep 60
96+
for i in 1 2 3 4 5; do
9797
if curl -sf https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/health; then
9898
echo "✅ Backend is healthy!"
9999
exit 0
100100
fi
101-
echo "Attempt $i failed, retrying in 10s..."
102-
sleep 10
101+
echo "Attempt $i failed, retrying in 20s..."
102+
sleep 20
103103
done
104-
echo "❌ Health check failed after 3 attempts"
104+
echo "❌ Health check failed after 5 attempts"
105+
# Download logs for debugging
106+
az webapp log download --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \
107+
--name ${{ env.AZURE_WEBAPP_NAME }} \
108+
--log-file /tmp/azure-logs.zip 2>/dev/null || true
109+
if [ -f /tmp/azure-logs.zip ]; then
110+
unzip -o /tmp/azure-logs.zip -d /tmp/azure-logs 2>/dev/null || true
111+
echo "=== Docker Logs ==="
112+
cat /tmp/azure-logs/LogFiles/*docker*.log 2>/dev/null | tail -80 || true
113+
echo "=== Startup Log ==="
114+
cat /tmp/azure-logs/LogFiles/startup.log 2>/dev/null || true
115+
fi
105116
exit 1
106117
107118
- name: Logout from Azure

src/config.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,18 @@ class Settings(BaseSettings):
8181
def _default_database_url(cls, v: str) -> str:
8282
if v:
8383
return v
84-
# Default to SQLite in ./data directory
85-
db_dir = Path("data")
86-
db_dir.mkdir(parents=True, exist_ok=True)
84+
# Default to SQLite — use /home/site/wwwroot/data on Azure,
85+
# otherwise ./data relative to project root
86+
if os.path.isdir("/home/site/wwwroot"):
87+
db_dir = Path("/home/site/wwwroot/data")
88+
else:
89+
db_dir = Path("data")
90+
try:
91+
db_dir.mkdir(parents=True, exist_ok=True)
92+
except OSError as e:
93+
logger.warning("Cannot create DB directory %s: %s — using /tmp", db_dir, e)
94+
db_dir = Path("/tmp/apiwatch-data")
95+
db_dir.mkdir(parents=True, exist_ok=True)
8796
return f"sqlite+aiosqlite:///{db_dir.resolve()}/apiwatch.db"
8897

8998
@field_validator("jwt_secret_key", mode="before")

startup.sh

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,79 @@
22
# Azure App Service startup script
33
# Azure sets PORT env var automatically (default 8000)
44

5+
# Redirect ALL output to both stdout and a log file for debugging
6+
exec > >(tee -a /home/LogFiles/startup.log) 2>&1
7+
58
echo "=== API-Watch Startup ==="
69
echo "PWD: $(pwd)"
710
echo "Date: $(date)"
811

912
# Ensure we're in the deployment directory
10-
cd /home/site/wwwroot
13+
cd /home/site/wwwroot || { echo "FATAL: cannot cd to /home/site/wwwroot"; exit 1; }
1114
echo "Working dir: $(pwd)"
12-
echo "Contents: $(ls -la)"
15+
echo "Contents:"
16+
ls -la
1317

1418
# Activate Oryx-created virtual environment (created during zip deployment)
1519
if [ -d "antenv" ]; then
1620
echo "Activating Oryx virtual environment (antenv)..."
1721
source antenv/bin/activate
1822
echo "Python: $(which python)"
19-
echo "Pip packages: $(pip list --format=columns 2>/dev/null | head -5)"
23+
echo "Python version: $(python --version 2>&1)"
2024
else
2125
echo "WARNING: antenv directory not found!"
22-
echo "Checking for packages directory..."
23-
fi
24-
25-
# Add bundled packages to Python path (fallback if no antenv)
26-
if [ -d "packages" ]; then
27-
export PYTHONPATH="/home/site/wwwroot/packages:$PYTHONPATH"
28-
echo "Using pre-bundled Python packages"
26+
echo "Listing site directory:"
27+
ls -la /home/site/wwwroot/
2928
fi
3029

31-
# Fallback: install if neither antenv nor bundled packages have gunicorn
30+
# Fallback: install if antenv doesn't have gunicorn
3231
if ! python -c "import gunicorn" 2>/dev/null; then
3332
echo "gunicorn not found, installing dependencies..."
34-
pip install --no-cache-dir -r requirements.txt 2>&1 | tail -10 || echo "⚠️ pip install failed"
33+
pip install --no-cache-dir -r requirements.txt 2>&1 | tail -10 || echo "pip install failed"
3534
fi
3635

3736
# Verify critical imports work
38-
echo "Testing critical imports..."
37+
echo "=== Testing critical imports ==="
3938
python -c "
4039
import sys
4140
print(f'Python: {sys.executable}')
41+
print(f'sys.path: {sys.path[:5]}')
4242
try:
43-
import fastapi; print(f'fastapi: {fastapi.__version__}')
44-
except Exception as e: print(f'fastapi FAILED: {e}')
43+
import fastapi; print(f' fastapi: {fastapi.__version__}')
44+
except Exception as e: print(f' fastapi FAILED: {e}')
4545
try:
46-
import gunicorn; print(f'gunicorn: OK')
47-
except Exception as e: print(f'gunicorn FAILED: {e}')
46+
import gunicorn; print(f' gunicorn: OK')
47+
except Exception as e: print(f' gunicorn FAILED: {e}')
4848
try:
49-
from src.api_server import app; print('src.api_server: OK')
50-
except Exception as e: print(f'src.api_server FAILED: {e}')
51-
" 2>&1 || echo "Import test failed"
49+
import uvicorn; print(f' uvicorn: OK')
50+
except Exception as e: print(f' uvicorn FAILED: {e}')
51+
try:
52+
from src.config import get_settings
53+
s = get_settings()
54+
print(f' src.config: OK (db={s.database_url[:30]}...)')
55+
except Exception as e: print(f' src.config FAILED: {e}')
56+
try:
57+
from src.api_server import app; print(f' src.api_server: OK')
58+
except Exception as e:
59+
print(f' src.api_server FAILED: {e}')
60+
import traceback; traceback.print_exc()
61+
" 2>&1
62+
63+
echo "=== Import test complete ==="
5264

5365
# Run database migrations (safe to run repeatedly — no-ops if up to date)
5466
if [ -d "alembic" ]; then
5567
echo "Running database migrations..."
56-
python -m alembic upgrade head 2>&1 || echo "⚠️ Migrations skipped (non-fatal)"
68+
python -m alembic upgrade head 2>&1 || echo "Migrations skipped (non-fatal)"
5769
fi
5870

5971
# Start with gunicorn + uvicorn workers for production performance
72+
# --preload: load app before forking so import errors are visible in main process
6073
export PORT=${PORT:-8000}
61-
echo "Starting API-Watch on port $PORT..."
74+
echo "Starting gunicorn on port $PORT with --preload..."
6275

6376
exec gunicorn src.api_server:app \
77+
--preload \
6478
--worker-class uvicorn.workers.UvicornWorker \
6579
--bind "0.0.0.0:$PORT" \
6680
--workers 1 \

0 commit comments

Comments
 (0)