Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cc23b0b
feat: create airflow api functions based on version
botanical Sep 11, 2026
0794589
fix: functions and formatting
botanical Sep 11, 2026
d03fdc2
fix: update promotion scripts to use airflow functions
botanical Sep 11, 2026
e158b0b
fix: comment for airflows
botanical Sep 11, 2026
e891646
fix: modify based on feedback
botanical Sep 15, 2026
eb49ffe
feat: create workflow for testing
botanical Sep 16, 2026
fe5ccd5
feat: update env vars to emphasize testing
botanical Sep 16, 2026
eed6ca9
fix: add pythonpath to env
botanical Sep 16, 2026
545ac9e
fix: add test collections
botanical Sep 16, 2026
3bc9168
fix: add environment to pick up github envs
botanical Sep 16, 2026
14f98f6
fix: add oauth2 support for aiflow 3
botanical Sep 16, 2026
c9538bd
fix: add keycloak env vars and call to token endpoint
botanical Sep 16, 2026
31577a2
fix: typos and fail gh jobs if status code not 200
botanical Sep 16, 2026
9580f88
fix: attempt to log error message for login
botanical Sep 16, 2026
1f38629
fix: token endpoint
botanical Sep 16, 2026
75820d5
fix: update grant type, remove username and password for oauth2
botanical Sep 16, 2026
67758f4
fix: oauth2 headers
botanical Sep 16, 2026
6546e82
fix: check keycloak status before extracting token
botanical Sep 16, 2026
089ecdc
fix: logging, trigger another run
botanical Sep 17, 2026
b18635b
fix: logging for jq
botanical Sep 17, 2026
0596b35
fix: linting issues
botanical Sep 17, 2026
225bbdb
fix: consolidate logging
botanical Sep 17, 2026
10a88af
fix: update to generate jwt token
botanical Sep 17, 2026
4e281ed
fix: add pyjwt to requirements.txt
botanical Sep 17, 2026
eda3347
fix: add not before claim
botanical Sep 17, 2026
3f986da
fix: add aud claim
botanical Sep 17, 2026
46f7dba
fix: update audience
botanical Sep 18, 2026
a3eee71
fix: try using different audience
botanical Sep 18, 2026
a1f4787
fix: get sub from env vars
botanical Sep 18, 2026
1bbb426
fix: pass in jwt sub
botanical Sep 18, 2026
11776b3
fix: logical date setting
botanical Sep 18, 2026
b25f3e8
fix: add run id and fix call to build request body
botanical Sep 18, 2026
3f50928
fix: cleanup commented out code
botanical Sep 18, 2026
5087977
fix: update based on feedback and add some docs
botanical Sep 22, 2026
46588f4
Apply batched suggestions from code review
botanical Sep 22, 2026
714e57f
fix: update docs a bit
botanical Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/promote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
SM2A_ADMIN_USERNAME: ${{ secrets.SM2A_ADMIN_USERNAME }}
SM2A_ADMIN_PASSWORD: ${{ secrets.SM2A_ADMIN_PASSWORD }}
SM2A_API_URL: ${{ vars.SM2A_API_URL }}
PROMOTION_DAG: ${{ vars.PROMOTION_DAG_NAME }}
PROMOTION_DAG_NAME: ${{ vars.PROMOTION_DAG_NAME }}

run: |
pip install -r ./scripts/requirements.txt
Expand All @@ -91,5 +91,7 @@ jobs:
echo "$collection_id ($config_type) successfully promoted to Production ✅"
else
echo "$collection_id ($config_type) failed to promote to Production ❌"
failed=1
fi
done
[ "${failed:-0}" -eq 0 ] || exit 1
69 changes: 69 additions & 0 deletions .github/workflows/test-promotion-script.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Test promotion script

on:
push:
branches: ['feat/airflow3-flag']
paths:
# Run the workflow only if files inside this path are updated
- 'scripts/**'
- 'tests/**'
- '.github/workflows/test-promotion-script.yml'

jobs:
test-promotion-script:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Python
uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
with:
python-version: '3.9'

- name: Install dependencies
run: pip install -r ./scripts/requirements.txt

- name: Test promotion script (Airflow 3)
env:
STAGING_SM2A_API_URL: ${{ vars.TESTING_AIRFLOW3_SM2A_API_URL }}
STAGING_SM2A_ADMIN_USERNAME: ${{ secrets.TESTING_AIRFLOW3_SM2A_ADMIN_USERNAME}}
STAGING_SM2A_ADMIN_PASSWORD: ${{ secrets.TESTING_AIRFLOW3_SM2A_ADMIN_PASSWORD}}
STAGING_AIRFLOW_API_VERSION: "3"
AIRFLOW_JWT_SECRET: ${{ secrets.AIRFLOW_JWT_SECRET }}
AIRFLOW_JWT_SUB: ${{ secrets.AIRFLOW_JWT_SUB }}
PYTHONPATH: .
run: |
response=$(python3 scripts/promote_collection.py tests/test_collection.json staging)
status_code=$(echo "$response" | jq -r '.statusCode' | head -n1)
echo "Status Code: $status_code"

