Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- Dockerfile +47 -16
- config.sh +57 -18
- entrypoint.sh +126 -34
- nginx.conf +53 -10
- refresh_service.py +173 -55
- requirements.txt +6 -6
- supervisord.conf +29 -9
Dockerfile
CHANGED
|
@@ -1,37 +1,68 @@
|
|
| 1 |
FROM ubuntu:22.04
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
ENV AIRFLOW_HOME=/opt/airflow
|
|
|
|
|
|
|
| 5 |
|
|
|
|
| 6 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
&& rm -rf /var/lib/apt/lists/* \
|
| 15 |
&& useradd -m -u 1000 airflow
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
USER airflow
|
| 21 |
-
|
| 22 |
ENV PATH="/home/airflow/.local/bin:${PATH}"
|
| 23 |
|
|
|
|
| 24 |
COPY --chown=airflow:airflow requirements.txt /tmp/requirements.txt
|
| 25 |
-
RUN pip install --no-cache-dir -
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
COPY --chown=airflow:airflow
|
| 29 |
-
COPY --chown=airflow:airflow
|
| 30 |
-
COPY --chown=airflow:airflow
|
| 31 |
-
COPY --chown=airflow:airflow
|
|
|
|
| 32 |
|
| 33 |
RUN chmod +x /entrypoint.sh /config.sh
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
EXPOSE 7860
|
| 36 |
|
| 37 |
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
|
|
|
| 1 |
FROM ubuntu:22.04
|
| 2 |
|
| 3 |
+
LABEL maintainer="subhamgiri460"
|
| 4 |
+
LABEL description="Apache Airflow on HuggingFace Docker Space"
|
| 5 |
+
|
| 6 |
ENV DEBIAN_FRONTEND=noninteractive
|
| 7 |
ENV AIRFLOW_HOME=/opt/airflow
|
| 8 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 9 |
+
ENV PYTHONUNBUFFERED=1
|
| 10 |
|
| 11 |
+
# ---- System packages ----
|
| 12 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
python3 \
|
| 14 |
+
python3-pip \
|
| 15 |
+
python3-venv \
|
| 16 |
+
nginx \
|
| 17 |
+
git \
|
| 18 |
+
supervisor \
|
| 19 |
+
curl \
|
| 20 |
+
libpq-dev \
|
| 21 |
&& rm -rf /var/lib/apt/lists/* \
|
| 22 |
&& useradd -m -u 1000 airflow
|
| 23 |
|
| 24 |
+
# ---- Directories & permissions ----
|
| 25 |
+
RUN mkdir -p \
|
| 26 |
+
/var/log/supervisor \
|
| 27 |
+
/var/log/nginx \
|
| 28 |
+
/run/nginx \
|
| 29 |
+
/var/lib/nginx \
|
| 30 |
+
/run/nginx/client_temp \
|
| 31 |
+
/run/nginx/proxy_temp \
|
| 32 |
+
/run/nginx/fastcgi_temp \
|
| 33 |
+
/run/nginx/uwsgi_temp \
|
| 34 |
+
/run/nginx/scgi_temp \
|
| 35 |
+
/opt/airflow/dags \
|
| 36 |
+
/opt/airflow/logs \
|
| 37 |
+
&& chown -R airflow:airflow \
|
| 38 |
+
/opt/airflow \
|
| 39 |
+
/var/log/supervisor \
|
| 40 |
+
/var/log/nginx \
|
| 41 |
+
/run/nginx \
|
| 42 |
+
/var/lib/nginx
|
| 43 |
|
| 44 |
USER airflow
|
|
|
|
| 45 |
ENV PATH="/home/airflow/.local/bin:${PATH}"
|
| 46 |
|
| 47 |
+
# ---- Python dependencies (cached layer) ----
|
| 48 |
COPY --chown=airflow:airflow requirements.txt /tmp/requirements.txt
|
| 49 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 50 |
+
&& pip install --no-cache-dir -r /tmp/requirements.txt \
|
| 51 |
+
&& rm /tmp/requirements.txt
|
| 52 |
|
| 53 |
+
# ---- Application files ----
|
| 54 |
+
COPY --chown=airflow:airflow config.sh /config.sh
|
| 55 |
+
COPY --chown=airflow:airflow supervisord.conf /etc/supervisord.conf
|
| 56 |
+
COPY --chown=airflow:airflow nginx.conf /etc/nginx/nginx.conf
|
| 57 |
+
COPY --chown=airflow:airflow entrypoint.sh /entrypoint.sh
|
| 58 |
+
COPY --chown=airflow:airflow refresh_service.py /refresh_service.py
|
| 59 |
|
| 60 |
RUN chmod +x /entrypoint.sh /config.sh
|
| 61 |
|
| 62 |
+
# ---- Health check ----
|
| 63 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
|
| 64 |
+
CMD curl -sf http://localhost:7860/health || exit 1
|
| 65 |
+
|
| 66 |
EXPOSE 7860
|
| 67 |
|
| 68 |
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
config.sh
CHANGED
|
@@ -1,28 +1,36 @@
|
|
| 1 |
#!/bin/bash
|
| 2 |
# ===========================================
|
| 3 |
-
# Airflow Configuration
|
| 4 |
# ===========================================
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
|
| 8 |
# ---- Directory Paths ----
|
| 9 |
export AIRFLOW_HOME="${AIRFLOW_HOME:-/opt/airflow}"
|
| 10 |
export DAGS_DIR="${AIRFLOW_HOME}/dags"
|
| 11 |
export LOGS_DIR="${AIRFLOW_HOME}/logs"
|
| 12 |
|
| 13 |
-
# ---- Database
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_SIZE="
|
| 18 |
-
export AIRFLOW__DATABASE__SQL_ALCHEMY_MAX_OVERFLOW="
|
| 19 |
-
export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_RECYCLE="1800"
|
|
|
|
| 20 |
|
| 21 |
# ---- DAG Repository ----
|
| 22 |
export DAG_REPO_URL="${DAG_REPO_URL:-https://github.com/subhamgiri460/myworkflows.git}"
|
| 23 |
export DAG_REPO_BRANCH="${DAG_REPO_BRANCH:-main}"
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
# ---- Admin Credentials
|
|
|
|
| 26 |
export AIRFLOW_ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}"
|
| 27 |
export AIRFLOW_ADMIN_PASSWORD="${AIRFLOW_ADMIN_PASSWORD:-admin}"
|
| 28 |
|
|
@@ -31,18 +39,49 @@ export AIRFLOW_WEB_PORT="${AIRFLOW_WEB_PORT:-8080}"
|
|
| 31 |
export REFRESH_SERVICE_PORT="${REFRESH_SERVICE_PORT:-5000}"
|
| 32 |
export NGINX_PORT="${NGINX_PORT:-7860}"
|
| 33 |
|
| 34 |
-
# ----
|
| 35 |
export AIRFLOW__CORE__LOAD_EXAMPLES="${AIRFLOW__CORE__LOAD_EXAMPLES:-False}"
|
| 36 |
export AIRFLOW__CORE__EXECUTOR="${AIRFLOW__CORE__EXECUTOR:-LocalExecutor}"
|
| 37 |
-
export AIRFLOW__CORE__PARALLELISM="2"
|
| 38 |
-
export AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG="2"
|
|
|
|
| 39 |
|
| 40 |
-
# -
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
export AIRFLOW__WEBSERVER__WEB_SERVER_PORT="${AIRFLOW_WEB_PORT}"
|
| 42 |
export AIRFLOW__WEBSERVER__WEB_SERVER_MASTER_TIMEOUT="${AIRFLOW__WEBSERVER__WEB_SERVER_MASTER_TIMEOUT:-300}"
|
| 43 |
export AIRFLOW__WEBSERVER__WORKER_CLASS="${AIRFLOW__WEBSERVER__WORKER_CLASS:-gevent}"
|
| 44 |
-
export AIRFLOW__WEBSERVER__SECRET_KEY="${AIRFLOW__WEBSERVER__SECRET_KEY:-
|
| 45 |
-
export AIRFLOW__WEBSERVER__WORKERS="1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
# ----
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#!/bin/bash
|
| 2 |
# ===========================================
|
| 3 |
+
# Airflow Configuration — Single Source of Truth
|
| 4 |
# ===========================================
|
| 5 |
+
# Override any value by setting the corresponding environment
|
| 6 |
+
# variable (e.g. as an HF Secret).
|
| 7 |
|
| 8 |
# ---- Directory Paths ----
|
| 9 |
export AIRFLOW_HOME="${AIRFLOW_HOME:-/opt/airflow}"
|
| 10 |
export DAGS_DIR="${AIRFLOW_HOME}/dags"
|
| 11 |
export LOGS_DIR="${AIRFLOW_HOME}/logs"
|
| 12 |
|
| 13 |
+
# ---- Database ----
|
| 14 |
+
# Default: local SQLite so the container always boots.
|
| 15 |
+
# For production, set AIRFLOW__DATABASE__SQL_ALCHEMY_CONN as an HF Secret
|
| 16 |
+
# pointing to your PostgreSQL instance.
|
| 17 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_CONN="${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN:-sqlite:///${AIRFLOW_HOME}/airflow.db}"
|
| 18 |
|
| 19 |
+
# Pool tuning — keeps connections low for Supabase / free-tier DBs
|
| 20 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_SIZE="${AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_SIZE:-2}"
|
| 21 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_MAX_OVERFLOW="${AIRFLOW__DATABASE__SQL_ALCHEMY_MAX_OVERFLOW:-2}"
|
| 22 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_RECYCLE="${AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_RECYCLE:-1800}"
|
| 23 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_PRE_PING="True"
|
| 24 |
|
| 25 |
# ---- DAG Repository ----
|
| 26 |
export DAG_REPO_URL="${DAG_REPO_URL:-https://github.com/subhamgiri460/myworkflows.git}"
|
| 27 |
export DAG_REPO_BRANCH="${DAG_REPO_BRANCH:-main}"
|
| 28 |
+
# For private repos: set DAG_REPO_TOKEN to a GitHub PAT (fine-grained or classic)
|
| 29 |
+
# The token is injected into the URL at runtime — never stored in config.
|
| 30 |
+
export DAG_REPO_TOKEN="${DAG_REPO_TOKEN:-}"
|
| 31 |
|
| 32 |
+
# ---- Admin Credentials ----
|
| 33 |
+
# Set these as HF Secrets for production.
|
| 34 |
export AIRFLOW_ADMIN_USER="${AIRFLOW_ADMIN_USER:-admin}"
|
| 35 |
export AIRFLOW_ADMIN_PASSWORD="${AIRFLOW_ADMIN_PASSWORD:-admin}"
|
| 36 |
|
|
|
|
| 39 |
export REFRESH_SERVICE_PORT="${REFRESH_SERVICE_PORT:-5000}"
|
| 40 |
export NGINX_PORT="${NGINX_PORT:-7860}"
|
| 41 |
|
| 42 |
+
# ---- Core ----
|
| 43 |
export AIRFLOW__CORE__LOAD_EXAMPLES="${AIRFLOW__CORE__LOAD_EXAMPLES:-False}"
|
| 44 |
export AIRFLOW__CORE__EXECUTOR="${AIRFLOW__CORE__EXECUTOR:-LocalExecutor}"
|
| 45 |
+
export AIRFLOW__CORE__PARALLELISM="${AIRFLOW__CORE__PARALLELISM:-2}"
|
| 46 |
+
export AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG="${AIRFLOW__CORE__MAX_ACTIVE_TASKS_PER_DAG:-2}"
|
| 47 |
+
export AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION="${AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION:-True}"
|
| 48 |
|
| 49 |
+
# Auto-generate Fernet key if not set (required for connection encryption)
|
| 50 |
+
if [ -z "${AIRFLOW__CORE__FERNET_KEY:-}" ]; then
|
| 51 |
+
export AIRFLOW__CORE__FERNET_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null || echo "")
|
| 52 |
+
fi
|
| 53 |
+
|
| 54 |
+
# ---- Webserver ----
|
| 55 |
export AIRFLOW__WEBSERVER__WEB_SERVER_PORT="${AIRFLOW_WEB_PORT}"
|
| 56 |
export AIRFLOW__WEBSERVER__WEB_SERVER_MASTER_TIMEOUT="${AIRFLOW__WEBSERVER__WEB_SERVER_MASTER_TIMEOUT:-300}"
|
| 57 |
export AIRFLOW__WEBSERVER__WORKER_CLASS="${AIRFLOW__WEBSERVER__WORKER_CLASS:-gevent}"
|
| 58 |
+
export AIRFLOW__WEBSERVER__SECRET_KEY="${AIRFLOW__WEBSERVER__SECRET_KEY:-$(python3 -c 'import secrets; print(secrets.token_hex(32))' 2>/dev/null)}"
|
| 59 |
+
export AIRFLOW__WEBSERVER__WORKERS="${AIRFLOW__WEBSERVER__WORKERS:-1}"
|
| 60 |
+
export AIRFLOW__WEBSERVER__EXPOSE_CONFIG="${AIRFLOW__WEBSERVER__EXPOSE_CONFIG:-False}"
|
| 61 |
+
|
| 62 |
+
# ---- Scheduler ----
|
| 63 |
+
export AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL="${AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL:-30}"
|
| 64 |
+
|
| 65 |
+
# ---- API ----
|
| 66 |
+
export AIRFLOW__API__AUTH_BACKENDS="${AIRFLOW__API__AUTH_BACKENDS:-airflow.api.auth.backend.session}"
|
| 67 |
+
|
| 68 |
+
# ---- Logging ----
|
| 69 |
+
export AIRFLOW__LOGGING__LOGGING_LEVEL="${AIRFLOW__LOGGING__LOGGING_LEVEL:-INFO}"
|
| 70 |
|
| 71 |
+
# ---- Validation ----
|
| 72 |
+
validate_config() {
|
| 73 |
+
local warn=0
|
| 74 |
+
if [[ "${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN}" == sqlite* ]]; then
|
| 75 |
+
echo "[WARN] Using SQLite — not suitable for production. Set AIRFLOW__DATABASE__SQL_ALCHEMY_CONN as an HF Secret."
|
| 76 |
+
# SQLite requires SequentialExecutor
|
| 77 |
+
export AIRFLOW__CORE__EXECUTOR="SequentialExecutor"
|
| 78 |
+
warn=1
|
| 79 |
+
fi
|
| 80 |
+
if [ "${AIRFLOW_ADMIN_PASSWORD}" = "admin" ]; then
|
| 81 |
+
echo "[WARN] Using default admin password. Set AIRFLOW_ADMIN_PASSWORD as an HF Secret."
|
| 82 |
+
warn=1
|
| 83 |
+
fi
|
| 84 |
+
if [ "${warn}" -eq 0 ]; then
|
| 85 |
+
echo "[OK] Configuration validated."
|
| 86 |
+
fi
|
| 87 |
+
}
|
entrypoint.sh
CHANGED
|
@@ -1,46 +1,138 @@
|
|
| 1 |
#!/bin/bash
|
| 2 |
-
set -
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
source /config.sh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
if [ ! -d "${DAGS_DIR}/.git" ]; then
|
|
|
|
| 17 |
rm -rf "${DAGS_DIR:?}"/* 2>/dev/null || true
|
| 18 |
-
git clone --depth 1 --branch "${DAG_REPO_BRANCH}" "${
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
else
|
| 21 |
cd "${DAGS_DIR}"
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
fi
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
airflow db init || true
|
| 29 |
-
airflow db migrate || true
|
| 30 |
-
|
| 31 |
-
echo ""
|
| 32 |
-
echo "=== Creating admin user ==="
|
| 33 |
-
# Delete old admin user if it exists to ensure fresh credentials
|
| 34 |
-
airflow users delete -u "${AIRFLOW_ADMIN_USER}" || true
|
| 35 |
-
|
| 36 |
-
airflow users create \
|
| 37 |
-
--username "${AIRFLOW_ADMIN_USER}" \
|
| 38 |
-
--firstname Admin \
|
| 39 |
-
--lastname User \
|
| 40 |
-
--role Admin \
|
| 41 |
-
--email "${AIRFLOW_ADMIN_USER}@example.com" \
|
| 42 |
-
--password "${AIRFLOW_ADMIN_PASSWORD}" || true
|
| 43 |
-
|
| 44 |
-
echo ""
|
| 45 |
-
echo "=== Starting services ==="
|
| 46 |
exec /usr/bin/supervisord -c /etc/supervisord.conf
|
|
|
|
| 1 |
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
|
| 4 |
+
# ---- Helpers ----
|
| 5 |
+
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
| 6 |
+
|
| 7 |
+
# Graceful shutdown: forward signals to child processes
|
| 8 |
+
trap 'log "Caught signal, shutting down..."; kill -TERM $(jobs -p) 2>/dev/null; exit 0' SIGTERM SIGINT SIGQUIT
|
| 9 |
+
|
| 10 |
+
# ---- Load Configuration ----
|
| 11 |
+
log "Loading configuration..."
|
| 12 |
source /config.sh
|
| 13 |
+
validate_config
|
| 14 |
+
|
| 15 |
+
log "Configuration:"
|
| 16 |
+
log " DB backend : $(echo "${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN}" | sed -E 's|://[^@]*@|://***:***@|')"
|
| 17 |
+
log " DAG repo : ${DAG_REPO_URL} (branch: ${DAG_REPO_BRANCH})"
|
| 18 |
+
log " Executor : ${AIRFLOW__CORE__EXECUTOR}"
|
| 19 |
+
log " Ports : nginx=${NGINX_PORT} webserver=${AIRFLOW_WEB_PORT} refresh=${REFRESH_SERVICE_PORT}"
|
| 20 |
+
|
| 21 |
+
# ---- Ensure directories ----
|
| 22 |
+
mkdir -p "${DAGS_DIR}" "${LOGS_DIR}"
|
| 23 |
|
| 24 |
+
# ---- Wait for Database (if PostgreSQL) ----
|
| 25 |
+
if [[ "${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN}" == postgresql* ]]; then
|
| 26 |
+
DB_WAIT_TIMEOUT="${DB_WAIT_TIMEOUT:-60}"
|
| 27 |
+
log "Waiting for PostgreSQL (timeout: ${DB_WAIT_TIMEOUT}s)..."
|
| 28 |
+
elapsed=0
|
| 29 |
+
while [ "${elapsed}" -lt "${DB_WAIT_TIMEOUT}" ]; do
|
| 30 |
+
if python3 -c "
|
| 31 |
+
import sqlalchemy, sys
|
| 32 |
+
try:
|
| 33 |
+
engine = sqlalchemy.create_engine('${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN}', connect_args={'connect_timeout': 5})
|
| 34 |
+
with engine.connect() as conn:
|
| 35 |
+
conn.execute(sqlalchemy.text('SELECT 1'))
|
| 36 |
+
sys.exit(0)
|
| 37 |
+
except Exception:
|
| 38 |
+
sys.exit(1)
|
| 39 |
+
" 2>/dev/null; then
|
| 40 |
+
log "PostgreSQL is ready."
|
| 41 |
+
break
|
| 42 |
+
fi
|
| 43 |
+
sleep 3
|
| 44 |
+
elapsed=$((elapsed + 3))
|
| 45 |
+
done
|
| 46 |
+
if [ "${elapsed}" -ge "${DB_WAIT_TIMEOUT}" ]; then
|
| 47 |
+
log "[ERROR] PostgreSQL not reachable after ${DB_WAIT_TIMEOUT}s."
|
| 48 |
+
log "Falling back to SQLite so the container still boots."
|
| 49 |
+
export AIRFLOW__DATABASE__SQL_ALCHEMY_CONN="sqlite:///${AIRFLOW_HOME}/airflow.db"
|
| 50 |
+
export AIRFLOW__CORE__EXECUTOR="SequentialExecutor"
|
| 51 |
+
fi
|
| 52 |
+
fi
|
| 53 |
+
|
| 54 |
+
# ---- Build authenticated repo URL (if PAT is set) ----
|
| 55 |
+
GIT_CLONE_URL="${DAG_REPO_URL}"
|
| 56 |
+
if [ -n "${DAG_REPO_TOKEN}" ]; then
|
| 57 |
+
# Inject token: https://github.com/... → https://<token>@github.com/...
|
| 58 |
+
GIT_CLONE_URL=$(echo "${DAG_REPO_URL}" | sed "s|https://|https://${DAG_REPO_TOKEN}@|")
|
| 59 |
+
log "Using authenticated URL for DAG repo (PAT token set)."
|
| 60 |
+
fi
|
| 61 |
|
| 62 |
+
# ---- Sync DAG Repository ----
|
| 63 |
+
_create_sample_dag() {
|
| 64 |
+
log "Creating sample DAG for testing..."
|
| 65 |
+
cat > "${DAGS_DIR}/sample_dag.py" << 'SAMPLE_EOF'
|
| 66 |
+
"""
|
| 67 |
+
Sample DAG — created automatically because no DAG repository was configured.
|
| 68 |
+
Replace this by setting DAG_REPO_URL or adding your own DAGs to /opt/airflow/dags.
|
| 69 |
+
"""
|
| 70 |
+
from datetime import datetime, timedelta
|
| 71 |
+
from airflow import DAG
|
| 72 |
+
from airflow.operators.bash import BashOperator
|
| 73 |
+
|
| 74 |
+
with DAG(
|
| 75 |
+
dag_id="sample_hello_world",
|
| 76 |
+
description="A sample DAG to verify Airflow is working",
|
| 77 |
+
schedule=timedelta(hours=1),
|
| 78 |
+
start_date=datetime(2024, 1, 1),
|
| 79 |
+
catchup=False,
|
| 80 |
+
tags=["sample", "test"],
|
| 81 |
+
) as dag:
|
| 82 |
+
hello = BashOperator(
|
| 83 |
+
task_id="say_hello",
|
| 84 |
+
bash_command='echo "Hello from Airflow! $(date)"',
|
| 85 |
+
)
|
| 86 |
+
check_env = BashOperator(
|
| 87 |
+
task_id="check_environment",
|
| 88 |
+
bash_command='echo "Python: $(python3 --version)" && echo "Airflow Home: $AIRFLOW_HOME"',
|
| 89 |
+
)
|
| 90 |
+
hello >> check_env
|
| 91 |
+
SAMPLE_EOF
|
| 92 |
+
log "Sample DAG created at ${DAGS_DIR}/sample_dag.py"
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
log "Syncing DAG repository..."
|
| 96 |
if [ ! -d "${DAGS_DIR}/.git" ]; then
|
| 97 |
+
# Fresh clone — clean any stale files (except sample_dag) first
|
| 98 |
rm -rf "${DAGS_DIR:?}"/* 2>/dev/null || true
|
| 99 |
+
if git clone --depth 1 --branch "${DAG_REPO_BRANCH}" "${GIT_CLONE_URL}" "${DAGS_DIR}" 2>/dev/null; then
|
| 100 |
+
log "DAG repository cloned."
|
| 101 |
+
else
|
| 102 |
+
log "[WARN] Could not clone DAG repo (URL unreachable or private without token)."
|
| 103 |
+
_create_sample_dag
|
| 104 |
+
fi
|
| 105 |
else
|
| 106 |
cd "${DAGS_DIR}"
|
| 107 |
+
# If PAT changed, update the remote URL
|
| 108 |
+
git remote set-url origin "${GIT_CLONE_URL}" 2>/dev/null || true
|
| 109 |
+
if git fetch origin "${DAG_REPO_BRANCH}" 2>/dev/null && git reset --hard "origin/${DAG_REPO_BRANCH}" 2>/dev/null; then
|
| 110 |
+
log "DAG repository updated."
|
| 111 |
+
else
|
| 112 |
+
log "[WARN] Could not update DAG repo. Using existing DAGs."
|
| 113 |
+
fi
|
| 114 |
+
fi
|
| 115 |
+
|
| 116 |
+
# ---- Database Migrations ----
|
| 117 |
+
log "Running database migrations..."
|
| 118 |
+
airflow db migrate 2>&1 | tail -5
|
| 119 |
+
log "Migrations complete."
|
| 120 |
+
|
| 121 |
+
# ---- Create Admin User (idempotent) ----
|
| 122 |
+
log "Ensuring admin user exists..."
|
| 123 |
+
if ! airflow users list 2>/dev/null | grep -q "${AIRFLOW_ADMIN_USER}"; then
|
| 124 |
+
airflow users create \
|
| 125 |
+
--username "${AIRFLOW_ADMIN_USER}" \
|
| 126 |
+
--firstname Admin \
|
| 127 |
+
--lastname User \
|
| 128 |
+
--role Admin \
|
| 129 |
+
--email "${AIRFLOW_ADMIN_USER}@example.com" \
|
| 130 |
+
--password "${AIRFLOW_ADMIN_PASSWORD}"
|
| 131 |
+
log "Admin user '${AIRFLOW_ADMIN_USER}' created."
|
| 132 |
+
else
|
| 133 |
+
log "Admin user '${AIRFLOW_ADMIN_USER}' already exists — skipping."
|
| 134 |
fi
|
| 135 |
|
| 136 |
+
# ---- Start Supervisor ----
|
| 137 |
+
log "Starting services via supervisord..."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
exec /usr/bin/supervisord -c /etc/supervisord.conf
|
nginx.conf
CHANGED
|
@@ -9,57 +9,100 @@ events {
|
|
| 9 |
http {
|
| 10 |
include /etc/nginx/mime.types;
|
| 11 |
default_type application/octet-stream;
|
| 12 |
-
|
| 13 |
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 14 |
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
| 15 |
-
|
| 16 |
access_log /var/log/nginx/access.log main;
|
| 17 |
sendfile on;
|
| 18 |
keepalive_timeout 65;
|
| 19 |
-
|
|
|
|
| 20 |
client_body_temp_path /run/nginx/client_temp;
|
| 21 |
proxy_temp_path /run/nginx/proxy_temp;
|
| 22 |
fastcgi_temp_path /run/nginx/fastcgi_temp;
|
| 23 |
uwsgi_temp_path /run/nginx/uwsgi_temp;
|
| 24 |
scgi_temp_path /run/nginx/scgi_temp;
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
upstream airflow {
|
| 27 |
server 127.0.0.1:8080;
|
|
|
|
| 28 |
}
|
| 29 |
-
|
| 30 |
upstream refresh {
|
| 31 |
server 127.0.0.1:5000;
|
|
|
|
| 32 |
}
|
| 33 |
-
|
| 34 |
server {
|
| 35 |
listen 7860;
|
| 36 |
server_name _;
|
| 37 |
client_max_body_size 100M;
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
location /refresh {
|
| 40 |
proxy_pass http://refresh;
|
| 41 |
proxy_set_header Host $host;
|
|
|
|
|
|
|
| 42 |
proxy_read_timeout 120s;
|
| 43 |
}
|
| 44 |
-
|
| 45 |
location /config {
|
| 46 |
proxy_pass http://refresh;
|
| 47 |
proxy_set_header Host $host;
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
-
|
| 50 |
location /health {
|
| 51 |
proxy_pass http://refresh;
|
| 52 |
proxy_set_header Host $host;
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
location / {
|
| 56 |
proxy_pass http://airflow;
|
| 57 |
proxy_set_header Host $host;
|
| 58 |
proxy_set_header X-Real-IP $remote_addr;
|
| 59 |
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 60 |
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
|
|
|
| 61 |
proxy_read_timeout 300s;
|
| 62 |
proxy_connect_timeout 75s;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
}
|
|
|
|
| 9 |
http {
|
| 10 |
include /etc/nginx/mime.types;
|
| 11 |
default_type application/octet-stream;
|
| 12 |
+
|
| 13 |
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
| 14 |
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
| 15 |
+
|
| 16 |
access_log /var/log/nginx/access.log main;
|
| 17 |
sendfile on;
|
| 18 |
keepalive_timeout 65;
|
| 19 |
+
|
| 20 |
+
# Temp paths (writable by non-root)
|
| 21 |
client_body_temp_path /run/nginx/client_temp;
|
| 22 |
proxy_temp_path /run/nginx/proxy_temp;
|
| 23 |
fastcgi_temp_path /run/nginx/fastcgi_temp;
|
| 24 |
uwsgi_temp_path /run/nginx/uwsgi_temp;
|
| 25 |
scgi_temp_path /run/nginx/scgi_temp;
|
| 26 |
+
|
| 27 |
+
# Gzip compression
|
| 28 |
+
gzip on;
|
| 29 |
+
gzip_types text/plain text/css application/json application/javascript text/xml;
|
| 30 |
+
gzip_min_length 256;
|
| 31 |
+
gzip_vary on;
|
| 32 |
+
|
| 33 |
+
# WebSocket upgrade map (must be before server block)
|
| 34 |
+
map $http_upgrade $connection_upgrade {
|
| 35 |
+
default upgrade;
|
| 36 |
+
'' '';
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Upstreams
|
| 40 |
upstream airflow {
|
| 41 |
server 127.0.0.1:8080;
|
| 42 |
+
keepalive 4;
|
| 43 |
}
|
| 44 |
+
|
| 45 |
upstream refresh {
|
| 46 |
server 127.0.0.1:5000;
|
| 47 |
+
keepalive 2;
|
| 48 |
}
|
| 49 |
+
|
| 50 |
server {
|
| 51 |
listen 7860;
|
| 52 |
server_name _;
|
| 53 |
client_max_body_size 100M;
|
| 54 |
+
|
| 55 |
+
# Security headers
|
| 56 |
+
add_header X-Frame-Options SAMEORIGIN always;
|
| 57 |
+
add_header X-Content-Type-Options nosniff always;
|
| 58 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
| 59 |
+
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
| 60 |
+
|
| 61 |
+
# ---- Refresh service ----
|
| 62 |
location /refresh {
|
| 63 |
proxy_pass http://refresh;
|
| 64 |
proxy_set_header Host $host;
|
| 65 |
+
proxy_set_header Connection "";
|
| 66 |
+
proxy_http_version 1.1;
|
| 67 |
proxy_read_timeout 120s;
|
| 68 |
}
|
| 69 |
+
|
| 70 |
location /config {
|
| 71 |
proxy_pass http://refresh;
|
| 72 |
proxy_set_header Host $host;
|
| 73 |
+
proxy_set_header Connection "";
|
| 74 |
+
proxy_http_version 1.1;
|
| 75 |
}
|
| 76 |
+
|
| 77 |
location /health {
|
| 78 |
proxy_pass http://refresh;
|
| 79 |
proxy_set_header Host $host;
|
| 80 |
+
proxy_set_header Connection "";
|
| 81 |
+
proxy_http_version 1.1;
|
| 82 |
}
|
| 83 |
+
|
| 84 |
+
location /status {
|
| 85 |
+
proxy_pass http://refresh;
|
| 86 |
+
proxy_set_header Host $host;
|
| 87 |
+
proxy_set_header Connection "";
|
| 88 |
+
proxy_http_version 1.1;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
# ---- Airflow webserver ----
|
| 92 |
location / {
|
| 93 |
proxy_pass http://airflow;
|
| 94 |
proxy_set_header Host $host;
|
| 95 |
proxy_set_header X-Real-IP $remote_addr;
|
| 96 |
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 97 |
proxy_set_header X-Forwarded-Proto $scheme;
|
| 98 |
+
proxy_http_version 1.1;
|
| 99 |
+
proxy_set_header Connection "";
|
| 100 |
proxy_read_timeout 300s;
|
| 101 |
proxy_connect_timeout 75s;
|
| 102 |
+
|
| 103 |
+
# WebSocket support (for live log tailing)
|
| 104 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 105 |
+
proxy_set_header Connection $connection_upgrade;
|
| 106 |
}
|
| 107 |
}
|
| 108 |
}
|
refresh_service.py
CHANGED
|
@@ -1,76 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
from flask import Flask, jsonify
|
| 3 |
import subprocess
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
app = Flask(__name__)
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def refresh_dags():
|
|
|
|
| 14 |
try:
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
)
|
| 30 |
-
result = reset_result
|
| 31 |
-
action = 'updated'
|
| 32 |
-
else:
|
| 33 |
-
result = subprocess.run(
|
| 34 |
-
['git', 'clone', '--depth', '1', '--branch', DAG_REPO_BRANCH, DAG_REPO_URL, DAGS_DIR],
|
| 35 |
-
capture_output=True, text=True, timeout=120
|
| 36 |
-
)
|
| 37 |
-
action = 'cloned'
|
| 38 |
-
|
| 39 |
-
if result.returncode == 0:
|
| 40 |
-
return jsonify({
|
| 41 |
-
'status': 'success',
|
| 42 |
-
'message': f'DAGs {action} successfully',
|
| 43 |
-
'repo': DAG_REPO_URL,
|
| 44 |
-
'branch': DAG_REPO_BRANCH
|
| 45 |
-
}), 200
|
| 46 |
-
else:
|
| 47 |
-
return jsonify({
|
| 48 |
-
'status': 'error',
|
| 49 |
-
'message': result.stderr or 'Unknown error'
|
| 50 |
-
}), 500
|
| 51 |
-
|
| 52 |
except subprocess.TimeoutExpired:
|
| 53 |
-
return jsonify({
|
| 54 |
except Exception as e:
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
-
@app.route(
|
| 59 |
def health():
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
-
@app.route(
|
| 64 |
def show_config():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return jsonify({
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
'refresh_service_port': os.environ.get('REFRESH_SERVICE_PORT', '5000')
|
| 71 |
}), 200
|
| 72 |
|
| 73 |
|
| 74 |
-
if __name__ ==
|
| 75 |
-
port = int(
|
| 76 |
-
app.run(host=
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DAG Refresh Service — lightweight Flask sidecar for git-syncing DAGs.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET/POST /refresh — pull latest DAGs from the configured git repo
|
| 6 |
+
GET /health — liveness check (also pings Airflow webserver)
|
| 7 |
+
GET /config — show current (non-sensitive) configuration
|
| 8 |
+
GET /status — git SHA, last sync time, repo info
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import fcntl
|
| 12 |
+
import logging
|
| 13 |
import os
|
|
|
|
| 14 |
import subprocess
|
| 15 |
+
import time
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
|
| 18 |
+
from flask import Flask, jsonify, request
|
| 19 |
+
|
| 20 |
+
# ---- Logging ----
|
| 21 |
+
logging.basicConfig(
|
| 22 |
+
level=logging.INFO,
|
| 23 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 24 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger("refresh-service")
|
| 27 |
|
| 28 |
app = Flask(__name__)
|
| 29 |
|
| 30 |
+
# ---- Configuration ----
|
| 31 |
+
AIRFLOW_HOME = os.environ.get("AIRFLOW_HOME", "/opt/airflow")
|
| 32 |
+
DAGS_DIR = os.path.join(AIRFLOW_HOME, "dags")
|
| 33 |
+
DAG_REPO_URL = os.environ.get("DAG_REPO_URL", "https://github.com/subhamgiri460/myworkflows.git")
|
| 34 |
+
DAG_REPO_BRANCH = os.environ.get("DAG_REPO_BRANCH", "main")
|
| 35 |
+
DAG_REPO_TOKEN = os.environ.get("DAG_REPO_TOKEN", "")
|
| 36 |
+
AIRFLOW_WEB_PORT = os.environ.get("AIRFLOW_WEB_PORT", "8080")
|
| 37 |
+
REFRESH_SERVICE_PORT = os.environ.get("REFRESH_SERVICE_PORT", "5000")
|
| 38 |
+
|
| 39 |
+
LOCK_FILE = "/tmp/dag_sync.lock"
|
| 40 |
+
_last_sync: dict = {}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _get_authenticated_url() -> str:
|
| 44 |
+
"""Inject PAT token into the repo URL for private repos."""
|
| 45 |
+
if DAG_REPO_TOKEN:
|
| 46 |
+
return DAG_REPO_URL.replace("https://", f"https://{DAG_REPO_TOKEN}@")
|
| 47 |
+
return DAG_REPO_URL
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _run_git(args: list[str], cwd: str | None = None, timeout: int = 60) -> subprocess.CompletedProcess:
|
| 51 |
+
"""Run a git command and return the result."""
|
| 52 |
+
return subprocess.run(
|
| 53 |
+
["git"] + args,
|
| 54 |
+
cwd=cwd,
|
| 55 |
+
capture_output=True,
|
| 56 |
+
text=True,
|
| 57 |
+
timeout=timeout,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _get_head_sha() -> str | None:
|
| 62 |
+
"""Return the current HEAD SHA in the DAGs directory."""
|
| 63 |
+
try:
|
| 64 |
+
result = _run_git(["rev-parse", "--short", "HEAD"], cwd=DAGS_DIR, timeout=10)
|
| 65 |
+
return result.stdout.strip() if result.returncode == 0 else None
|
| 66 |
+
except Exception:
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _sync_repo() -> tuple[dict, int]:
|
| 71 |
+
"""Clone or update the DAG repository. Returns (response_body, status_code)."""
|
| 72 |
+
global _last_sync
|
| 73 |
+
|
| 74 |
+
os.makedirs(DAGS_DIR, exist_ok=True)
|
| 75 |
+
|
| 76 |
+
auth_url = _get_authenticated_url()
|
| 77 |
+
is_update = os.path.isdir(os.path.join(DAGS_DIR, ".git"))
|
| 78 |
+
|
| 79 |
+
if is_update:
|
| 80 |
+
# Update remote URL in case PAT token changed
|
| 81 |
+
_run_git(["remote", "set-url", "origin", auth_url], cwd=DAGS_DIR)
|
| 82 |
|
| 83 |
+
fetch = _run_git(["fetch", "origin", DAG_REPO_BRANCH], cwd=DAGS_DIR)
|
| 84 |
+
if fetch.returncode != 0:
|
| 85 |
+
logger.error("git fetch failed: %s", fetch.stderr)
|
| 86 |
+
return {"status": "error", "message": f"git fetch failed: {fetch.stderr}"}, 500
|
| 87 |
|
| 88 |
+
reset = _run_git(["reset", "--hard", f"origin/{DAG_REPO_BRANCH}"], cwd=DAGS_DIR)
|
| 89 |
+
if reset.returncode != 0:
|
| 90 |
+
logger.error("git reset failed: %s", reset.stderr)
|
| 91 |
+
return {"status": "error", "message": f"git reset failed: {reset.stderr}"}, 500
|
| 92 |
+
action = "updated"
|
| 93 |
+
else:
|
| 94 |
+
clone = _run_git(
|
| 95 |
+
["clone", "--depth", "1", "--branch", DAG_REPO_BRANCH, auth_url, DAGS_DIR],
|
| 96 |
+
timeout=120,
|
| 97 |
+
)
|
| 98 |
+
if clone.returncode != 0:
|
| 99 |
+
logger.error("git clone failed: %s", clone.stderr)
|
| 100 |
+
return {"status": "error", "message": f"git clone failed: {clone.stderr}"}, 500
|
| 101 |
+
action = "cloned"
|
| 102 |
+
|
| 103 |
+
sha = _get_head_sha()
|
| 104 |
+
_last_sync = {
|
| 105 |
+
"action": action,
|
| 106 |
+
"sha": sha,
|
| 107 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
logger.info("DAGs %s — commit %s", action, sha)
|
| 111 |
+
return {
|
| 112 |
+
"status": "success",
|
| 113 |
+
"message": f"DAGs {action} successfully",
|
| 114 |
+
"repo": DAG_REPO_URL,
|
| 115 |
+
"branch": DAG_REPO_BRANCH,
|
| 116 |
+
"sha": sha,
|
| 117 |
+
}, 200
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---- Request logging ----
|
| 121 |
+
@app.before_request
|
| 122 |
+
def log_request():
|
| 123 |
+
logger.info("%s %s", request.method, request.path)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---- Endpoints ----
|
| 127 |
+
@app.route("/refresh", methods=["GET", "POST"])
|
| 128 |
def refresh_dags():
|
| 129 |
+
"""Pull latest DAGs from git. Uses a file lock to prevent concurrent syncs."""
|
| 130 |
try:
|
| 131 |
+
lock_fd = open(LOCK_FILE, "w")
|
| 132 |
+
acquired = False
|
| 133 |
+
try:
|
| 134 |
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
| 135 |
+
acquired = True
|
| 136 |
+
except BlockingIOError:
|
| 137 |
+
return jsonify({"status": "busy", "message": "A sync is already in progress"}), 429
|
| 138 |
+
|
| 139 |
+
if acquired:
|
| 140 |
+
body, status = _sync_repo()
|
| 141 |
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
| 142 |
+
lock_fd.close()
|
| 143 |
+
return jsonify(body), status
|
| 144 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
except subprocess.TimeoutExpired:
|
| 146 |
+
return jsonify({"status": "error", "message": "Git operation timed out"}), 504
|
| 147 |
except Exception as e:
|
| 148 |
+
logger.exception("Unexpected error during refresh")
|
| 149 |
+
return jsonify({"status": "error", "message": str(e)}), 500
|
| 150 |
|
| 151 |
|
| 152 |
+
@app.route("/health")
|
| 153 |
def health():
|
| 154 |
+
"""Liveness probe — also checks if Airflow webserver is reachable."""
|
| 155 |
+
airflow_ok = False
|
| 156 |
+
try:
|
| 157 |
+
import urllib.request
|
| 158 |
+
resp = urllib.request.urlopen(f"http://127.0.0.1:{AIRFLOW_WEB_PORT}/health", timeout=5)
|
| 159 |
+
airflow_ok = resp.status == 200
|
| 160 |
+
except Exception:
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
+
return jsonify({
|
| 164 |
+
"status": "healthy",
|
| 165 |
+
"airflow_webserver": "up" if airflow_ok else "starting",
|
| 166 |
+
}), 200
|
| 167 |
|
| 168 |
|
| 169 |
+
@app.route("/config")
|
| 170 |
def show_config():
|
| 171 |
+
"""Return non-sensitive configuration values."""
|
| 172 |
+
return jsonify({
|
| 173 |
+
"dags_dir": DAGS_DIR,
|
| 174 |
+
"repo_url": DAG_REPO_URL,
|
| 175 |
+
"branch": DAG_REPO_BRANCH,
|
| 176 |
+
"airflow_web_port": AIRFLOW_WEB_PORT,
|
| 177 |
+
"refresh_service_port": REFRESH_SERVICE_PORT,
|
| 178 |
+
}), 200
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@app.route("/status")
|
| 182 |
+
def status():
|
| 183 |
+
"""Return the last sync status, current git SHA, and uptime."""
|
| 184 |
return jsonify({
|
| 185 |
+
"last_sync": _last_sync or "no sync yet",
|
| 186 |
+
"current_sha": _get_head_sha(),
|
| 187 |
+
"repo": DAG_REPO_URL,
|
| 188 |
+
"branch": DAG_REPO_BRANCH,
|
|
|
|
| 189 |
}), 200
|
| 190 |
|
| 191 |
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
port = int(REFRESH_SERVICE_PORT)
|
| 194 |
+
app.run(host="0.0.0.0", port=port)
|
requirements.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
apache-airflow[postgres]==2.10.3
|
| 2 |
-
apache-airflow-providers-postgres
|
| 3 |
-
apache-airflow-providers-ssh
|
| 4 |
-
|
| 5 |
-
gunicorn
|
| 6 |
-
gevent
|
| 7 |
-
|
|
|
|
| 1 |
apache-airflow[postgres]==2.10.3
|
| 2 |
+
apache-airflow-providers-postgres==5.13.1
|
| 3 |
+
apache-airflow-providers-ssh==3.14.0
|
| 4 |
+
psycopg2-binary==2.9.10
|
| 5 |
+
gunicorn==23.0.0
|
| 6 |
+
gevent==24.11.1
|
| 7 |
+
cryptography>=42.0.0
|
supervisord.conf
CHANGED
|
@@ -9,38 +9,58 @@ loglevel=info
|
|
| 9 |
command=/usr/sbin/nginx -g "daemon off;"
|
| 10 |
autostart=true
|
| 11 |
autorestart=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
stderr_logfile=/dev/stderr
|
| 13 |
stderr_logfile_maxbytes=0
|
| 14 |
stdout_logfile=/dev/stdout
|
| 15 |
stdout_logfile_maxbytes=0
|
| 16 |
priority=5
|
| 17 |
|
| 18 |
-
[program:
|
| 19 |
-
command=
|
| 20 |
autostart=true
|
| 21 |
autorestart=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
stderr_logfile=/dev/stderr
|
| 23 |
stderr_logfile_maxbytes=0
|
| 24 |
stdout_logfile=/dev/stdout
|
| 25 |
stdout_logfile_maxbytes=0
|
| 26 |
-
priority=
|
| 27 |
|
| 28 |
-
[program:airflow-
|
| 29 |
-
command=airflow
|
| 30 |
autostart=true
|
| 31 |
autorestart=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
stderr_logfile=/dev/stderr
|
| 33 |
stderr_logfile_maxbytes=0
|
| 34 |
stdout_logfile=/dev/stdout
|
| 35 |
stdout_logfile_maxbytes=0
|
| 36 |
-
priority=
|
| 37 |
|
| 38 |
-
[program:
|
| 39 |
-
command=
|
| 40 |
autostart=true
|
| 41 |
autorestart=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
stderr_logfile=/dev/stderr
|
| 43 |
stderr_logfile_maxbytes=0
|
| 44 |
stdout_logfile=/dev/stdout
|
| 45 |
stdout_logfile_maxbytes=0
|
| 46 |
-
priority=
|
|
|
|
| 9 |
command=/usr/sbin/nginx -g "daemon off;"
|
| 10 |
autostart=true
|
| 11 |
autorestart=true
|
| 12 |
+
startsecs=5
|
| 13 |
+
startretries=3
|
| 14 |
+
stopwaitsecs=10
|
| 15 |
+
stopasgroup=true
|
| 16 |
+
killasgroup=true
|
| 17 |
stderr_logfile=/dev/stderr
|
| 18 |
stderr_logfile_maxbytes=0
|
| 19 |
stdout_logfile=/dev/stdout
|
| 20 |
stdout_logfile_maxbytes=0
|
| 21 |
priority=5
|
| 22 |
|
| 23 |
+
[program:refresh-service]
|
| 24 |
+
command=gunicorn --bind 127.0.0.1:5000 --workers 1 --worker-class gevent --timeout 120 --chdir / refresh_service:app
|
| 25 |
autostart=true
|
| 26 |
autorestart=true
|
| 27 |
+
startsecs=3
|
| 28 |
+
startretries=5
|
| 29 |
+
stopwaitsecs=10
|
| 30 |
+
stopasgroup=true
|
| 31 |
+
killasgroup=true
|
| 32 |
stderr_logfile=/dev/stderr
|
| 33 |
stderr_logfile_maxbytes=0
|
| 34 |
stdout_logfile=/dev/stdout
|
| 35 |
stdout_logfile_maxbytes=0
|
| 36 |
+
priority=10
|
| 37 |
|
| 38 |
+
[program:airflow-webserver]
|
| 39 |
+
command=airflow webserver
|
| 40 |
autostart=true
|
| 41 |
autorestart=true
|
| 42 |
+
startsecs=10
|
| 43 |
+
startretries=5
|
| 44 |
+
stopwaitsecs=30
|
| 45 |
+
stopasgroup=true
|
| 46 |
+
killasgroup=true
|
| 47 |
stderr_logfile=/dev/stderr
|
| 48 |
stderr_logfile_maxbytes=0
|
| 49 |
stdout_logfile=/dev/stdout
|
| 50 |
stdout_logfile_maxbytes=0
|
| 51 |
+
priority=20
|
| 52 |
|
| 53 |
+
[program:airflow-scheduler]
|
| 54 |
+
command=airflow scheduler
|
| 55 |
autostart=true
|
| 56 |
autorestart=true
|
| 57 |
+
startsecs=10
|
| 58 |
+
startretries=5
|
| 59 |
+
stopwaitsecs=30
|
| 60 |
+
stopasgroup=true
|
| 61 |
+
killasgroup=true
|
| 62 |
stderr_logfile=/dev/stderr
|
| 63 |
stderr_logfile_maxbytes=0
|
| 64 |
stdout_logfile=/dev/stdout
|
| 65 |
stdout_logfile_maxbytes=0
|
| 66 |
+
priority=30
|