Version v0.5.6 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.
CI/CD & deploy examples
Your first Pro DAG (≈10 min) takes one DAG from source
to a running pod with one command (dexaflow deploy), so you see each
artifact boundary before you automate it. This page is the CI recipes for the
same pipeline.
Deploying a Dexaflow DAG is the same everywhere because a DAG is an immutable
artifact: a dag.json + a container image, versioned together (ADR 0003).
By hand or in a pipeline, one command does it all:
dexaflow auth login --server https://pro.example.com # by hand, once; stores the session
dexaflow deploy --yes # compile → build → push → pin digest → register
dexaflow deploy is the pipeline-less promotion:
it builds for the cluster’s architecture (--platform, else build.platforms
in dexaflow.yaml, linux/amd64 when neither is set) with the first
builder that applies, pins the image by digest, and
registers the artifact.
--yes skips the confirmation prompt (use it in automation). It is the
recommended command in CI too, and the recipes below use it.
The explicit steps are still available when a pipeline wants the build and the register as separate stages. Both paths register the image by digest:
flowchart LR E[edit dag.py + dexaflow.yaml] --> D[dexaflow deploy] D --> R[control plane: registry/image@sha256:...] E -.-> C[dexaflow compile --build --push] C -.->|dag.json pinned| P[dexaflow push dag.json] P -.-> R
dexaflow compile --build --push: parsedag.py, overlaydexaflow.yaml, run the guardrails (unknowntask_id, unsupported operator, duplicate keys), build the DAG image, push it, and write the pushed image’s digest intodag.jsonasregistry/image@sha256:….dexaflow push dag.json: register the artifact with the control plane.
compile --build --push reads the digest of the image it pushed (the same read
deploy makes, with the same registry credentials)
and writes registry/image@sha256:… into dag.json, so push registers the
bytes that were built. When only that read fails (the push succeeded), the
compile keeps the tag in dag.json and warns with the reason, and push
tries the read again; deploy stops instead, since it registers only a
digest. push pins too: when dag.json names a tag (a file
compiled by an older CLI, or an image another tool pushed), it resolves the tag
in the registry and registers the digest it names at that moment. push --image <ref> registers another image than the one in dag.json, a tag resolved the
same way or a <repository>@sha256:<digest> used as given. When the registry
cannot be read, push registers the tag as written with a warning that says
why, and a server under deploy.require_digest: enforce
refuses it with an error that names the reason and the fix. dexaflow deploy
remains the recommended command: it adds the preflight, the plan and the
reuse of an image already built.
The same checks that warn you locally in dexaflow lite fail the CI build, so a
bad dag_id/task_id binding or an unsupported operator never reaches prod.
The first Pro DAG walkthrough runs deploy by
hand so you can watch the DAG cross each boundary. Come back here to wire the
same command into CI.
Registry auth, RBAC scope, non-root images, private-registry pulls, and the
--dag-version/409 gotcha are exactly the errors CI hits first, since a
runner has none of a laptop’s ambient state (no docker login, no
dexaflow auth login session, often a shallow clone with no tags for the
default version label to start from). Deploy prerequisites & why shortcuts fail
has the exact error and fix for each.
Your repo of DAGs (CI is the recommended path)
In Pro you keep your DAGs in a Git repository, one directory per DAG, and CI
turns each into an artifact when it changes. This is the recommended path: a push
triggers the pipeline, no one builds by hand. Each DAG is just two files, no
Dockerfile and no requirements.txt:
my-dags/ # your Git repo, a workspace
├── dexaflow.workspace.yaml # targets and shared defaults (registry, ...)
├── .github/workflows/
│ └── dexaflow-deploy.yaml # the pipeline `dexaflow ci init github` writes
├── ci/
│ └── dexaflow-deploy.sh # the deploy step every generated pipeline runs
└── dags/
├── my_pipeline/
│ ├── dag.py # the DAG (TaskFlow / operators)
│ └── dexaflow.yaml # id, python_version, dependencies, registry
└── another_pipeline/
├── dag.py
└── dexaflow.yaml
dexaflow deploy (and compile --build) generates the Dockerfile from
dexaflow.yaml: FROM the published Dexaflow base, your deps/connectors
installed, your DAG copied in. No Dockerfile to maintain. A Dockerfile in the
DAG directory is used verbatim instead and the yaml’s dependencies then do not
reach the image, so ship one only if you want full control (see
your own Dockerfile).
The built image is your artifact: push it wherever you like (Docker Hub, ECR,
Artifact Registry, ACR, GHCR, a private registry). deploy needs a registry
prefix from one of --registry, DEXAFLOW_REGISTRY, the registry: block of
dexaflow.yaml, the target, the workspace defaults, your config file or the
cluster’s deploy defaults
(Registry, platforms and base image),
and names the image after the dag_id unless registry.image_name says
otherwise; it has no --image flag. The only image Dexaflow owns is the base
your DAG layers on.
The mental model: a push to main runs one pipeline, which deploys only the
DAG projects that push changed (deploy --all --changed-since <before>, see
Deploying only what changed): each one is
compiled, built from its yaml, pushed to your registry and registered. The
control plane runs the new version on the next trigger. The projects deploy one
after the other, and a broken another_pipeline does not stop my_pipeline
from deploying; the job still fails, naming the DAG that did not deploy.
dexaflow ci init writes that
pipeline for you.
The first registration of a DAG leaves it paused, as in Airflow: its
schedule creates no run until someone unpauses it (UI switch, or
dexaflow admin dags unpause <dag_id>). Later registrations never change that,
so a pipeline that redeploys an active DAG keeps it active. A DAG that should
start active sets is_paused_upon_creation=False; see
New DAGs start paused.
What a deploy says, and the flags that change it
Every deploy prints the server it goes to (and the target that chose it) before it builds, and ends with a line saying what happens next:
next: etl is paused, so nothing runs until `dexaflow admin dags unpause etl`; unpausing creates 13 catchup runs since start_date 2026-10-01T00:00:00Z, then one run per tick; next tick 2026-10-14T00:00:00Z (UTC).
An active DAG gets its next run time instead. The line follows the scheduler:
with catchup=True and a start_date, every tick since start_date gets a
run; otherwise only the latest tick does. A DAG that already ran resumes after
its last run, so its count is an upper bound, as it is for --active on a DAG
that was paused. Times are in UTC: the scheduler does not yet apply a DAG’s
timezone or end_date, so the line ignores them too.
With no server set anywhere (no --server, --target, DEXAFLOW_SERVER_URL
or server_url), deploy stops before building instead of going to
http://localhost:8080; a machine set up for Lite keeps the local default.
Checks before the build (preflight)
A deploy that the server or the registry would refuse fails in seconds,
before the build, with a message that names what to fix. deploy checks, in
this order:
- Before the compile: the control plane answers and the token may
deploy. A token the server refuses (
401) namesdexaflow auth login; a token without thedeployerrole,deployorwriteon dags, or an API token without thedeployscope (403) namesdexaflow auth create-token --scope deploy. With--pausedor--activethe check also covers what those flags need (writeon dags and, for an API token, thedexaflow:runscope), and the refusal says so. A server that cannot be reached stops the deploy here. - Before the build: the registry accepts a push to the image’s
repository with the credentials the daemonless build paths push with:
the docker config (
~/.docker/config.jsonand its credential helpers) and, for an Artifact Registry, GCR, ECR or ACR host, the session of thegcloud,awsorazCLI, asregistry.auth_methodsays (see Registry credentials). The line says where the credential came from. The check starts an upload and cancels it (best effort: a registry that refuses the cancel expires the empty upload), so no image is written. A refusal of the credential found stops adockerdeploy, naming the login command of the registry (gcloud auth configure-docker <host>,aws ecr get-login-password --region <region> | docker login ...,az acr login --name <name>, ordexaflow registry login <registry>). With no credential found, or with another--builder(podman, for one, keeps credentials of its own inauth.json), a refusal is anote:naming the command for that builder, and why the cloud CLI gave no token when it could not (not onPATH, not signed in), and the push decides. Nothing is checked when nothing is pushed: with--skip-build, or when the registry already holds the image for the content label. - After the compile, before the build: the server’s dry run of the
register. The compiled
dag.jsongoes toPOST /api/v2/dags/{dag_id}/versions/dry_run, which runs every check the register runs (spec validation, the operator’s image, service account and field allowlists, the resource unit, source mode, the tenant’s limits, the declared connections and variables, the version label) and writes nothing. The register gets the image pinned by digest, which the build makes later. When nothing is built (--skip-build, or the registry already holds the image for the content label) the dry run is sent the digest the registry holds for the tag, the very image the register gets, so an allowlist that names that exact digest accepts it. Before a build it is sent the repository at a placeholder digest, so an allowlist of repositories or ofrepo@*decides as for the register. A refusal stops the deploy with the server’s reason, except two the built image decides when it is still to be built. One is a refusal of the placeholder image when it is the only refusal and the allowlist names the image’s repository at a digest (<repo>@sha256:...), which a reproducible build may match; a repository the allowlist does not cover still stops the deploy. The other is a version label registered with other content. Each of those is anote:, and the register answers it. So is a dry run the server could not answer (5xx, or429under load): the deploy goes on and the register runs the checks. A warning (a task that does not fit its size underexecutor.unit.enforce: warn) prints on stderr and the deploy goes on. - With the dry run: what the task pods will run as. The dry run
answer also says whether the server runs task pods as non-root
(
taskPodSecurity.runAsNonRoot) and, for each ServiceAccount a task pod would run as (the task’sexecution.service_account, else the chart’s task ServiceAccount, else the namespace’sdefault), whether it exists in the task namespace and how manyimagePullSecretsit carries. Both used to fail only when a task pod started.- A ServiceAccount that does not exist stops the deploy, naming it and
its tasks: the pods of those tasks would not be created. Set
execution.service_accountto one that exists, or have the operator create it (taskServiceAccount.createin the Helm values). The namespace’sdefaultServiceAccount is the exception: Kubernetes creates it in every namespace a moment after the namespace, so a missingdefaultis anote:and the deploy goes on. - A ServiceAccount with no
imagePullSecretsis anote:: a private registry is then pulled only with the nodes’ own credentials (IRSA, Workload Identity, a credential helper), which the server cannot see. The operator lists a pull secret intaskServiceAccount.imagePullSecretsotherwise. A ServiceAccount the server could not look up is anote:with the reason: the control plane reads the account withgetonserviceaccountsin the task namespace, which the chart grants (ADR 0085), except in a split control plane, whose API role has no access to the cluster. - When the project builds from its own
Dockerfile, the deploy reads theUSERits final stage ends with (the one of the stage it is builtFROM, when that is a stage of the same file). On a server that runs task pods as non-root,USER root,USER 0or a user name (USER app, which the kubelet cannot check is not root) stops the deploy and names the fix: a numeric non-root id such asUSER 65532:65532. NoUSER(the base image decides) or a build variable (USER $UID) is anote:. A server that does not run task pods as non-root takes anyUSER. When the server does not say (an older server, or no dry run), root or a name is anote:. The Dockerfile generated fromdexaflow.yamlalways ends withUSER 65532:65532and is not read; nothing is read with--skip-buildor in source mode, which build nothing.
- A ServiceAccount that does not exist stops the deploy, naming it and
its tasks: the pods of those tasks would not be created. Set
Each check that passes prints a preflight: line (on stderr with
--output json). A server from before the dry run route answers it with
404 and registers nothing, also during a rolling upgrade when one replica
is older: the deploy prints a note: and its policy checks run at the
register, after the build, as before. dexaflow doctor runs the first two
checks for the target in use, refreshing a saved session as a deploy does.
| Flag | What it does |
|---|---|
--paused / --active | Leaves the DAG paused or active after this deploy, new or not, instead of the creation rule above. The register sets it in its own transaction, audited as dag.pause or dag.unpause; for content that is already current, nothing is registered and the DAG is paused or unpaused with the Airflow PATCH /api/v2/dags/{dag_id}. Pausing is a run-state write: these flags need write:dag (the deployer role alone is refused with 403), and a scoped token needs dexaflow:run as well as dexaflow:deploy, so a deploy-only token is refused with 403. |
--plan | Says per DAG what a deploy would build, push and register, and why (content changed, already current, an older version, a new DAG), without running the parser, the builder or the register. Works with --all, and in source mode, where nothing is built or pushed. |
--changed-since <ref> | With --all, deploys only the projects whose files changed since a git ref, and lists the others as skipped. See Deploying only what changed. |
--output json | Prints one JSON document on stdout for the pipeline to read; the human lines and the builder’s output go to stderr. |
The JSON document has one entry per DAG:
{
"server": "https://pro.example.com",
"target": "prod",
"dags": [
{
"dag_id": "my_pipeline",
"status": "registered",
"version": "v1.4.0-3f9c2a1b7d4e",
"image": "ghcr.io/acme/my_pipeline@sha256:4e1f...",
"digest": "sha256:4e1f...",
"spec_hash": "9b0c...",
"created": true,
"dag_created": true,
"is_paused": true,
"next": "my_pipeline is paused, so nothing runs until ...",
"execution": {
"warm_pools": true,
"tasks": [
{ "task_id": "extract", "mode": "warm" },
{ "task_id": "load", "mode": "dedicated", "reason": "node_selector set" }
],
"pool": { "state": "on", "detail": "2 idle warm workers (up to 5) ...", "min_idle_workers": 2, "max_pool_size": 5 }
}
}
]
}
created says a new version was registered and dag_created that it was
the DAG’s first. status is registered (a new version), unchanged (the server already had
this exact content), current (the content label is the current version, so
nothing was built), skipped (unchanged since the --changed-since ref, with
reason), or failed (one DAG of --all, with error). With
--plan it is planned or refused, and a plan object carries build,
push, register and reason, and source_mode: true for a deploy in
source mode. links and
checks carry what the server added to the register’s answer, when it added
any (Links and checks from the server).
After a failure of --all the document is still printed, so the pipeline
sees which DAGs went through.
execution is the server’s answer on where each task runs: mode is warm
(warm-eligible) or dedicated, with the reason for a dedicated pod, and
pool.state is off, none or on, with previous_pool: "draining" for a
new version of an existing DAG on a server that keeps warm workers: the
previous version’s pool drains, and the new one starts at the next reconcile
when the DAG declares min_idle_workers, else with its first run (see
Prewarm and drain on deploy). The human output
prints it as an execution line and, unless every task takes a dedicated pod
for one reason, a warm pool line; see
Where a deploy’s tasks run.
A server from before the field sends none, and the entry has no execution.
--plan does not run the parser, so it has no tasks to report on.
Links and checks from the server
A server, or a platform in front of it, may add two optional fields to the
answer of the register and of its dry run: a links object (a name for each
URL, such as the page of the new version in its own UI) and a checks list
(what it checked, and the result). Each check is an object with a name, a
status and an optional detail, or a plain string. The engine sends
neither; deploy prints whatever it gets, the links sorted by name and the
checks in the order given, under the preflight: line of the dry run and
under the Deployed line of the register:
Deployed my_pipeline -> https://pro.example.com
image ghcr.io/acme/my_pipeline@sha256:4e1f...
registered version v1.4.0-3f9c2a1b7d4e
link console: https://console.example.com/dags/my_pipeline
check lint: passed (0 findings)
Only http and https links are kept; any other (a javascript:, file:
or relative URL) is dropped. Before printing, control characters (C0 and C1)
are removed, and so are format characters (bidirectional overrides, zero-width
characters) and line and paragraph separators, so a server cannot drive the
terminal or disguise a link. With --output json, the register’s links and
checks are fields of the DAG’s entry, absent when the server sent none.
How the server recognizes the CLI
Every request the CLI sends to a control plane, from any command (each HTTP
client it builds sets the headers in its transport), carries
User-Agent: dexaflow-cli/<version>, and X-Dexaflow-CI: true when one of
the usual CI variables is set (CI, GITHUB_ACTIONS or GITLAB_CI, to
anything but empty, false or 0), so a server can tell an interactive
deploy from a pipeline’s. Neither header grants anything: authorization
still comes from the token alone.
The pipeline dexaflow ci init writes stays the recommendation: it deploys
only the DAGs a change touched (--changed-since), and a broken DAG does not
stop the others from deploying.
dexaflow build [workspace] is for the times you are not in that pipeline:
after a base-image bump that every DAG must pick up, when you clone the repo onto
a new machine, or when you want to see all the images a change produces before
pushing anything. It walks the workspace and builds each project with the image
that project’s own registry: block derives (the same derivation deploy uses,
so nothing about naming is new):
dexaflow build my-dags/ # build every project under my-dags/
dexaflow build my-dags/ --push # ...and push each to its registry
Two behaviours matter more than the loop itself:
- A project that declares no
registry:is reported by name, not skipped quietly. A command that covers less than the workspace without saying so leaves you believing images exist. - A failure stops the run, names the project, and lists what was already built. A half-built workspace (some images new, some stale) is invisible afterwards.
With no --dag-version / --sha it derives them the same way deploy does,
per project: the version label from git describe in the project’s directory
plus a hash of that project’s content, and the short commit hash of that
directory. (deploy has no --sha flag at all; it always derives the sha.)
Using it as your CI, in place of deploy --all --changed-since, is a
deliberate trade: one job rebuilds everything on any change, so build time
grows with the repo, and it only builds: nothing is registered. Choose it only
if you want that.
Deploying only what changed
In a repository with many DAG projects, deploy --all --changed-since <ref>
deploys only the projects whose files differ from a git ref, so a commit that
touches one DAG does not compile the others:
dexaflow deploy --all --changed-since origin/main --yes
==> orders: dags/orders/dag.py changed since origin/main
==> skipping billing (unchanged since origin/main)
==> deploying orders (/repo/dags/orders)
...
Deployed 1 DAG(s) from /repo (1 unchanged since origin/main, skipped)
A project counts as changed when a file its image or its version label is
built from changed: the DAG source, the include_paths (shared code included),
the dbt project and dbt_groups directories, dbt.manifest, its
dexaflow.yaml (or leoflow.yaml), its .dockerignore, and its own
Dockerfile (with a project Dockerfile, every file of the project directory the
.dockerignore keeps). These are the files the content label hashes, so a
skipped project is one whose label would not change. A file that exclude_paths
leaves out, or that the generated Dockerfile does not copy (a README.md next
to dag.py, say), changes nothing. A change to the workspace file
(dexaflow.workspace.yaml, which holds the shared defaults and the targets)
counts as a change to every project.
How the comparison works:
- The ref is compared with the working tree, not with
HEADand not from a merge base: committed, staged, unstaged and untracked files all count, and so do submodules, whateverdiff.ignoreSubmodulessays. A change on the ref’s side since your branch started counts too, so the comparison errs toward deploying. - A file git ignores cannot be compared. A project built from one (a
generated module under
include_paths, dbtdepsoutput) always counts as changed, and the output names the file. Inside a submodule, the submodule’s own ignore rules decide. So does a project built from a file outside the repository, or one whose folder resolves outside it. - The ref must be in the clone. A shallow clone (the default of
actions/checkout) stops with a message saying to fetch the history: usefetch-depth: 0, orgit fetch --unshallow. A ref that does not exist, or a folder outside a git repository, stops the deploy too; nothing is deployed by guess. - A project removed since the ref (its
dexaflow.yamlordag.pydeleted) is reported with itsdag_id, and nothing is deployed or deregistered for it: its DAG stays registered and scheduled until you pause it withdexaflow admin dags pause <dag_id>(or every such DAG at once withdexaflow sync <dir> --prune) or remove it withdexaflow dags delete <dag_id> --deregister. A project moved to another folder keeps itsdag_id: it is reported as moved, with no removal advice, and deployed from its new folder. Adag_idchanged in place is not reported: the project is deployed under its new id, and the DAG under the old id stays registered until you remove it. - It selects among the workspace’s projects, so it goes with
--all, or a baredexaflow deployat the workspace root. With adag_idor a path it is refused, rather than silently skipping the DAG you named. - What git does not see is not compared: the CLI version,
--registry,--platform, the target’s settings and the config file. After upgrading the CLI or changing a target, deploy with--allalone once.
With --output json, every skipped project has an entry with status: "skipped" and its reason, the removed ones are listed under removed
(path, dag_id, reason), and the moved ones under moved (dag_id,
from, to).
--changed-since and the content label solve different costs. Without the
flag, deploy --all already registers nothing for a project whose label is the
current version, and reuses an image the registry holds for its label
(ADR 0076); but it still
compiles every project and asks the server about each. --changed-since
skips those projects before any of that. Use it in the pipeline of a large
repository, on every push; use --all alone when the inputs git does not see
changed (above), or to check that every project is deployed.
The pipeline dexaflow ci init
writes does this on every push to main, and the generated files are under
Examples. What it passes to
--changed-since decides what a failed or cancelled run leaves behind:
- GitHub passes the commit of the last successful run of the workflow on
the branch (
gh run list --status success). A run that failed, or one theconcurrency:group cancelled while it waited, leaves that commit where it was, so the next run deploys its changes too. With no successful run yet, every DAG is deployed. - GitLab passes the commit of the last successful push pipeline on the
branch when the job has
DEXAFLOW_GITLAB_API_TOKEN, a project access token with theread_apiscope: the job token cannot read the pipelines API. Without it, the job passes the commit the branch was on before the push ($CI_COMMIT_BEFORE_SHA), and a failed, cancelled or skipped pipeline is not caught up by the next push, which compares with its own previous commit: rerun the failed pipeline, or run one by hand, which deploys every DAG. Turn off “Auto-cancel redundant pipelines” for the branch and set the process mode of thedexaflow-deployresource group tooldest_first, so pipelines deploy in push order. - Cloud Build has no previous commit to pass: it deploys every DAG, unless
you set
_DEXAFLOW_BASE_REFon a build whose source is a git clone.
Every system deploys all the DAGs when the base commit is all zeros (a new branch) or missing from the clone (after a force push).
In a pipeline of your own, compare with the last commit that deployed rather
than with the previous push: move a ref after each successful deploy (for
example git push -f origin HEAD:refs/tags/deployed-prod in a last step) and
pass --changed-since deployed-prod, with a concurrency: group on the job so
two runs never move it out of order.
Rolling back
A rollback makes a version the control plane already holds current again. It rebuilds nothing: the version runs the image digest it was registered with.
dexaflow dags versions my_pipeline # what is registered, and which is current
dexaflow rollback my_pipeline # back to the version before the current one
dexaflow rollback my_pipeline --to v1.4.0-3fa2c1 # or to a label (or a version number) from the list
dexaflow dags versions lists each version, newest first: its number, its
label, a * on the current one, when it was registered, the id of the user
who registered it (dexaflow admin users list maps ids to emails), the
commit it was built from (see Where a version was built)
and the short image digest. --to matches a label first, then a version number. dexaflow rollback prints the version it moved from and
the one it moved to, and takes --server and --token like the other
commands. Both read and write through the API: the listing is
GET /api/v2/dags/{dag_id}/dagVersions, and the rollback is
PUT /api/v2/dags/{dag_id}/currentVersion with {"version": "<label>"} or
{"version_number": <n>}, which needs the same permission as registering a
version (scope dexaflow:deploy for a scoped token).
A rollback passes the checks a register of the version would pass today.
Under deploy.require_digest: enforce, a version registered with a tag
cannot be made current again: redeploy its sources with dexaflow deploy, or
set the setting to warn (see
Require images pinned by digest).
What a rollback changes, in one transaction:
- New runs use the version made current, from the next scheduler tick.
- The schedule and the other DAG settings (catchup, start date, max active runs, description, owner, tags) become that version’s.
- Runs already created keep their version, including runs in flight.
- Pause state does not change. A paused DAG stays paused, an unpaused one stays unpaused.
What the UI shows after a rollback follows Airflow, which has no rollback and always runs its newest version:
- The DAG header’s Latest Dag Version and the version the Code tab
opens on are still the newest version, not the current one. The Code tab’s
version picker shows the source registered with each version
(
GET /api/v2/dagSources/{dag_id}?version_number=<n>; withoutversion_numberor with0, the newest version’s source). - A run, and each of its task instances, shows the version it ran with, so a run created after the rollback shows the version made current, and its Code tab opens on that version’s source.
- The Clear dialog offers “Run with latest bundle version” even on a run
that already uses the current version, because the UI compares the run’s
version with the newest one. In Dexaflow that option re-pins the run to the
current version, so on such a run it changes nothing. On a finished run
whose version adds or drops a task, the clear creates the added tasks’ task
instances and marks the dropped ones
removed. A run that is still running keeps its version, and the API response says the option was not applied (the dialog does not show it): see Clearing a run that is still running. - Which version is current is the
*indexaflow dags versions, and theis_currentfield of each version inGET /api/v2/dags/{dag_id}/dagVersionsand oflatest_dag_versionin the DAG details. The MCPdag://sourceresource serves the current version’s source, asdag://specserves its spec.
The version must still pass what a register of it would pass today: the
operator’s execution policy, the tenant limits, and its declared Variables and
Connections. A version that no longer does is refused with 400 (or 403 for
a tenant limit), and the current version stays. Naming the version that is
already current changes nothing. Every rollback is recorded in the audit log as
dag.version.set_current, with the user and the from and to versions.
Two things that look like a rollback are not:
- Re-running
dexaflow pushwith an olderdag.jsonis a no-op: content the control plane already has answerscreated: false, and the newer version stays current, schedule included. - Retagging an image in the registry changes nothing: a version registered by
deploynames its image by digest.
dexaflow deploy of code that matches a version registered earlier (after a
git revert, for example) stops before building and suggests the
dexaflow rollback <dag_id> --to <label> command for that version.
Where a version was built
Every dexaflow deploy and dexaflow push records where the version was
built. GET /api/v2/dags/{dag_id}/dagVersions (and .../dagVersions/{n}, and
the answer of a rollback) returns it as build_info next to Airflow’s fields,
null for a version registered without it; the dag_version embedded in task
instance, run and DAG details answers leaves it out:
| Field | Where the CLI reads it |
|---|---|
commit | git rev-parse HEAD in the project directory, when it is in a git work tree. |
repo | The origin remote of that work tree, as an https URL: credentials and a trailing .git are dropped, and an ssh remote (git@host:org/repo.git) becomes https://host/org/repo. Outside git, GITHUB_SERVER_URL/GITHUB_REPOSITORY or GitLab’s CI_PROJECT_URL. |
pipeline_url | The CI run: on GitHub Actions GITHUB_SERVER_URL/GITHUB_REPOSITORY/actions/runs/GITHUB_RUN_ID, else CI_PIPELINE_URL (GitLab), BUILD_URL (Jenkins), BUILDKITE_BUILD_URL or CIRCLE_BUILD_URL, without a query string or fragment. |
builder | The builder that produced the image, as the deploy prints it (code-layer, local-daemon (docker), …). Absent when the deploy built nothing: with --skip-build, and when it reused an image the registry already held for this content label. |
base_image | The base image of the generated Dockerfile, which carries the task agent. Absent when the project ships its own Dockerfile, and when the deploy built nothing (--skip-build, or a reused image). |
cli_version | The version of the CLI that built and registered the version. |
What cannot be read is left out; a deploy never fails for it. dexaflow promote
forwards the source version’s build_info to the destination.
Build info is metadata of the register, not part of dag.json: it changes
neither the spec, its hash nor the version label, so the same sources built by
two pipelines still compile to one version. A version is immutable, so a
re-push of content already registered creates nothing and keeps the build info
of its first register. The CLI sends it as the build_commit, build_repo,
build_pipeline_url, build_builder, build_base_image and
build_cli_version query parameters of
POST /api/v2/dags/{dag_id}/versions; another client can send them too. The
server checks them as external input (a commit is 7 to 64 lower case hex
characters, the two URLs are absolute http or https URLs of at most 2048
bytes without credentials, query string or fragment, every field is valid
UTF-8 without control characters) and
refuses a bad value with 400. The register’s dag.version.register audit
entry records the same build_info. It is the caller’s claim, recorded as
sent and not verified: a deploy token can write any commit there.
Syncing a target with the workspace (GitOps)
dexaflow sync <dir> makes the target in use run what a folder of DAG
projects holds, the way Argo CD or Flux make a cluster match a repository. It
compares every project under <dir> with the DAGs registered on the target,
says per DAG what it does, and does it:
dexaflow sync ./dags --dry-run --prune # what would change; exit 1 if anything would
dexaflow sync ./dags --prune --yes # make the target match
workspace: /repo/dags
target prod: https://pro.example.com (target prod, from --target)
DAG STATE CURRENT VERSION PAUSED ACTION
billing drift v1.3.0-4-g77aa01-1f0c (#4) no deploy v1.4.0-3fa2c1
intake not deployed - - deploy v1.4.0-9b0e12
orders behind v1.4.1-c41d2e (#7) no none: registered content is newer than the workspace; run with --rollback to make v1.4.0-0d11f9 current
reports in sync v1.4.0-5e6f70 (#2) no none
legacy not in workspace - no pause
old_etl not in workspace - yes none: already paused
dry run: 3 change(s) would be made (2 deploy, 1 pause); 1 behind, left as is (pass --rollback)
| State | What it means | What sync does |
|---|---|---|
in sync | The local content label is the current version. | Nothing. |
drift | The local content label is no registered version: the files changed, or the commit did (the label starts with git describe). | Deploys it. |
not deployed | The server does not know the DAG, or holds no current version of it. | Deploys it. |
behind | The local content label is a registered version older than the current one: the target was deployed from a newer commit than this checkout. | Reports it and leaves it. With --rollback, makes that version current again, as dexaflow rollback --to <label> does: nothing is built and is_paused is kept. |
not in workspace | The target holds a DAG no project under <dir> defines. | Reports it; with --prune, pauses it. |
error | The project could not be read (its dexaflow.yaml does not parse, say) or its DAG could not be read. | Nothing; the exit code is 2. |
A git revert does not make a DAG behind: the revert is a new commit, so
git describe and the label change, and the DAG is drift and deployed again
under the new label. A DAG is behind when the checkout itself is older than
what was deployed, for example when Argo CD syncs an earlier revision.
The comparison is the one dexaflow status
makes, and the label is the one dexaflow deploy computes, so “in sync” means
a deploy would build and register nothing. What goes into the label decides
when sync agrees with the job that deployed:
git describeof the project. A new commit moves the label even for a DAG whose files did not change: after each commit,syncdeploys every DAG once, asdeploy --allwould. Run anywhere else (a hook, a second clone), sync must see the same history and tags as the job that deployed, or every DAG reads asdrift.- The CLI version. Pin the same
dexaflowversion in CI and in the hook; after an upgrade every DAG reads asdriftonce. --registry,--platformand--dockerfile, and the target’s, the workspace’s and the cluster’s deploy defaults. Passsyncthe flags a deploy is given.- Only the default label. Sync deploys with the content label. A DAG
deployed with
--dag-versionnever reads asin sync, and sync would register it again under the content label: a team that labels its versions by hand should keep usingdeployand usesync --dry-run --pruneonly to find the DAGs to retire.
How each change is made:
- Deploys go through the same code as
dexaflow deploy --all: compile, build and push (or reuse the image the registry already holds for the content label), pin the digest, register. A new DAG is paused or active as the server decides for new DAGs (is_paused_upon_creation, thendags_are_paused_at_creation); a redeploy never changesis_paused. - Rollbacks (
--rollbackonly) make the matching version current withPUT /api/v2/dags/{dag_id}/currentVersion, as in Rolling back. --prunepauses, it never deletes:PATCH /api/v2/dags/{dag_id}withis_paused: true, undone withdexaflow admin dags unpause <dag_id>. A DAG that comes back to the workspace later is deployed again but stays paused, since a deploy never changesis_paused: unpause it yourself. To remove a DAG for good, usedexaflow dags delete <dag_id> --deregister.- A change that fails is reported on its DAG and the other changes still go through.
- At a terminal, sync shows the plan and asks before it changes anything
(
Apply 2 deploys and 1 pause on https://pro.example.com? [y/N]);--yesskips the question.--output jsonnever asks: pass--yesor--dry-run.
What --prune guards against:
- Every DAG the token can see is a candidate. Nothing marks which
workspace deployed a DAG yet, so every DAG on the target that no project
under
<dir>defines isnot in workspace, and--prunepauses a DAG deployed to the same target from another repository. Sync one folder per target (or per tenant), the folder that holds all of that target’s DAGs. - A project sync cannot read stops it. A
dexaflow.yamlthat does not parse hides the project’sdag_id(a project with adag.pyis found under its folder name, a dbt project not at all), so its DAG would read asnot in workspace. With--prune, a project inerrormakes sync apply nothing at all and exit 2; without--prunethe other changes go through. - A cap. With
--prune, sync applies nothing when it would pause more DAGs than--max-prune(by default, the number of projects under<dir>). - A confirmation. Without a terminal,
--pruneneeds--yes. - The DAG list. With
--prune, a list of the target’s DAGs that cannot be read changes nothing; without it, that is a warning and the deploys and rollbacks go through. - An empty folder is refused, so a wrong path never reads as “nothing should run”.
Targets resolve as for a command run in <dir>, whatever folder sync runs
in: --server, --target, DEXAFLOW_TARGET, DEXAFLOW_SERVER_URL, the
target selected for the workspace around <dir> (dexaflow target use), its
default_target, then server_url in the config file. The token is
--token, then DEXAFLOW_TOKEN, then the one saved for that server, and the
token decides the tenant. Like a deploy, sync refuses to guess the built-in
http://localhost:8080.
Credentials. A deploy token can
read, deploy and roll back, so it is enough for sync, sync --rollback and
sync --dry-run --prune. Pausing needs write on DAGs (and the
dexaflow:run scope for a scoped token), which the deployer role and a
deploy token do not have: sync --prune with one reports each pause as
failed (403) and exits 2.
Exit codes, for a CI check or a hook:
| Code | Meaning |
|---|---|
0 | The target matches the workspace (--dry-run), or every change was applied. |
1 | --dry-run found something to change, or a behind DAG was left alone (no --rollback). |
2 | The target or a project could not be read, a change failed, --prune refused to apply, the confirmation was declined, or the command was used wrongly. |
A DAG that is not in workspace counts as a change only with --prune, and
only while it is not paused, so sync --dry-run without --prune passes on a
target that also runs DAGs from elsewhere.
--output json prints one JSON document on stdout (the deploy lines go to
stderr): workspace, target, server, source, dry_run, prune, an
error when the target could not be read, refused when --prune applied
nothing, dags (each with dag_id, path for a project, state,
local_version, current_version, matched_version for behind,
is_paused, action (none, deploy, rollback or pause), result
(planned, applied or failed), the deploy result of a deployed DAG,
and error), a summary (in_sync, behind, deploy, rollback,
pause, not_in_workspace, errors) and the exit_code. Fields are only
ever added.
As a CI check. Run the dry run on a schedule, or after the deploy job, to catch a target changed by hand or a deploy that did not finish:
- name: Is prod running what main holds
env:
DEXAFLOW_SERVER_URL: https://pro.example.com
DEXAFLOW_TOKEN: ${{ secrets.DEXAFLOW_TOKEN }} # a deploy token
run: dexaflow sync ./dags --dry-run --prune
As an Argo CD hook. Argo CD runs a Job annotated as a hook after each
sync of an Application. The control plane never reads git (the DAGs are
built and registered from outside it), so the Job clones the repository at
the revision Argo CD synced and runs dexaflow sync itself. Argo CD hands a
Helm source the synced commit as $ARGOCD_APP_REVISION in its parameters, so
ship the Job as a small chart in the DAG repository, for example
deploy/dexaflow-sync/:
apiVersion: v2
name: dexaflow-sync
version: 0.1.0
revision: "" # set by Argo CD to the synced commit
repository: https://github.com/acme/dags
server: https://pro.example.com
image: registry.example.com/tools/dexaflow-cli:1.4.0 # your image: the CLI, git and Python
apiVersion: batch/v1
kind: Job
metadata:
generateName: dexaflow-sync-
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: sync
image: {{ .Values.image | quote }}
env:
- name: DEXAFLOW_SERVER_URL
value: {{ .Values.server | quote }}
- name: DEXAFLOW_TOKEN
valueFrom: { secretKeyRef: { name: dexaflow-sync, key: token } }
- name: REPOSITORY
value: {{ .Values.repository | quote }}
- name: REVISION
value: {{ required "revision is set by Argo CD" .Values.revision | quote }}
command: [sh, -c]
args:
- git clone --quiet "$REPOSITORY" /work &&
git -C /work checkout --quiet "$REVISION" &&
dexaflow sync /work/dags --rollback --prune --yes
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: dags
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/acme/dags
targetRevision: main
path: deploy/dexaflow-sync
helm:
parameters:
- name: revision
value: $ARGOCD_APP_REVISION
destination:
server: https://kubernetes.default.svc
namespace: dexaflow-sync
Notes on the hook:
- A full clone, with history and tags, so the labels are the ones CI
computed; the CLI image pins the same
dexaflowversion as CI. --rollback: syncing an earlier revision in Argo CD is a rollback, so the hook makes the older version current instead of stopping onbehind.- No builder in a pod. Let CI build and push the images
(
dexaflow deploy --all); the hook’s deploys then reuse the image the registry holds for each content label. A DAG whose image is missing fails with exit 2, which fails the hook. - No published CLI image yet: build one with the release binary,
gitand Python 3.11 to 3.13 (the compile parsesdag.py). --pruneneeds a token that may pause DAGs (see Credentials above). With a deploy token, drop--prunefrom the hook and rundexaflow sync ./dags --dry-run --prunein CI to report the DAGs to retire.
With Flux, which has no hooks and no revision to hand a Job, run sync
in the CI job that pushes the commit Flux deploys, right after
dexaflow deploy --all (or in its place), with --yes; and run
dexaflow sync ./dags --dry-run --prune on a schedule to catch drift.
Promoting between environments
dexaflow promote registers on one target the exact
version another target runs: the same dag.json and the same image digest.
Nothing is compiled or built, so what reaches production is what ran in
staging.
dexaflow promote my_pipeline --from staging --to prod --dry-run # what would be copied and registered
dexaflow promote my_pipeline --from staging --to prod # the current version of staging
dexaflow promote my_pipeline --from staging --to prod --version v1.4.0-3fa2c1 # or another version
- Which version. Without
--versionit is the source’s current version.--versionmatches a label first, then a version number fromdexaflow dags versions my_pipeline --target staging. - Only images pinned by digest. The version must run
<repo>@sha256:<digest>, as every versiondexaflow deploy, andcompile --build --pushfollowed bypush, registers. A version registered with a tag (by a CLI from before #1703, or by a push that could not read the registry) is refused: a tag can name other bytes tomorrow. - The image, copied when needed. The destination registry is
--registry, else theregistryof the--totarget, else thedeploy.defaults.registryits control plane advertises. When one is set and the image is not already in it, the image is copied there by digest, with every layer and platform, byte for byte (bytes that do not match their digest are refused), and the copy is checked in the destination afterwards. The repository path is kept:ghcr.io/acme-dev/team-a/etl@sha256:…promoted to a target whose registry isregistry.acme.dev/prodbecomesregistry.acme.dev/prod/acme-dev/team-a/etl@sha256:…(same digest). The copy is also tagged with the version label, unless that tag already names another digest there, which refuses the promote: a promote never moves a tag. The version is registered with the copy, and only itsimagefield differs from the source’sdag.json. When the destination already holds this version, nothing is copied. Without a destination registry the same reference is registered, and the destination cluster must be able to pull it. The copy reads and pushes with the credentials ofdocker loginand of a cloud CLI session, as a deploy does; a failure names the login command of each registry. - The normal register. The destination gets the version through
POST /api/v2/dags/{dag_id}/versions, after its dry run (/versions/dry_run): a refusal (the execution allowlist, the tenant limits, a label already registered there with other content), or any answer that is not a dry run other than an older server’s404or405, stops the promote before anything is copied. A DAG the promote creates starts paused if the server or the DAG says so (is_paused_upon_creation, elsedags_are_paused_at_creation), as a deploy’s would, and an existing DAG keepsis_paused. Content the destination already has answers that nothing changed. When both servers run the same Dexaflow version, the destination records the same spec hash as the source when the image is not copied. - Audited, as claimed. The register’s
dag.version.registeraudit entry carriespromoted_fromwith the source target’s name and server URL as the promoting CLI sent them (promoted_fromandpromoted_from_serveron the register). The destination records this claim; it does not verify it. The source version’sbuild_info(commit, pipeline, builder) is sent along, so the destination lists where the version was built. - A token for each control plane.
--from-tokenand--to-token, else the session saved for each server bydexaflow auth login --target <name>.DEXAFLOW_TOKENis not read, so one token is never sent to two control planes. The source side reads each version’s spec (GET /api/v2/dags/{dag_id}/dagVersions/{version_number}/spec), which holds the DAG’s source, environment literals and params, so it needs read on dags and, for an API token, thereadscope: a deploy-only token is refused there with403. The destination side needs only what a deploy needs: adeployeruser or a token with thedeployscope. - For pipelines.
--yesskips the confirmation prompt, and--output jsonprints one JSON document on stdout (the source and destination targets, servers, version and images, whether the image was copied, and the statusregistered,unchanged,plannedorfailed), with the human lines on stderr.
In a pipeline, a promotion job after the staging deploy:
- name: Promote to prod
env:
STAGING_TOKEN: ${{ secrets.DEXAFLOW_STAGING_TOKEN }}
PROD_TOKEN: ${{ secrets.DEXAFLOW_PROD_TOKEN }}
run: |
dexaflow promote my_pipeline --from staging --to prod --yes \
--from-token "$STAGING_TOKEN" --to-token "$PROD_TOKEN"
Require images pinned by digest
A version registered with a tag (ghcr.io/acme/etl:v1) runs whatever the tag
names when a node pulls it, so two nodes can run different bytes for one
version. The server setting deploy.require_digest
(DEXAFLOW_DEPLOY_REQUIRE_DIGEST, Helm deploy.requireDigest) decides what
the control plane does with an image that is not pinned as
<repository>@sha256:<64 hex> (sha256 only: another digest algorithm counts as
not pinned):
| Value | Register of an unpinned image | Making such a version current again |
|---|---|---|
off (default) | Registered, as every release before the setting did. | Allowed, as before. |
warn | Registered, with a warning in the answer’s warnings, which dexaflow push and dexaflow deploy print on stderr. | Allowed, with a warning that dexaflow rollback prints on stderr. |
enforce | Refused with a 400 that names the setting and how to pin. Nothing is registered. | Refused with a 400 that names the way out: redeploy the version’s sources with dexaflow deploy, or set warn. |
Under warn and enforce the server logs each image it warns about
(admitting an image not pinned by digest, with the DAG, the version, the
image and the stage) and counts each warning and refusal in
dexaflow_unpinned_image_total{stage="register|set_current",outcome="warned|refused"}.
The dag.version.register and dag.version.set_current audit entries record
require_digest and image_pinned. The dry run of a register (the deploy
preflight) answers with the same warning or refusal, and records nothing.
dexaflow deploy, dexaflow promote and dexaflow compile --build --push
followed by dexaflow push register pinned images (#1703). push resolves
a tag in dag.json to its digest before registering it, so a file compiled by
an older CLI is pinned too, as long as the registry can be read from where
push runs. When it cannot, push registers the tag with a warning, and under
enforce the refusal names why the image could not be pinned. Resolving a
tag reads the registry (for up to 30 seconds), with the credentials of
registry.auth_method in the project’s dexaflow.yaml: the one next to
dag.json, else the one in the working directory, else the one `–project
ref=ghcr.io/acme/my_pipeline:v1
digest=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r .digest)
repo=$ref
case ${ref##*/} in *:*) repo=${ref%:*} ;; esac # drop the tag, never a registry port
dexaflow push dag.json --image "$repo@$digest"
A CLI from before #1703 registers the tag that compile --build --push
pushed: use warn to find the pipelines that still do, and upgrade their CLI
or move them to dexaflow deploy.
Set enforce only once nothing on the cluster registers a tag any more: no
increase of outcome="warned" while every pipeline has run.
A source mode version names the runtime image, which
the server requires pinned by digest, so it passes every mode. Under
dexaflow lite, the local dev loop registers a placeholder image: leave the
setting off there.
Prerequisites
- The
dexaflowCLI on the runner (download the release binary, orgo install). - Python 3.11 to 3.13 on the runner (
dexaflow compileinvokes the stdlib-only parser shim, ADR 0024, to turndag.pyintodag.json). See Python on the runner below. - A container registry your cluster can pull from, and a credential for it on
the runner: the
gcloud,awsorazsession for Artifact Registry, ECR and ACR, the docker config (docker login, or a login action) for the others, and the container tool’s own login when it builds (Registry credentials). - The control plane URL, passed as
--serveror exported asDEXAFLOW_SERVER_URL(the pre-renameLEOFLOW_SERVER_URLstill works). The CLI reads no other variable for it: a pipeline that exports something else (DEXAFLOW_SERVER, for instance) silently targets the default,http://localhost:8080. - A CI credential for the control plane, below.
Credentials for the control plane
A CI job authenticates with a deploy token: a long-lived API token that
registers DAG versions and nothing else. A control plane that trusts your CI
provider takes the job’s own OIDC token instead, and no token is stored
(Deploying without a stored token). Store it as a CI secret named
DEXAFLOW_TOKEN; deploy, push, rollback and the other commands read that
variable (the pre-rename LEOFLOW_TOKEN still works).
Once, as an admin, create a service user for CI with the built-in
deployerrole. It reads DAGs and their versions, registers versions and sets the current version (rollback), and nothing else: it cannot delete or pause a DAG, trigger a run, or touch connections, variables or users.dexaflow auth create-user --email ci-deploy@example.com --password-stdin \ --role deployer < password.txtCreate a deploy token for that user. Sign in as the service user (the command signs in with
--usernameand the password, or uses the sessiondexaflow auth loginsaved), name the token after the pipeline that will hold it, and choose its lifetime:dexaflow auth create-token --server https://pro.example.com \ --username ci-deploy@example.com --password-stdin \ --scope deploy --name github-my-dags --ttl 90d < password.txtThe token (it starts with
dxf_) is printed once, alone on stdout; the name, id, scopes and expiry go to stderr. Store it as theDEXAFLOW_TOKENsecret of the pipeline. The server keeps only its SHA-256 hash, so a lost token cannot be shown again: create a new one.Set
DEXAFLOW_SERVER_URLandDEXAFLOW_TOKENin the job and rundexaflow deploy, as in the examples below.
What a deploy token can do, and how it ends:
- Scope.
--scope deploycarriesdexaflow:deploy: the token reads DAGs, DAG details and versions, registers versions and sets the current version, and is refused everything else, whatever its owner’s roles: no variables, connections, logs, XComs, runs, users or event log. It never does more than its owner’s roles allow either, and the owner’s roles are read again on every request.--scope readmakes a token that reads everything its owner can read, so do not give one to CI.deploy --triggerneeds to trigger a run, which a deploy token cannot do. - Lifetime.
--ttltakes days (90d) or a duration (12h), from one hour to 365 days; the default is 90 days. An expired token answers401. - Listing.
dexaflow auth tokens listshows your tokens with their id, name, scopes, expiry and when each was last used (recorded at most once every five minutes). An admin (whoever holdswrite:user) sees every token of the tenant, and never a token of another tenant. - Revoking.
dexaflow auth tokens revoke <id>stops a token on its next request. Deactivating the owner stops all of the owner’s tokens. - In the UI. User > API tokens lists the same tokens as
dexaflow auth tokens list: name, scope, owner, when it was created, when it expires and when it was last used, never the token itself. Revoke asks for a confirmation and then stops the token, likedexaflow auth tokens revoke, and the revoke is recorded in the audit log asapi_token.revoke. The screen does not create tokens: the secret is shown once, bydexaflow auth create-token. - Limits. A token cannot create, list or revoke tokens. A user holds at
most 50 live tokens, and token names are unique among a user’s live tokens:
1 to 64 letters, digits, dots, dashes or underscores. Once a token has
expired, a new token can take its name: the create retires the expired token
and records it in the audit log as
api_token.retire_expired, with the old token’s id, when it expired and the id of the token that took its name, so it is not mistaken for a manual revoke (api_token.revoke). The token is sent in theAuthorizationheader only; it never works as a browser session cookie. - Turned off. An operator can turn API tokens off with
auth.api_tokens.disabled: true(Helm:auth.apiTokens.disabled), for example where a platform in front of Dexaflow decides at each sign-in who may reach it and no credential should outlive that decision. The token routes and the API tokens screen are then not served, and everydxf_token answers401, including one created before; it works again if the tokens are turned back on before it expires. Deploy through CI OIDC or the platform’s issuer instead.
Creating, listing and revoking tokens is also available through the API
(POST, GET /api/v2/apiTokens and DELETE /api/v2/apiTokens/{id}).
A session token from dexaflow auth create-token without --scope expires
after one hour (auth.jwt.token_ttl_seconds, 3600 by default), so it cannot
be stored as a CI secret. On a control plane older than deploy tokens, store
the service user’s password instead (DEXAFLOW_CI_PASSWORD below) and mint a
session token at the start of each job:
export DEXAFLOW_SERVER_URL=https://pro.example.com
DEXAFLOW_TOKEN="$(printf '%s\n' "$DEXAFLOW_CI_PASSWORD" |
dexaflow auth create-token --username ci-deploy@example.com --password-stdin)"
export DEXAFLOW_TOKEN
That token lives one hour: a build that can take longer should run the steps
apart (dexaflow compile --build --push, then mint the token, then
dexaflow push, which registers the digest compile wrote into dag.json).
When deploys go through your platform’s issuer
A control plane with auth.trusted_issuer.deploy_audience set takes deploys
only from its trusted issuer
(deploys through the issuer only):
a deploy token, a session token and a password login all get 403 on
dexaflow deploy, dexaflow push and dexaflow rollback, and deploy says
that the control plane takes deploys only through its trusted issuer. Deploy
through the platform that owns deployments instead. When that platform runs
dexaflow deploy itself, it passes a bearer its issuer minted for the deploy
audience, with the dexaflow:deploy scope and a user linked to the issuer,
as DEXAFLOW_TOKEN or --token, its aud being the deploy audience alone.
With --paused or --active, which set is_paused, the token also needs
the dexaflow:run scope (and the user write on dags). The token is
short-lived (at most auth.trusted_issuer.bearer_max_lifetime_seconds), so
mint it for each job. Deleting a DAG goes through the issuer the same way.
Deploying without a stored token (CI OIDC)
A deploy token is a secret that lives for months in the CI system. When the
control plane trusts the CI provider instead, nothing long-lived is stored:
each job asks its provider for an OIDC token minted for that one job,
dexaflow auth ci-token exchanges it at POST /api/v2/auth/ciToken for a
deploy session of the control plane, and the session expires an hour later
(auth.ci_oidc.session_ttl_seconds). GitHub Actions, GitLab CI and Google
Cloud Build are supported, and any other provider that publishes its keys
(ADR 0092).
Trusting a CI provider
List the providers the control plane trusts under auth.ci_oidc.issuers. Each
entry is one provider for one tenant:
auth:
ci_oidc:
session_ttl_seconds: 3600
issuers:
- name: github-acme
provider: github
issuer: https://token.actions.githubusercontent.com
audience: https://dexaflow.acme.example
tenant: acme
rules:
- claims:
repository: acme/dags
ref: refs/heads/main
environment: production
role: deployer
- name: gitlab-acme
provider: gitlab
issuer: https://gitlab.com
audience: https://dexaflow.acme.example
tenant: acme
rules:
- claims: {project_path: acme/dags, ref: main, ref_protected: "true"}
role: deployer
- name: cloudbuild-acme
provider: cloudbuild
issuer: https://accounts.google.com
audience: https://dexaflow.acme.example
tenant: acme
rules:
- claims: {email: dags-builder@acme-ci.iam.gserviceaccount.com}
role: deployer
With the Helm chart, the same list goes under auth.ciOidc.issuers and the
lifetime under auth.ciOidc.sessionTtlSeconds; the chart passes the list to
the server as JSON in LEOFLOW_AUTH_CI_OIDC_ISSUERS (DEXAFLOW_AUTH_CI_OIDC_ISSUERS
works too, and wins over the file). The server checks every entry at boot and
refuses to start, naming the field, when one is wrong.
| Field | What it is |
|---|---|
name | 1 to 40 lowercase letters, digits or -. It names the entry in logs, metrics, audit records and the sessions it issues. |
provider | github, gitlab, cloudbuild or other. It decides which identity claim each rule must pin. |
issuer | The exact iss of the provider’s tokens, https (http only on a loopback host). The keys are read from its discovery document, whose issuer must be the same. |
jwks_url | Optional: where the keys are, instead of discovery. |
audience | The aud the job asks its token for. One issuer and audience pair belongs to one entry, so give each tenant its own audience. |
tenant | The tenant every session of this entry acts in. It is never read from a claim: a token that claims another tenant still gets this one. |
max_lifetime_seconds | The longest exp - iat a token may have, 0 (one hour) to 86400. GitHub and Cloud Build mint tokens for minutes; raise it for a GitLab job token that lives as long as a long job. |
rules | Which jobs may deploy, and as what. The first rule whose claims all match decides; a token that matches none is refused with 403. |
A rule has claims, a role and optional scopes:
claimsmaps claim names to values. A value is matched against a string claim of the token, exactly or as a pattern (*and?within a segment, soacme/*matchesacme/dagsbut notacme/dags/sub, and[a-z]ranges). A pattern must name something:*alone is refused. Each rule pins an identity claim of its provider, so no rule admits every job on the provider:repository,repository_id,repository_ownerorrepository_owner_idon GitHub,project_path,project_id,namespace_pathornamespace_idon GitLab,emailorsubon Cloud Build. Pin the branch (ref) and, on GitHub, theenvironmenttoo, so a pull request or a feature branch cannot deploy.roleis a role of the entry’s tenant,deployerfor most pipelines.adminis refused. The role must exist when the token is exchanged (the exchange answers403otherwise), and its permissions are read again on every request, so changing the role changes what live sessions can do.scopesnarrow the session, as the scopes of an API token do:dexaflow:deploy(the default),dexaflow:run(fordeploy --triggerand--paused/--active) ordexaflow:read.
What the pipeline does
dexaflow ci init github|gitlab|cloudbuild asks the control plane the
pipeline deploys to which issuers it offers your tenant (GET /api/v2/deployDefaults, with your CLI session), and when one matches the
system it writes the tokenless pipeline:
- GitHub: the workflow gets
id-token: write, and the deploy step asks the runner for a token for the entry’s audience, with noDEXAFLOW_TOKENsecret. - GitLab: the job declares
id_tokens: DEXAFLOW_OIDC_TOKENwith the entry’s audience, with noDEXAFLOW_TOKENvariable. - Cloud Build: the step runs
gcloud auth print-identity-token --audiences=<audience> --include-emailas the build’s service account, and the Secret Manager secret is gone.
ci/dexaflow-deploy.sh then exchanges that token with dexaflow auth ci-token
and deploys with the session it prints. A DEXAFLOW_TOKEN set in the CI system still works when
DEXAFLOW_OIDC_TOKEN is not set, so a pipeline can go back to a deploy token
without being written again. The pre-rename LEOFLOW_OIDC_TOKEN works too.
--auth picks the path: auto (the default) writes the tokenless pipeline
when the server offers an issuer for the system and the deploy token path
otherwise, with a note saying why (no issuer for the system, a server that
does not answer, a CLI release before v0.5.5); oidc fails instead of
falling back; token never asks. The generic script has no provider to
mint a token, so it keeps the deploy token, unless your CI system sets
DEXAFLOW_OIDC_TOKEN and runs dexaflow auth ci-token itself.
Watching the exchange
- Metric.
dexaflow_ci_oidc_exchange_total{issuer, outcome}counts the exchanges by entry and outcome:issued,invalid_token(signature,alg, audience, expiry or lifetime),unknown_issuer,no_rule,role_missing,keys_unavailableanderror. A token that names no entry is counted underissuer="unknown". - Logs. Each exchange logs the entry, the tenant, the job’s
subclaim, the role and the outcome; a refusal adds the reason. Neither the token nor its signature is logged. - Audit. Each exchange is recorded in the entry’s tenant as
ci_oidc.exchange, with the entry, the subject, the role and the outcome. - Answers.
401for a token that is not valid or names no entry,403when no rule matches or the role is missing,503when the provider’s keys cannot be read (try again),429after 30 refused exchanges in a minute from one address (issued sessions do not count).
A session carries the entry’s name and a fingerprint of the entry: changing
or removing the entry ends its live sessions on their next request. A session
works in the Authorization header only, never as a browser cookie.
Python on the runner
The dexaflow compile step (which deploy runs) needs Python 3.11, 3.12, or
3.13 on PATH as python3 to parse dag.py. Bring your own Python on the
runner; the managed CPython that dexaflow setup can download is meant for a
developer laptop, not for build pipelines, where it adds ~50 MB to every run and
bypasses your runner’s pin/caching.
The recommended path on each runner type:
| Runner | Recipe |
|---|---|
| GitHub Actions | Add actions/setup-python@v5 with python-version: '3.12' before installing dexaflow. Cached automatically. |
| GitLab CI | Run the job in the dexaflow-cli image (the job dexaflow ci init gitlab writes, from v0.5.4), or on python:3.12-slim. A job that needs Docker in Docker runs on docker:27 and installs python3 with apk add python3. |
| Argo CD, Argo Workflows, Flux, Tekton, Jenkins on Kubernetes | Run the job in the dexaflow-cli image: it ships Python 3.13, git and uv next to the CLI (recipes). |
| Cloud Build | Run the step on an image with Python 3.11 or later: the generated step installs Debian’s python3 on gcr.io/google.com/cloudsdktool/cloud-sdk:slim, which also has the gcloud session the CLI reads for Artifact Registry. gcr.io/cloud-builders/docker is Ubuntu 20.04, whose python3 is 3.8. |
| CodeBuild | Select Python 3.11 or later under runtime-versions in the buildspec. |
| Self-hosted runners | Pin Python via your image baseline (apt install python3.12 or pyenv) and version-lock in your runner provisioning. |
| Generic Docker-in-Docker | Base your build container on python:3.12-slim (gives you Python + a Debian userland for the docker build shell). |
Older Python (≤3.10) fails the compile cleanly: dexaflow compile errors out
with the version requirement, not a confusing traceback.
No dexaflow setup in CI
dexaflow compile and dexaflow deploy extract the parser bundled in the binary
into ~/.dexaflow/pysrc/ on their own the first time they run (and again after a
binary upgrade), so a runner needs no setup step. Install the binary with
DEXAFLOW_NO_SETUP=1: dexaflow setup would also prepare Lite (credentials,
datastore) and may download a managed Python, none of which a pipeline uses.
Caching ~/.dexaflow/pysrc/ between runs is optional; the extraction takes a
few seconds.
Installing the CLI in a pipeline
A pipeline pins the CLI it runs, so a release does not change what the next
run deploys. From v0.5.4 on, the release ships two ways to do that without
piping install.sh into a shell: a GitHub Action, and a container image for
every system that runs a job in a container.
The setup-dexaflow action
- uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<VERSION>
with:
version: <VERSION>
server-url: https://pro.example.com # optional
The action installs the release named by version for the runner’s OS and
architecture (Linux or macOS, amd64 or arm64), and puts dexaflow on PATH
for the rest of the job. On every run it installs cosign v2.4.3 (and uses
that one, not a cosign already on the runner), verifies the keyless
signature of the release’s checksums.txt against the release workflow at
the v-prefixed tag, and checks the archive against checksums.txt; only
then is the binary extracted. The release files (checksums.txt, its
signature and certificate, the archive) are cached with actions/cache per
version, platform and verify-signature setting. A cache hit saves the
downloads only: the cached files are verified again like downloaded ones.
| Input | Default | What it does |
|---|---|---|
version | the ref of the action, only when it is a release tag | The release to install, vX.Y.Z or vX.Y.Z-rc.N, any release from v0.5.0 on. Set it: with a commit SHA, a branch or a local path the action fails without it, and latest is refused. |
server-url | none | Exported as DEXAFLOW_SERVER_URL for the later steps. |
verify-signature | true | false skips the signature check and the cosign install (the checksum is still checked), for a runner that cannot reach Sigstore. |
| Output | Value |
|---|---|
version | The installed tag (v0.5.4). |
path | The absolute path of the dexaflow binary. |
cache-hit | true when the release files came from the cache (they were verified all the same). |
To pin the action by commit, as for any third-party action:
uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<sha> # v0.5.4 with
version: v0.5.4.
The action installs only the CLI. compile still needs a Python 3.11 to 3.13
on PATH (Python on the runner), which GitHub’s
hosted runners have; actions/setup-python pins one.
The dexaflow-cli image
ghcr.io/dexadata/dexaflow-cli:<VERSION> (also tagged without the v) is
the CLI of that release for linux/amd64 and linux/arm64, with what
dexaflow deploy runs next to it: Python 3.13 for compile, git for the
version label and --changed-since, uv
for the daemonless dependency layer, and the CA certificates. It has no
Docker CLI: a deploy from it takes the skip, code-layer,
dependency-layer or remote builder, so a DAG whose
image needs a real build (a package with no wheel, system_packages, its own
Dockerfile) needs a remote builder, a job with a daemon (the GitLab
recipe under Examples), or buildah
or kaniko in the cluster (recipes). The Cloud Build remote builder runs
gcloud, which the image does not carry. The GitLab job that
dexaflow ci init gitlab writes runs in this image, pinned by digest, when no
project needs a daemon.
The entrypoint is dexaflow, and the image runs as UID 65532 with
HOME=/home/dexaflow. It is signed like the other images; the identity
names the v-prefixed tag, the ref the release workflow ran at:
cosign verify ghcr.io/dexadata/dexaflow-cli:<VERSION> \
--certificate-identity "https://github.com/dexadata/dexaflow/.github/workflows/release.yaml@refs/tags/<VERSION>" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
The CLI reads registry credentials from the docker config (Registry credentials), so a job writes one from its CI variables. In GitLab CI, with no Docker daemon and no privileged runner:
deploy_dags:
image:
name: ghcr.io/dexadata/dexaflow-cli:<VERSION>
entrypoint: [""] # GitLab runs the script in a shell
variables:
DEXAFLOW_SERVER_URL: https://pro.example.com
GIT_DEPTH: "0" # git describe needs history and tags
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- mkdir -p "$HOME/.docker"
- printf '{"auths":{"%s":{"auth":"%s"}}}' "$CI_REGISTRY" "$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 -w0)" > "$HOME/.docker/config.json"
- dexaflow deploy --all --yes # DEXAFLOW_TOKEN is a masked CI/CD variable
The job writes the compiled artifacts under the checkout, which GitLab’s
Docker executor leaves writable for any user. The image trusts every
checkout for git (safe.directory), since the CI system clones it as another
user.
As an Argo CD hook, the image checks a target against the repository after
each sync; --dry-run exits 1 when something would change, which fails the
hook and marks the sync. Drop --dry-run and add --yes to make the target
match instead (Syncing a target with the workspace):
apiVersion: batch/v1
kind: Job
metadata:
generateName: dexaflow-sync-
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
securityContext: { runAsNonRoot: true }
containers:
- name: sync
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
command: ["sh", "-c"]
args:
- git clone https://github.com/your-org/dags /tmp/dags &&
dexaflow sync /tmp/dags --dry-run --prune
env:
- name: DEXAFLOW_SERVER_URL
value: https://pro.example.com
- name: DEXAFLOW_TOKEN
valueFrom: { secretKeyRef: { name: dexaflow-deploy-token, key: token } }
Flux has no hooks, and it does not run a finished Job again while its spec is
unchanged. Run the same container from a Kubernetes CronJob, or from the
pipeline that pushes to the repository.
Writing the pipeline: dexaflow ci init
dexaflow ci init <system> writes a working deploy pipeline for the workspace
around the current folder, at the top of its git repository, where the CI
system reads it:
| System | Files written |
|---|---|
github | .github/workflows/dexaflow-deploy.yaml and ci/dexaflow-deploy.sh |
gitlab | .gitlab-ci.yml and ci/dexaflow-deploy.sh |
cloudbuild | cloudbuild.yaml and ci/dexaflow-deploy.sh |
generic | ci/dexaflow-deploy.sh alone, to run from any other CI system (Argo Workflows, Tekton, Jenkins) |
dexaflow ci init github
wrote .github/workflows/dexaflow-deploy.yaml
wrote ci/dexaflow-deploy.sh
Next:
1. create a deploy token, and store it as the CI secret DEXAFLOW_TOKEN:
dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d
2. set DEXAFLOW_SERVER_URL in the CI system to the control plane URL
3. commit the files and push to main
Every pipeline runs ci/dexaflow-deploy.sh, which holds the deploy itself, so
the logic is the same on every system. On each push to the branch (--branch,
main by default) it:
- installs the CLI at the version that wrote it, with the
install.shof that release tag, which checks the archive against the release’s checksums and, whencosignis on the runner, requires its signature. Pass--cli-version vX.Y.Zto pin another release,v0.5.0or later (a CLI built from source must);DEXAFLOW_CLI_VERSIONoverrides it in the job. On GitHub the step namedInstall dexaflowuses the setup-dexaflow action of the same release instead, pinned by the commit of its tag, so the workflow needsv0.5.4or later. The commit is this CLI’s own, or for another--cli-versionthe onegit ls-remotefinds for the tag on GitHub. On GitLab the job runs in thedexaflow-cliimage of the same release, which already holds the CLI, so the script installs nothing; - installs uv
when the runner has none, from PyPI with
--require-hashesagainst the wheel hashes the script carries, so dependency images are built from wheels without a container daemon; - reads the control plane from
DEXAFLOW_SERVER_URL, or from the workspace’s target: with--target <name>, or adefault_targetindexaflow.workspace.yaml, the script exportsDEXAFLOW_TARGETand the pipeline needs no URL (a target of your own config file is not in the repository, so it is not used). The deploy token comes from the CI secretDEXAFLOW_TOKEN(Credentials for the control plane). The pre-renameLEOFLOW_TOKEN,LEOFLOW_SERVER_URLandLEOFLOW_TARGETstill work; - runs
dexaflow deploy --all --changed-since <base> --yes --output json, where the base is the commit of the last successful deploy on GitHub, and on GitLab with an API token, or the commit before the push otherwise (which commit, and why), ordexaflow deploy --all --yes --output jsonwhen there is none (a first run, a new branch, a force push, a source without.git); - writes a summary of the JSON report: the job summary on GitHub
(
$GITHUB_STEP_SUMMARY), thedexaflow-deploy.mdanddexaflow-deploy.jsonartifacts on GitLab, the build log elsewhere. SetDEXAFLOW_SUMMARY_FILEandDEXAFLOW_REPORT_FILE(absolute paths) to put them somewhere else. The job fails when a DAG fails to deploy.
The registry login is the CI system’s own: GITHUB_TOKEN for ghcr.io,
workload identity for Artifact Registry and an IAM role for ECR on GitHub, the
job token ($CI_REGISTRY_USER) on GitLab, and the build’s service account on
Cloud Build. Another registry gets a login from the secrets
DEXAFLOW_REGISTRY_USERNAME and DEXAFLOW_REGISTRY_PASSWORD. The comments at
the top of each file list what to set up. The registries are the ones the
workspace’s projects push to (registry.url, the target’s registry, the
workspace defaults); with none, the file says where to add your login.
On GitHub the job runs in an environment (--environment, production by
default), so DEXAFLOW_TOKEN can be an environment secret and the
environment’s protection rules (required reviewers, allowed branches) apply to
each deploy; secrets and variables of the repository work too. A manual run
(workflow_dispatch) deploys only from the deploy branch. Every action is
pinned by commit SHA, with its version in a comment, and the checkout keeps no
token in the clone. On GitLab, without --branch, the job follows the
project’s default branch ($CI_DEFAULT_BRANCH). The container images of the
GitLab job and the Cloud Build step are pinned by digest. Run
dexaflow ci init <system> --force after upgrading the CLI to pick up the
pins of the new release.
The CLI picks the builder at deploy time, as it does on
a laptop, and Docker is the fallback. On GitHub the runner has Docker, so
nothing changes in the workflow. On GitLab the job needs no daemon, and runs
in the dexaflow-cli image, unless a project needs a real image build (its own
Dockerfile, system_packages, a dependency that is not a requirement by
name) and no remote builder is
configured: then it runs Docker in Docker, and the file names the projects
and why. On Cloud Build, the step installs Docker only in that case too.
When the control plane trusts the CI provider, the pipeline signs in with
the job’s OIDC token instead of DEXAFLOW_TOKEN, and step 1 of Next: names
the issuer it uses; --auth picks the path
(Deploying without a stored token).
An existing file is never overwritten: the command names it and writes
nothing. Pass --force to replace it, or --stdout to print the files
instead of writing them. A .gitlab-ci.yml holds the project’s other jobs,
so merge the dexaflow-deploy job into it by hand. After upgrading the CLI,
run dexaflow ci init <system> --force to pin the new version.
Examples: what dexaflow ci init writes
These are the files dexaflow ci init writes for a workspace whose projects
push to the registry each system logs in to natively (ghcr.io on GitHub,
registry.gitlab.com on GitLab, Artifact Registry on Cloud Build), with
vX.Y.Z in place of the CLI version. The test suite checks that they match
the command’s output.
# Deploys the DAGs changed on main since its last successful deploy to Dexaflow.
# Written by `dexaflow ci init github` for dexaflow vX.Y.Z; the deploy
# itself is in ci/dexaflow-deploy.sh.
#
# Set up once, in the repository settings: an environment named production
# (Settings, Environments) holding
# - the variable DEXAFLOW_SERVER_URL: the control plane URL;
# - the secret DEXAFLOW_TOKEN: a deploy token, created with
# `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
# Repository-level variables and secrets of the same names work too. Protection
# rules on the environment (required reviewers, a branch policy) apply to
# every deploy.
#
# Each run deploys what changed since the commit of the last successful run of
# this workflow on main, so a failed or cancelled run is caught up by the
# next one; with no successful run yet, every DAG is deployed.
name: Deploy DAGs to Dexaflow
on:
push:
branches: ["main"]
workflow_dispatch:
concurrency:
group: dexaflow-deploy
cancel-in-progress: false
permissions:
contents: read
actions: read # gh run list: the last successful deploy
packages: write
jobs:
deploy:
# A manual run from another branch deploys nothing.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
env:
DEXAFLOW_SERVER_URL: ${{ vars.DEXAFLOW_SERVER_URL }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # --changed-since and the version label need the history
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# Installs the pinned CLI, after checking its signature and checksum.
- name: Install dexaflow
uses: dexadata/dexaflow/.github/actions/setup-dexaflow@<sha> # vX.Y.Z
with:
version: vX.Y.Z
- name: Log in to ghcr.io
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Find the commit of the last successful deploy
id: base
env:
GH_TOKEN: ${{ github.token }}
BRANCH: main
run: |
sha="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow dexaflow-deploy.yaml --branch "$BRANCH" \
--status success --limit 1 --json headSha --jq '.[0].headSha // ""')" || sha=""
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# The CLI picks the builder: an image already in the registry, the DAG
# code as a layer on the dependency image, dependencies from wheels with
# uv, the remote builder when one is configured, and the runner's Docker
# only when none of these applies.
- name: Deploy the DAGs changed since then
env:
DEXAFLOW_TOKEN: ${{ secrets.DEXAFLOW_TOKEN }}
DEXAFLOW_BASE_REF: ${{ steps.base.outputs.sha }}
run: sh ci/dexaflow-deploy.sh
setup-dexaflow installs the release named by
version, checks its signature and checksum, and caches it per version.
GHCR refuses image names with upper-case letters, so keep registry.url and
registry.image_name lower case.
Do not add docker/setup-buildx-action to this job. It makes a
docker-container builder the default, whose builds stay in the builder’s
cache instead of the local image store; the CLI runs a plain docker build
without --load when it falls back to Docker, so the docker push that
follows finds no image.
For a pull request, --plan says what merging would deploy. Pass the base
branch through env: rather than inline in run::
- name: What would this pull request deploy
env:
BASE_REF: ${{ github.base_ref }}
run: dexaflow deploy --all --changed-since "origin/$BASE_REF" --plan
# Deploys the DAGs a push to the default branch changed to Dexaflow.
# Written by `dexaflow ci init gitlab` for dexaflow vX.Y.Z; the deploy
# itself is in ci/dexaflow-deploy.sh. To add it to an existing .gitlab-ci.yml, copy
# the dexaflow-deploy job into that file by hand.
#
# Set up once, in Settings, CI/CD, Variables:
# - DEXAFLOW_SERVER_URL: the control plane URL;
# - DEXAFLOW_TOKEN, masked and protected: a deploy token, created with
# `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
# - DEXAFLOW_GITLAB_API_TOKEN, masked and protected (recommended): a project
# access token with the read_api scope, see below.
# The job pushes to the project's container registry with the job token
# ($CI_REGISTRY_USER and $CI_REGISTRY_PASSWORD).
#
# What a run deploys: with DEXAFLOW_GITLAB_API_TOKEN, what changed since the
# commit of the last successful push pipeline on the branch, so a failed or
# cancelled pipeline is caught up by the next one. The job token cannot read
# the pipelines API, so without that variable a run deploys what changed since
# the previous commit of its push ($CI_COMMIT_BEFORE_SHA), and the changes of a
# failed, cancelled or skipped pipeline wait for the next change to the same
# DAG: rerun that pipeline, or run one by hand (Build, Pipelines, Run
# pipeline), which deploys every DAG. Turn off "Auto-cancel redundant
# pipelines" for the branch, and set the process mode of the resource group
# dexaflow-deploy to oldest_first (PUT /projects/:id/resource_groups/dexaflow-deploy
# with process_mode=oldest_first), so pipelines deploy in push order.
#
# No container daemon is needed: the CLI pushes the DAG code as a layer on the
# dependency image, and builds that image from wheels with uv. The job runs in
# the dexaflow-cli image of vX.Y.Z, pinned by digest, which carries the
# CLI, Python, git and uv, so nothing is installed in the job. The CLI is the
# image's, so DEXAFLOW_CLI_VERSION does not apply: to change the release,
# write this file again with `dexaflow ci init gitlab --force` (--cli-version
# picks another release).
dexaflow-deploy:
image:
name: ghcr.io/dexadata/dexaflow-cli:vX.Y.Z@sha256:<digest>
entrypoint: [""] # GitLab runs the script in a shell
resource_group: dexaflow-deploy
variables:
GIT_DEPTH: "0" # --changed-since and the version label need the history
DEXAFLOW_SUMMARY_FILE: $CI_PROJECT_DIR/dexaflow-deploy.md
DEXAFLOW_REPORT_FILE: $CI_PROJECT_DIR/dexaflow-deploy.json
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ($CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web")
script:
- |
mkdir -p "$HOME/.docker"
b64() { printf '%s:%s' "$1" "$2" | base64 | tr -d '\n'; }
printf '{"auths":{"%s":{"auth":"%s"}}}\n' \
"$CI_REGISTRY" "$(b64 "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD")" \
>"$HOME/.docker/config.json"
- |
DEXAFLOW_BASE_REF="$CI_COMMIT_BEFORE_SHA"
if [ -n "${DEXAFLOW_GITLAB_API_TOKEN:-}" ]; then
DEXAFLOW_BASE_REF="$(python3 -c '
import json, os, urllib.parse, urllib.request
e = os.environ
q = urllib.parse.urlencode({"ref": e["CI_COMMIT_BRANCH"], "source": "push", "status": "success", "per_page": 1})
r = urllib.request.Request(e["CI_API_V4_URL"] + "/projects/" + e["CI_PROJECT_ID"] + "/pipelines?" + q,
headers={"PRIVATE-TOKEN": e["DEXAFLOW_GITLAB_API_TOKEN"]})
with urllib.request.urlopen(r, timeout=30) as resp:
p = json.load(resp)
print(p[0]["sha"] if p else "")
')" || DEXAFLOW_BASE_REF=""
fi
export DEXAFLOW_BASE_REF
- sh ci/dexaflow-deploy.sh
artifacts:
when: always
paths: [dexaflow-deploy.md, dexaflow-deploy.json]
expire_in: 30 days
The job runs in the dexaflow-cli image of the
release ci init pins, by tag and digest: the command reads the digest from
ghcr.io when it writes the file, and stops, writing nothing, when it cannot
(no network, for example). The image carries the CLI, Python, git and uv, so
the job installs nothing, and entrypoint: [""] lets GitLab run the script in
a shell instead of the image’s dexaflow entrypoint. The image runs as UID
65532, which writes to the checkout because GitLab’s Docker executor leaves it
writable for any user. The CLI is the image’s, so DEXAFLOW_CLI_VERSION does
not apply: run dexaflow ci init gitlab --force (with --cli-version for
another release) to move the job to another release. A --cli-version older
than v0.5.4, which has no dexaflow-cli image, writes a job on
python:3.12-slim (by digest) that installs git and curl with apt-get, and
the CLI and uv with the script.
When a project needs a real image build and no remote builder is configured,
the job runs Docker in Docker instead: image: docker:27 and
services: [docker:27-dind] (by digest), the TLS variables (DOCKER_HOST,
DOCKER_TLS_CERTDIR) and apk add --no-cache python3 git curl. That image
has no pip, so uv is not installed and the daemon builds the dependency
images too. docker:dind
runs only on a runner configured with privileged = true (and, for the TLS
variables, volumes = ["/certs/client"]) in its config.toml; GitLab’s shared
SaaS runners are. registry.url in dexaflow.yaml or the workspace defaults
is your project’s $CI_REGISTRY_IMAGE, written out (for example
registry.gitlab.com/your-group/my-dags).
Build, push and register on Cloud Build; the DAG image runs as task pods on your
cluster. The build’s service account pushes to Artifact Registry in the same
project through the gcloud session the CLI reads, so registry.url is
us-central1-docker.pkg.dev/<project>/dags. The deploy token lives in Secret
Manager as dexaflow-deploy-token and reaches the step as DEXAFLOW_TOKEN.
# Deploys the DAGs of this workspace to Dexaflow on Cloud Build.
# Written by `dexaflow ci init cloudbuild` for dexaflow vX.Y.Z; the
# deploy itself is in ci/dexaflow-deploy.sh.
#
# Set up once:
# - a Secret Manager secret dexaflow-deploy-token holding a deploy token,
# created with `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`,
# readable by the build's service account;
# - the substitution _DEXAFLOW_SERVER_URL: the control plane URL;
# - a trigger on pushes to main, for example
# gcloud builds triggers create github --repo-owner=<owner> --repo-name=<repo> --branch-pattern='^main$' --build-config=cloudbuild.yaml \
# --service-account=projects/<project>/serviceAccounts/<builder>@<project>.iam.gserviceaccount.com
# The step signs in to Artifact Registry with the build's service account
# (the gcloud session the CLI reads), which needs roles/artifactregistry.writer.
# A source uploaded by `gcloud builds submit` has no .git, so every DAG is
# deployed; DAGs whose content is already the current version register
# nothing. When the source is a git clone, the step fetches its full history
# and tags, and _DEXAFLOW_BASE_REF deploys only what changed since a commit.
steps:
- id: deploy
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim@sha256:ffd36b9b80bb08b2dea60b58ca2b60dfa3a40a3c00f3e5732d6d113f4e7ffa45
entrypoint: bash
args:
- -c
- |
set -e
apt-get update -qq
apt-get install -y -qq --no-install-recommends python3 python3-pip git curl ca-certificates >/dev/null
if [ -d .git ]; then git fetch --unshallow --tags || true; fi
sh ci/dexaflow-deploy.sh
secretEnv: [DEXAFLOW_TOKEN]
env:
- DEXAFLOW_SERVER_URL=${_DEXAFLOW_SERVER_URL}
- DEXAFLOW_BASE_REF=${_DEXAFLOW_BASE_REF}
- PIP_BREAK_SYSTEM_PACKAGES=1
substitutions:
_DEXAFLOW_SERVER_URL: https://dexaflow.example.com
_DEXAFLOW_BASE_REF: ""
availableSecrets:
secretManager:
- versionName: projects/$PROJECT_ID/secrets/dexaflow-deploy-token/versions/latest
env: DEXAFLOW_TOKEN
options:
logging: CLOUD_LOGGING_ONLY
gcloud builds submit uploads the source without .git, so the version label
starts from dev and the step deploys every DAG. When the source is a git
clone, the step fetches its full history and tags first
(git fetch --unshallow --tags). The label still carries the
hash of each DAG’s content, so a DAG that did not change registers nothing,
and one that changed gets a new label.
When the local Docker daemon can’t reach Google’s IPs (in Cloud Shell, for
example), don’t build locally at all. The cloudbuild.yaml above runs the
whole deploy inside Cloud Build (Google’s network, not your machine):
serverless, still yaml-driven, no Dockerfile, no local egress. Trigger it from
Cloud Shell with:
gcloud builds submit --config cloudbuild.yaml .
Cloud Build receives the source, builds, pushes to Artifact Registry and
registers there; your machine never touches a Docker daemon. (Use
gcloud builds submit --tag … only if you ship your own Dockerfile; the
yaml-driven path has none for it to build.)
dexaflow ci init generic writes the script alone. Run it from any CI system
with Python 3.11+ and git on the runner (see
Python on the runner), after the registry login, with
DEXAFLOW_SERVER_URL, DEXAFLOW_TOKEN and DEXAFLOW_BASE_REF set:
export DEXAFLOW_SERVER_URL=https://pro.example.com
export DEXAFLOW_TOKEN="$DEPLOY_TOKEN_FROM_YOUR_SECRET_STORE" # a deploy token
export DEXAFLOW_BASE_REF="$COMMIT_BEFORE_THIS_PUSH" # empty deploys every DAG
sh ci/dexaflow-deploy.sh
#!/bin/sh
# Deploys the DAGs of this workspace that changed to Dexaflow.
# Written by `dexaflow ci init` for dexaflow vX.Y.Z; write it again with
# `dexaflow ci init <system> --force` after upgrading the CLI.
#
# Usage: sh ci/dexaflow-deploy.sh [deploy|install]
# deploy (the default) install the CLI and uv when missing, then deploy
# install only install the pinned CLI
#
# Environment:
# DEXAFLOW_SERVER_URL the control plane URL (required)
# DEXAFLOW_TOKEN a deploy token (required), created once with
# `dexaflow auth create-token --scope deploy --name ci-<repo> --ttl 90d`
# DEXAFLOW_BASE_REF the commit the branch was on before this push: only
# the DAGs changed since it are deployed. Empty, all
# zeros (a new branch) or missing from the clone
# deploys every DAG.
# DEXAFLOW_SUMMARY_FILE a file to append a Markdown summary to (default:
# $GITHUB_STEP_SUMMARY, else the log)
# DEXAFLOW_REPORT_FILE a file to copy the `dexaflow deploy --output json`
# report to (optional)
# DEXAFLOW_CLI_VERSION the release to install instead of vX.Y.Z (a tag
# such as vX.Y.Z, v0.5.0 or later)
# The pre-rename LEOFLOW_TOKEN, LEOFLOW_SERVER_URL and LEOFLOW_TARGET still work.
#
# The CLI is installed by the install.sh of its release tag, which checks the
# archive against the release's checksums.txt, and its cosign signature when
# cosign is on PATH (then required). uv comes from PyPI, checked against the
# wheel hashes below.
set -eu
cli_version="${DEXAFLOW_CLI_VERSION:-vX.Y.Z}"
DEXAFLOW_TOKEN="${DEXAFLOW_TOKEN:-${LEOFLOW_TOKEN:-}}"
DEXAFLOW_SERVER_URL="${DEXAFLOW_SERVER_URL:-${LEOFLOW_SERVER_URL:-}}"
export DEXAFLOW_TOKEN DEXAFLOW_SERVER_URL
uv_version="0.12.24"
workspace_dir="."
die() {
echo "error: $*" >&2
exit 1
}
# The version ends up in a URL and in install.sh: only a release tag is taken.
case "$cli_version" in
v[0-9]*.[0-9]*.[0-9]*)
case "$cli_version" in
*[!0-9A-Za-z.-]*) die "DEXAFLOW_CLI_VERSION=$cli_version is not a release tag such as vX.Y.Z" ;;
esac
;;
*) die "DEXAFLOW_CLI_VERSION=$cli_version is not a release tag such as vX.Y.Z" ;;
esac
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# install_dexaflow installs the pinned CLI with the release's install.sh,
# unless that version is already on PATH.
install_dexaflow() {
if command -v dexaflow >/dev/null 2>&1 &&
[ "$(dexaflow version 2>/dev/null | awk '{print $2}')" = "${cli_version#v}" ]; then
return 0
fi
dir="${DEXAFLOW_INSTALL_DIR:-$HOME/.dexaflow/bin}"
url="https://raw.githubusercontent.com/dexadata/dexaflow/$cli_version/install.sh"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$tmp/install.sh"
else
wget -qO "$tmp/install.sh" "$url"
fi
if command -v cosign >/dev/null 2>&1; then
DEXAFLOW_INSTALL_REQUIRE_SIGNATURE=1
export DEXAFLOW_INSTALL_REQUIRE_SIGNATURE
fi
DEXAFLOW_VERSION="$cli_version" DEXAFLOW_NO_SETUP=1 DEXAFLOW_NO_PATH=1 \
DEXAFLOW_INSTALL_DIR="$dir" sh "$tmp/install.sh"
PATH="$dir:$PATH"
export PATH
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$dir" >>"$GITHUB_PATH"
fi
}
# install_uv puts uv on PATH, so dependency images are built from wheels
# without a container daemon. Without it the remote builder or the local
# daemon builds them.
install_uv() {
command -v uv >/dev/null 2>&1 && return 0
echo "uv==$uv_version --hash=sha256:e47005957dca320272ad68960b591b854b6bf0c3288368efb096d9ccd06b8404 --hash=sha256:94c9ed22c1b5d6861a72639e23c5707bb07a354b67ec0462e9a0169a9665b5fd --hash=sha256:0fc14fabb78c55d140a01125d5cd038afb4fed317eb0c3868f3fba87b8cefe16 --hash=sha256:b6fe9cd01f5c576a640f17829cb465899fffb5d6d8dfdd576e38d4f1e40dd438" >"$tmp/uv.txt"
if python3 -m pip install --quiet --disable-pip-version-check --only-binary :all: \
--require-hashes -r "$tmp/uv.txt" >/dev/null 2>&1 &&
command -v uv >/dev/null 2>&1; then
return 0
fi
echo "note: uv could not be installed; dependency images are built by the remote builder or the container daemon" >&2
}
# base_ref prints the commit to compare with, or nothing to deploy every DAG.
base_ref() {
base="${DEXAFLOW_BASE_REF:-}"
case "$base" in
*[!0]*) ;;
*) return 0 ;;
esac
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "note: not a git checkout, so every DAG is deployed" >&2
return 0
fi
if ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "note: $base is not in this clone (a force push, or a shallow clone), so every DAG is deployed" >&2
return 0
fi
echo "$base"
}
# summarize prints a Markdown summary of the deploy report $1.
summarize() {
python3 - "$1" <<'PY'
import json
import sys
try:
with open(sys.argv[1]) as f:
report = json.load(f)
except (OSError, ValueError):
print("Dexaflow deploy wrote no report; see the job log.")
sys.exit(0)
def cell(value):
return str(value or "").replace("|", "\\|").replace("\n", " ")
where = report.get("server") or ""
if report.get("target"):
where += " (target %s)" % report["target"]
print("### Dexaflow deploy to %s" % cell(where))
print()
dags = report.get("dags") or []
if dags:
print("| DAG | Status | Version | Image | Note |")
print("| --- | --- | --- | --- | --- |")
for d in dags:
note = d.get("error") or d.get("reason") or d.get("next")
print("| %s | %s | %s | %s | %s |" % tuple(cell(v) for v in (
d.get("dag_id"), d.get("status"), d.get("version"), d.get("image"), note)))
else:
print("No DAG changed, so nothing was deployed.")
for p in report.get("removed") or []:
print()
print("- %s (%s) was removed and is still registered. If its DAG should go, remove it with "
"`dexaflow dags delete %s --deregister`." % (cell(p.get("path")), cell(p.get("dag_id")), cell(p.get("dag_id"))))
for p in report.get("moved") or []:
print()
print("- %s moved from %s to %s." % (cell(p.get("dag_id")), cell(p.get("from")), cell(p.get("to"))))
PY
}
deploy() {
[ -n "${DEXAFLOW_TOKEN:-}" ] ||
die "DEXAFLOW_TOKEN is not set: store a deploy token as a CI secret (dexaflow auth create-token --scope deploy)"
[ -n "${DEXAFLOW_SERVER_URL:-}" ] ||
die "DEXAFLOW_SERVER_URL is not set: set it to the control plane URL"
base="$(base_ref)"
report="$tmp/report.json"
status=0
if [ -n "$base" ]; then
dexaflow deploy --all --changed-since "$base" --yes --output json >"$report" || status=$?
else
dexaflow deploy --all --yes --output json >"$report" || status=$?
fi
summarize "$report" >"$tmp/summary.md" || true
out="${DEXAFLOW_SUMMARY_FILE:-${GITHUB_STEP_SUMMARY:-}}"
if [ -n "$out" ]; then
cat "$tmp/summary.md" >>"$out"
else
cat "$tmp/summary.md"
fi
if [ -n "${DEXAFLOW_REPORT_FILE:-}" ]; then
cp "$report" "$DEXAFLOW_REPORT_FILE"
fi
return "$status"
}
cd "$(dirname "$0")/.."
cd "$workspace_dir"
case "${1:-deploy}" in
install) install_dexaflow ;;
deploy)
install_dexaflow
install_uv
deploy
;;
*) die "usage: sh ci/dexaflow-deploy.sh [deploy|install]" ;;
esac
Split into a build stage and a register stage, the same pipeline registers the image tag rather than its digest (see the warning at the top):
dexaflow compile dags/my_pipeline --build --push -o dag.json # image from registry: in dexaflow.yaml
dexaflow push dag.json
Recipes for Argo Workflows, Tekton, Jenkins, buildah and kaniko
dexaflow ci init writes the pipeline for GitHub, GitLab and Cloud Build. On
another CI system, run the script that dexaflow ci init generic writes,
ci/dexaflow-deploy.sh, in the dexaflow-cli image.
It does what the generated pipelines do: it deploys the DAGs changed since a
base commit, prints a summary of the report, and fails when a DAG fails to
deploy. The recipes below run it on Argo Workflows, Tekton and Jenkins, and
show buildah and kaniko for the DAGs whose image needs a real build.
Set up once, for every recipe:
Write the script at the top of the DAG repository and commit it:
dexaflow ci init genericThe script pins the CLI version that wrote it. Run it in the
dexaflow-cliimage of that same version: the script then finds the CLI onPATHand downloads nothing (the image has nocurlto download with). After upgrading the CLI, rundexaflow ci init generic --forceand move the image tag in the same commit.Store a deploy token as a Kubernetes Secret (for Jenkins, as a credential, shown in its tab):
kubectl create secret generic dexaflow-deploy-token --from-literal=token="$DEPLOY_TOKEN"Store the registry credential as a Secret of type
kubernetes.io/dockerconfigjson. The recipes mount it asconfig.jsonand pointDOCKER_CONFIGat its folder, which the CLI reads like~/.docker/config.json(Registry credentials):kubectl create secret docker-registry dexaflow-registry \ --docker-server=registry.example.com --docker-username=ci --docker-password="$REGISTRY_PASSWORD"The image carries no
gcloud,awsoraz, so it cannot read a cloud session: for Artifact Registry, ECR or ACR, store a credential the registry takes as a password (a service account key, an ACR token), and renew one that expires (an ECR password lasts twelve hours).
Which builder runs. The CLI picks the builder as
it does on a laptop. The dexaflow-cli image has uv and no container tool,
so a deploy from it takes skip, code-layer or dependency-layer, with
no daemon and no privileged pod. A DAG whose image needs a real build
(system_packages, a package without a wheel, its own Dockerfile) fails
at the build there: the Cloud Build remote builder
runs gcloud, which the image does not carry. The buildah and kaniko tabs
build such a DAG in the cluster; an image of your own with gcloud added
can use the remote builder instead.
Which commit to compare with. The script reads the base commit from
DEXAFLOW_BASE_REF. The Argo Workflows and Tekton recipes take it as a
parameter, the commit the branch was on before the push; the Jenkins recipe
passes the commit of the last successful build. Empty, all zeros or missing
from the clone deploys every DAG, and a DAG whose content is already the
current version registers nothing. A run that failed is not caught up by the
next one when the base is the previous push: see
Deploying only what changed for a ref that
moves only after a successful deploy.
A WorkflowTemplate that clones the repository at the commit to deploy and
runs the script. The mutex (Argo Workflows 3.6 or later) runs one deploy at a
time.
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: dexaflow-deploy
spec:
entrypoint: deploy
arguments:
parameters:
- name: revision # the commit to deploy
- name: base-ref # the commit deployed before it; empty deploys every DAG
value: ""
synchronization:
mutexes:
- name: dexaflow-deploy
volumes:
- name: registry-auth
secret:
secretName: dexaflow-registry
items:
- key: .dockerconfigjson
path: config.json
templates:
- name: deploy
container:
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
command: [sh, -c]
args:
- |
set -eu
git clone --quiet "$REPOSITORY" /tmp/repo
git -C /tmp/repo checkout --quiet "$REVISION"
sh /tmp/repo/ci/dexaflow-deploy.sh
env:
- name: REPOSITORY
value: https://github.com/acme/dags
- name: REVISION
value: "{{workflow.parameters.revision}}"
- name: DEXAFLOW_BASE_REF
value: "{{workflow.parameters.base-ref}}"
- name: DEXAFLOW_SERVER_URL
value: https://pro.example.com
- name: DEXAFLOW_TOKEN
valueFrom:
secretKeyRef:
name: dexaflow-deploy-token
key: token
- name: DOCKER_CONFIG
value: /etc/dexaflow/docker
volumeMounts:
- name: registry-auth
mountPath: /etc/dexaflow/docker
readOnly: true
Run it for a commit:
argo submit --from workflowtemplate/dexaflow-deploy \
-p revision="$NEW_COMMIT" -p base-ref="$PREVIOUS_COMMIT"
With Argo Events, a Sensor on the repository’s push webhook passes the push’s
after commit as revision and its before commit as base-ref. A private
repository needs a credential in the clone, for example a read-only token
from another Secret in the REPOSITORY URL.
A Task that clones the repository and runs the script, and a TaskRun
that starts it. The parameters reach the script as environment variables, so
a value is never pasted into the script’s text.
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: dexaflow-deploy
spec:
params:
- name: repository
type: string
- name: revision # the commit to deploy
type: string
- name: base-ref # the commit deployed before it; empty deploys every DAG
type: string
default: ""
workspaces:
- name: source
volumes:
- name: registry-auth
secret:
secretName: dexaflow-registry
items:
- key: .dockerconfigjson
path: config.json
steps:
- name: deploy
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
workingDir: $(workspaces.source.path)
env:
- name: REPOSITORY
value: $(params.repository)
- name: REVISION
value: $(params.revision)
- name: DEXAFLOW_BASE_REF
value: $(params.base-ref)
- name: DEXAFLOW_SERVER_URL
value: https://pro.example.com
- name: DEXAFLOW_TOKEN
valueFrom:
secretKeyRef:
name: dexaflow-deploy-token
key: token
- name: DOCKER_CONFIG
value: /etc/dexaflow/docker
volumeMounts:
- name: registry-auth
mountPath: /etc/dexaflow/docker
readOnly: true
script: |
#!/bin/sh
set -eu
git clone --quiet "$REPOSITORY" repo
git -C repo checkout --quiet "$REVISION"
sh repo/ci/dexaflow-deploy.sh
---
apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
generateName: dexaflow-deploy-
spec:
taskRef:
name: dexaflow-deploy
params:
- name: repository
value: https://github.com/acme/dags
- name: revision
value: "<the commit to deploy>"
- name: base-ref
value: "<the commit deployed before it>"
workspaces:
- name: source
emptyDir: {}
Create the TaskRun with kubectl create -f (it has a generateName), or
from a Tekton Triggers TriggerTemplate that passes the push’s after and
before commits. Tekton runs two TaskRuns at once if two pushes arrive
together, so start them from one trigger that queues, or accept that two
deploys of different commits may overlap. With a PersistentVolumeClaim as
the workspace instead of emptyDir, give the pod fsGroup: 65532 (in the
TaskRun’s podTemplate.securityContext) so the image’s user can write the
clone.
A Jenkinsfile for a multibranch pipeline on the
Kubernetes plugin. The agent
checks the repository out, and the dexaflow-cli container runs the script
in the same workspace. The base is GIT_PREVIOUS_SUCCESSFUL_COMMIT, the
commit of the last successful build of the branch, so a failed build is
caught up by the next one. BUILD_URL is recorded as the version’s
pipeline_url.
Set up once: the credentials dexaflow-deploy-token (secret text, the deploy
token) and dexaflow-registry (username and password for the registry), and,
in the branch source, the clone behaviours that fetch tags and do not clone
shallow (git describe and --changed-since need the history).
// Deploys the DAGs changed on main since the last successful build to Dexaflow.
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsUser: 1000 # the user of the agent container, which writes the checkout
runAsGroup: 1000
containers:
- name: dexaflow
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
command: ["sleep"]
args: ["infinity"]
'''
}
}
options {
disableConcurrentBuilds()
skipDefaultCheckout()
}
environment {
DEXAFLOW_SERVER_URL = 'https://pro.example.com'
}
stages {
stage('Deploy DAGs') {
when { branch 'main' }
steps {
script {
def scmVars = checkout scm
env.DEXAFLOW_BASE_REF = scmVars.GIT_PREVIOUS_SUCCESSFUL_COMMIT ?: ''
}
container('dexaflow') {
withCredentials([
string(credentialsId: 'dexaflow-deploy-token', variable: 'DEXAFLOW_TOKEN'),
usernamePassword(credentialsId: 'dexaflow-registry', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) {
sh '''
export HOME=/tmp/dexaflow-home # the image's home belongs to its own user, 65532
mkdir -p "$HOME/.docker"
auth="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 -w0)"
printf '{"auths":{"registry.example.com":{"auth":"%s"}}}' "$auth" >"$HOME/.docker/config.json"
sh ci/dexaflow-deploy.sh
'''
}
}
}
}
}
}
The shell steps are in single quotes, so Groovy never interpolates a secret
into the command line. On an agent outside Kubernetes, with Python 3.11 to
3.13, git and curl, sh ci/dexaflow-deploy.sh installs the pinned CLI
itself, and the same stage works without the container block.
For DAGs whose image needs a real build and no remote builder: the deploy
runs next to buildah, and --builder buildah makes
buildah the local tool of the fallback.
The CLI still generates the Dockerfile from dexaflow.yaml and hands it to
buildah build, then buildah push; the earlier builders still apply first,
so a DAG with a dependency image in the registry only pushes its code layer.
buildah needs no daemon, but it runs as root in its container, with the
vfs storage driver and chroot isolation. The step installs buildah on
the Debian image the GitLab job runs on, so the CLI has Python 3.12 for
compile. Whether a RUN step of the build (the apt-get of
system_packages, the pip install) works without privileged: true
depends on the cluster’s runtime and policies; many clusters allow a
privileged or root pod only in a namespace kept for builds.
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: dexaflow-deploy-buildah
spec:
params:
- name: repository
type: string
- name: revision
type: string
- name: base-ref
type: string
default: ""
workspaces:
- name: source
volumes:
- name: registry-auth
secret:
secretName: dexaflow-registry
items:
- key: .dockerconfigjson
path: config.json
steps:
- name: deploy
image: python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
workingDir: $(workspaces.source.path)
securityContext:
runAsUser: 0
env:
- name: REPOSITORY
value: $(params.repository)
- name: REVISION
value: $(params.revision)
- name: DEXAFLOW_BASE_REF
value: $(params.base-ref)
- name: DEXAFLOW_SERVER_URL
value: https://pro.example.com
- name: DEXAFLOW_TOKEN
valueFrom:
secretKeyRef:
name: dexaflow-deploy-token
key: token
- name: DOCKER_CONFIG # the CLI reads the registry credential here
value: /etc/dexaflow/docker
- name: REGISTRY_AUTH_FILE # and buildah reads the same file
value: /etc/dexaflow/docker/config.json
- name: STORAGE_DRIVER
value: vfs
- name: BUILDAH_ISOLATION
value: chroot
volumeMounts:
- name: registry-auth
mountPath: /etc/dexaflow/docker
readOnly: true
script: |
#!/bin/sh
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends buildah git curl ca-certificates >/dev/null
git clone --quiet "$REPOSITORY" repo
git -C repo checkout --quiet "$REVISION"
sh repo/ci/dexaflow-deploy.sh install # the CLI the script pins, checked against the release checksums
PATH="$HOME/.dexaflow/bin:$PATH"
cd repo
set --
case "$DEXAFLOW_BASE_REF" in
*[!0]*) set -- --changed-since "$DEXAFLOW_BASE_REF" ;;
esac
dexaflow deploy --all "$@" --builder buildah --yes
Start it with a TaskRun like the one in the Tekton tab, with
taskRef.name: dexaflow-deploy-buildah. The same script works as an Argo
Workflows container with securityContext.runAsUser: 0.
Notes:
- One platform per build. Set one entry in
build.platforms. With several, the CLI checks that the pushed tag is a manifest list holding every platform and fails the deploy otherwise; buildah makes a manifest list only with--manifest, which the CLI does not pass. - One credential file. buildah reads
REGISTRY_AUTH_FILE, the CLI reads$DOCKER_CONFIG/config.json; pointing both at the mounted Secret keeps the push and the digest read on the same credential. - The script’s own summary and its
--changed-sincefallbacks are not used here, since the script does not pass--builder: a base missing from the clone stops the deploy instead of deploying every DAG.
kaniko builds the Dockerfile it is given. The CLI does not hand kaniko the
Dockerfile it generates from dexaflow.yaml, so kaniko fits a project that
ships its own Dockerfile
(the escape hatch of ADR 0003). For a
project built from dexaflow.yaml alone, use the daemonless builders of the
other tabs, or buildah.
The Task takes the explicit steps: dexaflow compile writes dag.json and
names the image from the registry: block, kaniko builds and pushes that
image and writes the digest it pushed, and dexaflow push registers the
version with the image pinned to that digest. kaniko runs as root in its own
image and needs no privileged pod.
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: dexaflow-deploy-kaniko
spec:
params:
- name: repository
type: string
- name: revision
type: string
- name: dag-dir # the project to deploy, such as dags/etl
type: string
workspaces:
- name: source
volumes:
- name: registry-auth
secret:
secretName: dexaflow-registry
items:
- key: .dockerconfigjson
path: config.json
steps:
- name: compile
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
workingDir: $(workspaces.source.path)
env:
- name: REPOSITORY
value: $(params.repository)
- name: REVISION
value: $(params.revision)
- name: DAG_DIR
value: $(params.dag-dir)
script: |
#!/bin/sh
set -eu
git clone --quiet "$REPOSITORY" repo
git -C repo checkout --quiet "$REVISION"
mkdir -p out
dexaflow compile "repo/$DAG_DIR" -o out/dag.json
python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["image"])' out/dag.json >out/image
- name: build
image: gcr.io/kaniko-project/executor:debug # pin the executor you trust by digest
workingDir: $(workspaces.source.path)
env:
- name: DAG_DIR
value: $(params.dag-dir)
- name: DOCKER_CONFIG
value: /kaniko/.docker
volumeMounts:
- name: registry-auth
mountPath: /kaniko/.docker
readOnly: true
script: |
#!/busybox/sh
set -eu
/kaniko/executor --context="dir://$PWD/repo/$DAG_DIR" --dockerfile=Dockerfile \
--destination="$(cat out/image)" --digest-file=out/digest --custom-platform=linux/amd64
- name: register
image: ghcr.io/dexadata/dexaflow-cli:<VERSION>
workingDir: $(workspaces.source.path)
env:
- name: DEXAFLOW_SERVER_URL
value: https://pro.example.com
- name: DEXAFLOW_TOKEN
valueFrom:
secretKeyRef:
name: dexaflow-deploy-token
key: token
script: |
#!/bin/sh
set -eu
image="$(cat out/image)"
repo="$image"
case ${image##*/} in *:*) repo=${image%:*} ;; esac # drop the tag, never a registry port
dexaflow push out/dag.json --image "$repo@$(cat out/digest)"
Notes:
- The digest is kaniko’s.
--digest-fileholds the digest kaniko pushed, andpush --image <repo>@sha256:...registers it as given, so a later push of the same tag cannot change what is registered. - One DAG per run.
deploy --all --changed-sincedoes not drive this path: start oneTaskRunper project that changed. - The upstream kaniko repository is archived (2025). Use the executor image of a maintained fork your organization trusts, pinned by digest; the flags above are the executor’s.
--custom-platformmust matchbuild.platforms; kaniko builds one platform per run.
Control-plane deployment (Helm chart, in validation)
Deploying the control plane itself (Helm chart, published dexaflow-server/
dexaflow-migrate images, TLS on the agent channel, keyless cloud auth) is the
Pro track. One command installs the chart with auto-generated TLS and no
cert-manager — from its published OCI artifact
(helm install dexaflow oci://ghcr.io/dexadata/charts/dexaflow --version <VERSION>),
or from source on main for the bleeding edge
(helm install lf ./helm/dexaflow …). See Install Pro.
The chart is installable today and in validation — see the
Helm chart, the reproducible
Kubernetes test setup
(the deploy/k8s recipe is cloud-portable — it runs unchanged on EKS / GKE / AKS),
Operating modes,
and the Roadmap. The product proves itself in Lite first.
See also: DAG authoring · Operating modes.