Subham9126 commited on
Commit
494a23b
·
verified ·
1 Parent(s): 826918e

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +47 -16
  2. config.sh +57 -18
  3. entrypoint.sh +126 -34
  4. nginx.conf +53 -10
  5. refresh_service.py +173 -55
  6. requirements.txt +6 -6
  7. 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
- python3 \
8
- python3-pip \
9
- python3-venv \
10
- nginx \
11
- git \
12
- supervisor \
13
- curl \
 
14
  && rm -rf /var/lib/apt/lists/* \
15
  && useradd -m -u 1000 airflow
16
 
17
- RUN mkdir -p /var/log/supervisor /var/log/nginx /run/nginx /var/lib/nginx /opt/airflow/dags \
18
- && chown -R airflow:airflow /opt/airflow /var/log/supervisor /var/log/nginx /run/nginx /var/lib/nginx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 -r /tmp/requirements.txt
 
 
26
 
27
- COPY --chown=airflow:airflow config.sh /config.sh
28
- COPY --chown=airflow:airflow supervisord.conf /etc/supervisord.conf
29
- COPY --chown=airflow:airflow nginx.conf /etc/nginx/nginx.conf
30
- COPY --chown=airflow:airflow entrypoint.sh /entrypoint.sh
31
- COPY --chown=airflow:airflow refresh_service.py /refresh_service.py
 
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 - SINGLE SOURCE OF TRUTH
4
  # ===========================================
5
- # All configurable values are defined here with defaults
6
- # Override by setting environment variables or HF Secrets
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 Connection (Set via HF Secret for production) ----
14
- export AIRFLOW__DATABASE__SQL_ALCHEMY_CONN="${AIRFLOW__DATABASE__SQL_ALCHEMY_CONN:-postgresql://postgres.ffetkispaxytayrnmxvj:uKInpRkBXsCUfVg1@aws-1-ap-south-1.pooler.supabase.com:5432/postgres}"
 
 
 
15
 
16
- # Prevent DB Connection Exhaustion on Hugging Face / Supabase Free Tier
17
- export AIRFLOW__DATABASE__SQL_ALCHEMY_POOL_SIZE="1"
18
- export AIRFLOW__DATABASE__SQL_ALCHEMY_MAX_OVERFLOW="1"
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 (Set via HF Secret for production) ----
 
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
- # ---- Airflow Core Settings ----
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
- # ---- Airflow Webserver Settings ----
 
 
 
 
 
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:-hf-spaces-default-secret-key-12345}"
45
- export AIRFLOW__WEBSERVER__WORKERS="1"
 
 
 
 
 
 
 
 
 
 
46
 
47
- # ---- Airflow Scheduler Settings ----
48
- export AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL="${AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL:-30}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 -e
3
 
4
- echo "=== Loading configuration ==="
 
 
 
 
 
 
 
5
  source /config.sh
 
 
 
 
 
 
 
 
 
 
6
 
7
- echo "Configuration:"
8
- echo " DAG_REPO_URL: ${DAG_REPO_URL}"
9
- echo " DAG_REPO_BRANCH: ${DAG_REPO_BRANCH}"
10
- echo " DAGS_DIR: ${DAGS_DIR}"
11
- echo " AIRFLOW_WEB_PORT: ${AIRFLOW_WEB_PORT}"
12
- echo " REFRESH_SERVICE_PORT: ${REFRESH_SERVICE_PORT}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- echo ""
15
- echo "=== Syncing DAG repository ==="
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}" "${DAG_REPO_URL}" "${DAGS_DIR}"
19
- echo "Repository cloned successfully"
 
 
 
 
20
  else
21
  cd "${DAGS_DIR}"
22
- git fetch origin "${DAG_REPO_BRANCH}" && git reset --hard "origin/${DAG_REPO_BRANCH}"
23
- echo "Repository updated successfully"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  fi
25
 
26
- echo ""
27
- echo "=== Running database migrations ==="
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
- DAGS_DIR = os.environ.get('AIRFLOW_HOME', '/opt/airflow') + '/dags'
8
- DAG_REPO_URL = os.environ.get('DAG_REPO_URL', 'https://github.com/subhamgiri460/myworkflows.git')
9
- DAG_REPO_BRANCH = os.environ.get('DAG_REPO_BRANCH', 'main')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
 
 
 
 
11
 
12
- @app.route('/refresh', methods=['GET', 'POST'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  def refresh_dags():
 
14
  try:
15
- if os.path.exists(os.path.join(DAGS_DIR, '.git')):
16
- fetch_result = subprocess.run(
17
- ['git', 'fetch', 'origin', DAG_REPO_BRANCH],
18
- cwd=DAGS_DIR, capture_output=True, text=True, timeout=60
19
- )
20
- if fetch_result.returncode != 0:
21
- return jsonify({
22
- 'status': 'error',
23
- 'message': f'Git fetch failed: {fetch_result.stderr}'
24
- }), 500
25
-
26
- reset_result = subprocess.run(
27
- ['git', 'reset', '--hard', f'origin/{DAG_REPO_BRANCH}'],
28
- cwd=DAGS_DIR, capture_output=True, text=True, timeout=60
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({'status': 'error', 'message': 'Operation timed out'}), 500
54
  except Exception as e:
55
- return jsonify({'status': 'error', 'message': str(e)}), 500
 
56
 
57
 
58
- @app.route('/health')
59
  def health():
60
- return jsonify({'status': 'healthy'}), 200
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
- @app.route('/config')
64
  def show_config():
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  return jsonify({
66
- 'dags_dir': DAGS_DIR,
67
- 'repo_url': DAG_REPO_URL,
68
- 'branch': DAG_REPO_BRANCH,
69
- 'airflow_web_port': os.environ.get('AIRFLOW_WEB_PORT', '8080'),
70
- 'refresh_service_port': os.environ.get('REFRESH_SERVICE_PORT', '5000')
71
  }), 200
72
 
73
 
74
- if __name__ == '__main__':
75
- port = int(os.environ.get('REFRESH_SERVICE_PORT', 5000))
76
- app.run(host='0.0.0.0', port=port)
 
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
- flask
5
- gunicorn
6
- gevent
7
- requests
 
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:airflow-webserver]
19
- command=airflow webserver
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=20
27
 
28
- [program:airflow-scheduler]
29
- command=airflow scheduler
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=30
37
 
38
- [program:refresh-service]
39
- command=gunicorn --bind 127.0.0.1:5000 --workers 1 --worker-class gevent --chdir / refresh_service:app
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=10
 
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