# Update status message based on response code
if [[ $status_code -eq 200 ]] || [[ $status_code -eq 201 ]]; then
echo "Airflow 3 test passed"
else
error_msg=$(echo "$response" | jq -r '.error')
echo "$collection_id failed to publish ❌"
echo "Error: $error_msg"
exit 1
fi

- name: Test promotion script (Airflow 2)
env:
STAGING_SM2A_API_URL: ${{ vars.TESTING_AIRFLOW2_SM2A_API_URL }}
STAGING_SM2A_ADMIN_USERNAME: ${{ secrets.TESTING_AIRFLOW2_SM2A_ADMIN_USERNAME}}
STAGING_SM2A_ADMIN_PASSWORD: ${{ secrets.TESTING_AIRFLOW2_SM2A_ADMIN_PASSWORD}}
STAGING_AIRFLOW_API_VERSION: "2"
PYTHONPATH: .
run: |
response=$(python3 scripts/promote_collection.py tests/test_collection_tenant.json staging)
status_code=$(echo "$response" | jq -r '.statusCode' | head -n1)
echo "Status Code: $status_code"

# Update status message based on response code
if [[ $status_code -eq 200 ]] || [[ $status_code -eq 201 ]]; then
echo "Airflow 2 test passed"
else
echo "$collection_id failed to publish ❌"
exit 1
fi

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can delete this file once this PR is reviewed!

11 changes: 11 additions & 0 deletions docs/README.md
Comment thread
jjfrench marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

# README

## Environment Variables Reference for Collection and Dataset Promotion
Comment thread
anayeaye marked this conversation as resolved.

