astro-airflow · diff
v1.0.0 to v1.0.0
3 added, 3 removed. Audit A to A.
---
name: astro-airflow
description: "Inspect and debug Airflow on Astronomer (Astro) deployments — fetch DAG runs, task instance logs, container logs, env vars, and deployment state without installing an MCP plugin. Use when the user mentions Astro/Astronomer, asks about DAG runs or task logs on staging/prod, says 'check the deployment', references `astro deployment`, `make airflow`, an Astro deployment ID, or a *.astronomer.run URL. Pairs the official `astro` CLI for platform ops with direct Airflow REST API calls for DAG-level data."
license: MIT
metadata:
author: vanducng
version: "1.0.0"
upstream: "https://docs.astronomer.io/astro/cli/overview"
---
# astro-airflow
Read-only debugging surface for Airflow on Astro. No MCP plugin install required — uses `astro` CLI for what it exposes (container logs, env vars, deployment metadata) and `curl` against the deployment's Airflow REST API for what it doesn't (DAG runs, task instance logs).
## When to use
- User asks "what failed in `<dag_id>`" or "why is `<dag_id>` stuck" on staging/prod
- User says "check the deployment logs", "fetch task log", "clear failed tasks" on Astro
- User references an Astro deployment ID (e.g. `cmgjr4hyl001l01lrepyhz604`) or a `*.astronomer.run/<id>` URL
- A repo's CLAUDE.md mentions Astro deployments, `make airflow CMD=...`, or `astro dev start`
- Need to compare staging vs prod env vars / connections / variables
- Debugging a CI deploy that hit Astronomer
**When NOT to use:** local-only Airflow questions where `make airflow CMD=...` or the local Astro container suffices — use those directly. This skill is for *remote* (staging/prod) inspection.
## Prerequisites
- `astro` CLI installed and logged in (`astro login <org-hostname>`) — verify with `astro context list`
- `curl` and `jq` (both standard)
- A **Deployment API token** for the target deployment, stored in gopass. Mint via Astro UI → Deployment → Access → API Tokens. Use a custom least-privilege role with `deployment.get` + `deployment.airflow.*.get` permissions for read-only debugging.
- Deployment **webserver URL** (e.g. `https://<org>.astronomer.run/<deployment-short-id>/`) — find via `astro deployment inspect <deployment-id> --key metadata.airflow_api_url`
## Core: `astro` CLI for platform ops
### Discover deployments
```bash
astro deployment list # all deployments in current workspace
astro deployment inspect <deployment-id> # full deployment metadata (JSON)
astro deployment inspect <deployment-id> --key metadata.airflow_api_url
astro deployment inspect <deployment-id> --key metadata.workload_identity
```
### Container logs (scheduler / api-server / triggerer / workers)
Container-level logs — Python tracebacks, scheduler errors, OOM kills. **Not** per-task logs. Component is a **boolean flag** (`--scheduler`, `--apiserver`, `--triggerer`, `--workers`, `--webserver`), not `--component <name>`.
```bash
astro deployment logs <deployment-id> --scheduler # default last 500 lines
astro deployment logs <deployment-id> --scheduler --log-count 100 # tail N lines
astro deployment logs <deployment-id> --scheduler --keyword "ImportError"
astro deployment logs <deployment-id> --scheduler --error # level filter only
astro deployment logs <deployment-id> --apiserver # Airflow 3.x API server
astro deployment logs <deployment-id> --webserver # Airflow 2.x web UI
astro deployment logs <deployment-id> --triggerer
astro deployment logs <deployment-id> --workers --keyword "OOMKilled"
```
**Limitation:** Cannot combine `--error` (level) and `--keyword` (text) in the same call — pick one. To do both, run `--keyword "ERROR"` and grep the output for what you actually want.
Use these when:
- Scheduler isn't picking up DAGs (look for parse errors) — `--scheduler --keyword "ImportError\|Broken DAG"`
- Triggerer is crashing (async task issues) — `--triggerer --error`
- Workers OOM — `--workers --keyword "OOMKilled"`
- DAG import errors not visible in UI — `--scheduler --keyword "Broken DAG"`
### Environment variables
```bash
astro deployment variable list --deployment-id <id> # list all (values redacted by default)
astro deployment variable list --deployment-id <id> -s # include secret values (sensitive!)
astro deployment variable create --deployment-id <id> KEY=value # create or update
astro deployment variable update --deployment-id <id> KEY=value
astro deployment variable copy --source-id <staging> --target-id <prod> # promote
```
### Deploy state
```bash
astro deployment hibernate <id> --force # pause compute
astro deployment wake-up <id> # resume
astro deployment pool list --deployment-id <id> # worker pool sizing
```
## Core: Airflow REST API for DAG runs + task logs
Astro CLI does not expose per-task logs or structured DAG run state. Hit the deployment's Airflow REST API directly. **Airflow 3.x = `/api/v2/`**, **Airflow 2.x = `/api/v1/`**.
### Setup helpers (use once per session)
Any Astro API token works as `Authorization: Bearer` for a deployment's Airflow API — **Deployment**
(narrowest, preferred for prod automation), **Workspace** (any deployment in the workspace), or
**Organization**. Verified June 2026: the CNB repo's `ASTRO_WORKSPACE_API_KEY` (in `cnb-ds-astro/.env`)
hits prod's `/api/v2/...` fine. The same value works for the `astro` CLI via `export ASTRO_API_TOKEN=...`.
```bash
# Token: gopass deployment token, OR the repo's workspace key (never echo it)
export ASTRO_TOKEN="$(gopass show -o <path/to/deployment-token>)"
# export ASTRO_TOKEN="$(grep -E '^ASTRO_WORKSPACE_API_KEY=' /path/to/repo/.env | cut -d= -f2-)"
# Deployment webserver URL
export AF_URL="https://<org>.astronomer.run/<deployment-short-id>"
# Reusable curl wrapper
af() { curl -fsSL -H "Authorization: Bearer ${ASTRO_TOKEN}" "${AF_URL}$1"; }
```
### List + inspect DAG runs
```bash
# Recent runs across all DAGs (Airflow 3.x)
af "/api/v2/dags/~/dagRuns?limit=20&order_by=-start_date" | jq '.dag_runs[] | {dag_id, run_id, state, start_date}'
# Runs for a specific DAG
af "/api/v2/dags/<dag_id>/dagRuns?limit=10&order_by=-start_date" | jq
# Only failed runs in a date window
af "/api/v2/dags/<dag_id>/dagRuns?state=failed&start_date_gte=2026-05-01T00:00:00Z" | jq
# Single run detail
af "/api/v2/dags/<dag_id>/dagRuns/<run_id>" | jq
```
### Task instances for a run
```bash
# All task instances in a run
af "/api/v2/dags/<dag_id>/dagRuns/<run_id>/taskInstances" \
| jq '.task_instances[] | {task_id, state, try_number, duration, start_date}'
# Only the failed ones
af "/api/v2/dags/<dag_id>/dagRuns/<run_id>/taskInstances?state=failed" | jq
# Specific task instance
af "/api/v2/dags/<dag_id>/dagRuns/<run_id>/taskInstances/<task_id>" | jq
```
### Task instance log (Airflow 3.x: structured events, NOT a string)
**Gotcha (verified June 2026, Airflow 3.1):** the log endpoint returns
`{"content": [ {event, timestamp, sources, ...}, ... ], "continuation_token": "..."}`.
`content` is a **list of event objects**, not a string — `jq -r '.content'` yields `null`/nothing
and looks like an "empty log". Iterate the list and read `.event`. (Only Airflow 2.x `/api/v1/`
returned `.content` as a plain string.) **URL-encode the run_id** — scheduled IDs contain `+`/`:`
(`scheduled__2026-06-24T10:00:00+00:00`).
```bash
# Airflow 3.x: extract the real log lines (skip ::group:: UI fold markers)
af "/api/v2/dags/<dag_id>/dagRuns/<run_id>/taskInstances/<task_id>/logs/<try_number>?full_content=true" \
| jq -r '.content[] | select(type=="object") | .event' | grep -v '^::' | tail -n 200
# Stream a long/running task: replay continuation_token as ?token=<...> until it stops advancing
af ".../logs/<try>?full_content=true" | jq -r '.continuation_token' # pass back as &token=
```
Running tasks DO return logs mid-run. `full_content=true` returns the full first block; without it
you only get the first chunk. For a long, slow task (e.g. a big backfill), polling Snowflake/state
for progress is cheaper than streaming the whole log.
### Other useful read endpoints
```bash
af "/api/v2/dags?limit=50&only_active=true" # list DAGs
af "/api/v2/dags/<dag_id>" # DAG detail
af "/api/v2/dags/<dag_id>/details" # parsed DAG (schedule, tasks, etc.)
af "/api/v2/importErrors" # DAG parse errors
af "/api/v2/connections" # list connections (no secrets)
af "/api/v2/variables" # list Airflow Variables (values included — careful)
af "/api/v2/pools" # slot pool status (find queue starvation)
af "/api/v2/monitor/health" # scheduler / metadata DB health
```
### Mutations (opt-in — only when the user explicitly asks; skill is read-only by default)
The `af()` wrapper is GET-only; use a write wrapper. Airflow 3.x removed the `dags set-run-state` CLI,
so terminate runs via the API.
```bash
afw() { curl -fsSL -X "$1" -H "Authorization: Bearer ${ASTRO_TOKEN}" -H "Content-Type: application/json" "${AF_URL}$2" -d "$3"; }
afw PATCH "/api/v2/dags/<dag_id>?update_mask=is_paused" '{"is_paused": false}' # unpause (needed before a new DAG's first trigger)
afw POST "/api/v2/dags/<dag_id>/dagRuns" '{"dag_run_id":"manual__e2e","logical_date":null}' # trigger now
afw PATCH "/api/v2/dags/<dag_id>/dagRuns/<run_id>" '{"state":"failed"}' # terminate/clear a run (URL-encode run_id)
```
**`max_active_runs=1` gotcha:** unpausing a DAG can spawn one scheduled run for the latest interval
(catchup=false → just one), so a manual trigger then sits **queued** behind it — two runs in the UI
≠ two running concurrently. To run only one, terminate the redundant (preferably the *queued* one, so
no in-flight work is lost).
## Decision tree
```
User wants... → Use
─────────────────────────────────────────────────────────────────────
"why did this run fail" → REST: dagRuns → taskInstances?state=failed → logs/<try>
"any failed DAGs today" → REST: /dagRuns?state=failed&start_date_gte=...
- "scheduler is broken / DAGs not parsing" → astro CLI: deployment logs --component scheduler
+ "scheduler is broken / DAGs not parsing" → astro CLI: deployment logs --scheduler
"task log for try 2 of X" → REST: /taskInstances/X/logs/2
- "worker OOM" → astro CLI: deployment logs --component workers --keyword OOMKilled
+ "worker OOM" → astro CLI: deployment logs --workers --keyword OOMKilled
"what env vars are set" → astro CLI: deployment variable list
"copy staging vars to prod" → astro CLI: deployment variable copy
- "DAG won't import" → REST: /importErrors AND astro logs --component scheduler
+ "DAG won't import" → REST: /importErrors AND astro logs --scheduler
"compare schedule/tasks staging vs prod" → REST: /dags/<id>/details on both URLs
"pool is starved" → REST: /pools
"trigger a backfill" / "clear failed" → DO NOT — read-only token. Ask user to use Astro UI or `make airflow`.
```
## Patterns Claude should use
### Investigate "DAG X failed" end-to-end
```bash
export ASTRO_TOKEN="$(gopass show -o <path/to/staging-token>)"
export AF_URL="https://<org>.astronomer.run/<deployment-short-id>"
af() { curl -fsSL -H "Authorization: Bearer ${ASTRO_TOKEN}" "${AF_URL}$1"; }
# 1. Get most recent failed run
RUN_ID=$(af "/api/v2/dags/<dag_id>/dagRuns?state=failed&limit=1&order_by=-start_date" \
| jq -r '.dag_runs[0].run_id')
# 2. Find failed task(s)
af "/api/v2/dags/<dag_id>/dagRuns/${RUN_ID}/taskInstances?state=failed" \
| jq '.task_instances[] | {task_id, try_number}'
# 3. Pull the log for the failed task's last try (substitute TASK_ID and TRY from step 2)
af "/api/v2/dags/<dag_id>/dagRuns/${RUN_ID}/taskInstances/<task_id>/logs/<try>" \
| jq -r '.content' | tail -n 200
```
### Compare staging vs prod env vars
```bash
diff \
<(astro deployment variable list --deployment-id <staging-id> -s | sort) \
<(astro deployment variable list --deployment-id <prod-id> -s | sort)
```
### Tail scheduler logs for parse errors after a deploy
```bash
astro deployment logs <id> --scheduler --log-count 200 \
--keyword "ImportError\|SyntaxError\|Broken DAG"
```
## Safety rules
- **Read-only by default.** This skill never triggers DAGs, clears tasks, or mutates state via API. If the user needs that, point them to the Astro UI or local `make airflow CMD='dags trigger ...'`.
- **Token discipline.** Pull from gopass. Never echo `$ASTRO_TOKEN` to stdout. Never write it to a file outside the gopass store. Never commit to git.
- **Distinct tokens per environment.** Don't reuse one token for staging + prod — different blast radii.
- **`--keyword` is regex, not glob.** Escape special chars (`.`, `(`, `|`).
- **Log fetch is heavy.** Task logs on Astro can be multi-MB (Kubernetes pod logs in S3). Always tail with `| tail -n 200` unless explicitly asked for full log.
- **Don't paste secret values into chat** when listing variables — summarize names only.
- **Confirm deployment ID before mutating** any env var. Astronomer offers no undo for `variable update`.
## Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| `401 Unauthorized` | token expired or wrong scope | Re-mint in Astro UI, update gopass |
| `403 Forbidden` on POST endpoints | token uses `WORKSPACE_MEMBER` role (POST blocked by auth proxy) | Use `DEPLOYMENT_ADMIN` or a custom role with required perms |
| `404` on `/api/v2/...` | deployment runs Airflow 2.x, not 3.x | Try `/api/v1/...` |
| `astro deployment logs` flag error `unknown flag: --component` | wrong CLI syntax | Use boolean flags: `--scheduler`, `--apiserver`, `--triggerer`, `--workers`, `--webserver` |
| `cannot query for more than one log level and/or keyword at a time` | combining `--error` + `--keyword` | Run one, grep the output for the other |
| `astro deployment logs` returns nothing | wrong component flag for Airflow version | Airflow 3.x uses `--apiserver`, not `--webserver` |
| Empty `dag_runs` array | DAG never ran, or `start_date_gte` filter too narrow | Drop the filter, check `paused` state on the DAG |
| Log fetch returns truncated content | response paginated via `continuation_token` | Loop with `?token=<continuation_token>` until empty |
| `astro` CLI returns "context not found" | not logged into the right org | `astro context list && astro context switch <name>` |
## Discovery
```bash
astro context list # which org/workspace am I in
astro deployment list # available deployments
astro version # CLI version (some flags need ≥ 1.30)
astro deployment inspect <id> | jq # everything about a deployment
# Find an Airflow API endpoint surface
af "/api/v2/" # 3.x root
af "/api/v1/" # 2.x root
```
## References
- Astro CLI: https://docs.astronomer.io/astro/cli/overview
- Astro CLI command reference: https://docs.astronomer.io/astro/cli/reference
- Astro Deployment API tokens: https://docs.astronomer.io/astro/deployment-api-tokens
- Custom Deployment roles: https://docs.astronomer.io/astro/customize-deployment-roles
- Airflow 3.x REST API: https://airflow.apache.org/docs/apache-airflow/stable/stable-rest-api-ref.html
- For a structured MCP alternative (adds a plugin to the deployment image, more setup), see Astronomer's `astro-airflow-mcp` package.