| Variable | Used by | Default | Notes |
| -- | -- | -- | -- |
| STAGING_AIRFLOW_API_VERSION | Staging Promotion | 2 | The Airflow Version for the Staging Environment. For Airflow 2 = API v1 + Basic auth, Airflow 3 = API v2 + JWT |
| PRODUCTION_AIRFLOW_API_VERSION | Production Promotion | 2 | Same as above |
| AIRFLOW_JWT_SECRET | Any Promotion step that uses Airflow 3 | none | Conditionally required when the resolved API version is 3. Must match the secret that the Airflow 3 deployment signs with. Can be found in Secrets Manager |
| AIRFLOW_JWT_SUB | Airflow 3 only | none | Conditionally required when the resolved API verison is 3. The subject claim for the token (the Integer ID from Airflow's user table) |
123 changes: 123 additions & 0 deletions scripts/airflow_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""
Supports Airflow 2 and 3 (API v1 / v2)
"""

import os
import uuid
import json
import http.client

from typing import Dict, Any
from base64 import b64encode


class AirflowAPIError(Exception):
def __init__(self, msg, status_code=None):
super().__init__(msg)
self.status_code = status_code

pass


def _build_request_body(
conf: Dict[str, Any], dag_id: str, note: str = "", api_version: str = "2"
) -> Dict[str, Any]:
"""Build the request body for the DagRun"""
run_id = os.getenv("GITHUB_RUN_ID")
if run_id:
run_url = (
f"{os.getenv('GITHUB_SERVER_URL')}/{os.getenv('GITHUB_REPOSITORY')}"
f"/actions/runs/{run_id}"
)
default_note = f"Run from GitHub Actions: {run_url}"
else:
default_note = "Run from GitHub Actions veda-data workflow"

body = {
"conf": conf,
"dag_run_id": f"{dag_id}-{uuid.uuid4()}",
"note": note or default_note,
}
Comment thread
botanical marked this conversation as resolved.
if api_version != "2":
body["logical_date"] = None
return body


def _generate_jwt_token(secret: str, sub: str, expiration_time: int = 3600) -> str:
"""Generate a HS512 JWT Token for Airflow API authentication"""
import jwt
import time

payload = {
"iss": "airflow",
"sub": sub,
"aud": "apache-airflow",
"nbf": int(time.time()),
"iat": int(time.time()),
"exp": int(time.time()) + expiration_time,
}
return jwt.encode(payload, secret, algorithm="HS512")


def trigger_dag_run(
base_api_url: str,
dag_id: str,
conf: Dict[str, Any],
username: str,
password: str,
api_version: str = None,
) -> Dict[str, Any]:
"""
Trigger a DAG run with version-aware API handling

Raises: AirflowAPIError if it fails
"""

if api_version is None:
api_version = os.getenv("AIRFLOW_API_VERSION") or "2"

api_path = f"/api/v{'1' if api_version == '2' else '2'}/dags/{dag_id}/dagRuns"
request_body = _build_request_body(conf, dag_id, api_version=api_version)

if api_version == "2":
api_token = b64encode(f"{username}:{password}".encode()).decode()
headers = {
"Content-Type": "application/json",
"Authorization": "Basic " + api_token,
}
else:
jwt_secret = os.getenv("AIRFLOW_JWT_SECRET")
if not jwt_secret:
raise AirflowAPIError("AIRFLOW_JWT_SECRET environment variable not set")
jwt_sub = os.getenv("AIRFLOW_JWT_SUB")
if not jwt_sub:
raise AirflowAPIError("AIRFLOW_JWT_SUB environment variable not set")
access_token = _generate_jwt_token(jwt_secret, jwt_sub)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}

try:
http_conn = http.client.HTTPSConnection(base_api_url)
http_conn.request("POST", api_path, json.dumps(request_body), headers)
response = http_conn.getresponse()
response_data = response.read()
http_conn.close()

if response.status >= 400:
raise AirflowAPIError(
f"Airflow API v{api_version} returns "
f"{response.status}: {response_data.decode()}",
status_code=response.status,
)

return {
"statusCode": response.status,
"body": response_data.decode(),
}

except AirflowAPIError:
raise
except Exception as e:
raise AirflowAPIError(f"Failed to trigger the DAG run: {str(e)}")
Comment thread
jjfrench marked this conversation as resolved.
59 changes: 26 additions & 33 deletions scripts/promote_collection.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from typing import Dict, Any

import http.client
import json
import sys
import os
import uuid
from base64 import b64encode

from airflow_api import AirflowAPIError, trigger_dag_run


def trigger_collection_dag(payload: Dict[str, Any], stage: str):
Expand All @@ -18,10 +17,12 @@ def trigger_collection_dag(payload: Dict[str, Any], stage: str):
api_url_env = "STAGING_SM2A_API_URL"
username_env = "STAGING_SM2A_ADMIN_USERNAME"
password_env = "STAGING_SM2A_ADMIN_PASSWORD"
api_version_env = "STAGING_AIRFLOW_API_VERSION"
elif stage == "production":
api_url_env = "SM2A_API_URL"
username_env = "SM2A_ADMIN_USERNAME"
password_env = "SM2A_ADMIN_PASSWORD"
api_version_env = "PRODUCTION_AIRFLOW_API_VERSION"
else:
raise ValueError(
f"Invalid stage provided: {stage}. Must be 'staging' or 'production'."
Expand All @@ -30,38 +31,24 @@ def trigger_collection_dag(payload: Dict[str, Any], stage: str):
base_api_url = os.getenv(api_url_env)
username = os.getenv(username_env)
password = os.getenv(password_env)
api_version = os.getenv(api_version_env) or "2"

if not all([base_api_url, username, password]):
raise ValueError(
f"Missing one or more environment variables: "
f"stage is None={stage is None}, "
f"username is None={username_env is None}, "
f"password is None={password_env is None}"
)

api_token = b64encode(f"{username}:{password}".encode()).decode()
raise ValueError(f"Missing required environment variables for stage '{stage}' ")

headers = {
Comment thread
botanical marked this conversation as resolved.
"Content-Type": "application/json",
"Authorization": "Basic " + api_token,
}

body = {
**payload,
"dag_run_id": f"{dag_name}-{uuid.uuid4()}",
"note": "Run from GitHub Actions veda-data",
}
http_conn = http.client.HTTPSConnection(base_api_url)
http_conn.request(
"POST", f"/api/v1/dags/{dag_name}/dagRuns", json.dumps(body), headers
)
response = http_conn.getresponse()
response_data = response.read()
http_conn.close()

print(json.dumps({"statusCode": response.status}))
print(response_data.decode())
return {"statusCode": response.status, "body": response_data.decode()}
try:
result = trigger_dag_run(
base_api_url=base_api_url,
dag_id=dag_name,
conf=payload.get("conf", {}),
username=username,
password=password,
api_version=api_version,
)
print(json.dumps({"statusCode": result["statusCode"], "body": result["body"]}))
return result
except AirflowAPIError as e:
print(json.dumps({"statusCode": e.status_code or 500, "error": str(e)}))


if __name__ == "__main__":
Expand All @@ -74,7 +61,13 @@ def trigger_collection_dag(payload: Dict[str, Any], stage: str):

except IndexError:
print("Usage: promote_collection.py <file_name> <stage>")
sys.exit(1)
except FileNotFoundError:
print(f"Error: File '{sys.argv[1]}' not found.")
sys.exit(1)
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON content in file {sys.argv[1]}")
print(f"Invalid JSON content in file {sys.argv[1]}")
sys.exit(1)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
Loading
Loading