Dexaflow v0.5.0
A 0.x (pre-1.0) build — SemVer carries the maturity, there is no separate
alpha/beta, and the pre-alpha series ended at v0.0.1 (ADR 0037). APIs and
on-disk shape may still evolve between minor versions; -rc.N tags are release
candidates gated by the E2E suite. Install this exact release with:
curl -fsSL https://raw.githubusercontent.com/dexadata/dexaflow/v0.5.0/install.sh | LEOFLOW_VERSION=v0.5.0 shA bare
curl … | shinstalls the latest stable release (/releases/latest
excludes pre-releases), so on a pre-release page it would NOT give youv0.5.0.
LEOFLOW_VERSIONmust sit on theshside of the pipe (notcurl) — a
VAR=x curl … | shprefix sets the var forcurlonly, soinstall.shwould
not see it and would fall back to latest-stable.
Added
-
A reference page for every image and chart a release publishes, and the
tag scheme for each: Published
images.
The only page that namedleoflow-server,leoflow-migrateand
leoflow-runtimetogether was a maintainer page about scanning them, so an
operator asking what Dexaflow publishes and how it is tagged had nowhere to
read.It also writes down a rule that lived only in the code: when
base_imageis
unset, a releaseddexaflowpinsdexaflow-runtime:py<ver>-v<X.Y.Z>, which
is immutable, while a development build falls back to
leoflow-runtime:py<ver>, a line every release republishes. Two people
compiling the same project can therefore end up on different bases, and
nothing said so. The configuration reference now says it where a reader is
already deciding whether to pin. -
An optional link from the UI back to the platform you serve it from
(#1290). Operators who run Dexaflow inside an internal portal or a hosting
console can setui.home_link.labelandui.home_link.url(Helm:
ui.homeLink), and every UI page shows a small "back" pill at the
bottom-left that opens the URL in the same tab. It is off by default. Boot
fails on a URL that is not absolutehttp://orhttps://, or on a label
without a URL, so a typo never renders a dead or script-bearing link. -
Brand the UI by configuration (#1289).
ui.themetakes a JSON object in
the shape of Airflow's[api] theme(Chakratokenssuch as the brand
palette and fonts,globalCss,icon,icon_dark_mode) and serves it in
/ui/config, so the bundled UI applies it through its own theming instead of
a patched bundle.ui.favicon_urlreplaces the favicon and
ui.stylesheet_urlsloads extra stylesheets such as web fonts. Helm:
ui.theme,ui.faviconUrl,ui.stylesheetUrls. All off by default; boot
fails on invalid theme JSON, an unknown theme key, or a URL that is not
http(s) or root-relative. See Branding the
UI. -
Hand sign-in and sign-out to the platform Dexaflow is served from
(#1288). Withauth.external_signin_urlset, a UI visitor without a session
goes to that URL with the page they asked for innext, instead of
Dexaflow's sign-in page, so deep links survive.auth.external_signout_url
is where sign-out lands after clearing the session. Helm:
auth.externalSigninUrl,auth.externalSignoutUrl. Off by default;
?local=1and a refused single sign-on still reach Dexaflow's own page, and
boot fails on anything but an absolutehttp(s)URL. -
Open a UI session from a trusted issuer's token (#1284). A platform that
already signs its users in can now hand them to Dexaflow without Dexaflow
storing a password and without the platform holding Dexaflow's signing
secret: configureauth.trusted_issuer(issuer, JWKS URL, audience, tenant
claim, allowed tenants, allowed origins; Helm:auth.trustedIssuer) and post
a short-lived token the issuer signed toPOST /api/v2/auth/sessionfrom a
page on one of the allowed origins. Dexaflow verifies it
against the issuer's public keys, signs in the existing active user linked
to the token's subject in the token's tenant, and redirects tonext. The
token never creates users or grants roles, carries ajtiand opens one
session (a replayedjtiis refused), and lives at most two minutes by
default (max_lifetime_seconds, up to 600). Off by default, validated at boot,
and keys are fetched on first use so an issuer outage cannot block boot. See
Trusted-issuer
handoff. -
An operator service API to create tenants and passwordless users
(#1283). Withauth.service_tokenset (Helm:auth.serviceTokenor
auth.serviceTokenExistingSecret),PUT /api/v2/service/tenants/{tenant}
creates a tenant with the same built-in roles, permissions and default pool
asdefault, andPUT /api/v2/service/tenants/{tenant}/users/{subject}
makes sure a user with no password exists there, linked to the trusted
issuer under that subject, with exactly the roles given. Both are idempotent
and authenticate with the service token, never a user session. Users are
linked only in tenants the trusted issuer may sign in to, and every call is
recorded in the audit trail. Off by default. The OIDC boot warning about a missing tenant now names the service
API as the way to create one. See Operator service
API.
Changed
-
Leoflow is now Dexaflow. Messages, the login and IDE pages, the UI navbar,
the docs and the README use the new name and thedexaflowcommands. The
README and a new "The name" page tell where both names come from, and keep the
dedication to Leonardo (@leonardo-jas),
after whom Leoflow was named. Everything adopted under the old name keeps
working; the configuration reference lists each one. -
Images and the chart are published as
dexaflow-*andcharts/dexaflow,
and every release is still published under theleoflownames. Each
release pushesdexaflow-server,dexaflow-migrateanddexaflow-runtime,
and the same builds asleoflow-server,leoflow-migrateand
leoflow-runtime: same tags, same digests, all signed. Values files,
Dockerfiles andFROMlines that nameleoflow-*keep receiving new
releases without a change. The chart's image defaults and the runtime base
dexaflow compilebuilds on are thedexaflow-*names.The chart is published twice from the same templates:
charts/dexaflowfor
new installs andcharts/leoflowfor releases installed before the rename.
The chart name decides the selector labels and every resource name, and a
Deployment's selector cannot change in place, so an existing release is
upgraded withoci://ghcr.io/dexadata/charts/leoflow(or with the
dexaflowchart and--set nameOverride=leoflow, which renders the same
resources). The CI upgrade guard now upgrades a released install exactly
that way. -
New installs keep their DAG projects in
~/dexaflow. An install from
before the rename that has~/leoflowkeeps using it as the default
workspace, and a workspace recorded bydexaflow setupis used as is, so no
project moves. The bundle installer copies its DAGs into the recorded
workspace. Messages that still named the olddevcommand now say
dexaflow lite. -
DAGs import the authoring names from
dexaflow, andfrom leoflow import dbt_groupkeeps working. The package adag.pyimports at its top is now
dexaflow, at parse time and inside task images and Lite venvs alike.
leoflowis a re-export of it, not a copy:leoflow.dbt_group is dexaflow.dbt_group, so DAGs written before the rename compile and run
unchanged, and the two can never drift apart.A Lite venv built before this release has no
dexaflowpackage; the
freshness check now probes for it, so the venv is rebuilt once on the next
dexaflow liteinstead of everyfrom dexaflow import ...failing in the pod.The internal modules
leoflow_runtimeandleoflow_parserkeep their names:
agents in already built task images runpython -m leoflow_runtime, and the
parser_cmdin existingconfig.yamlfiles namesleoflow_parser. -
Metrics are named
dexaflow_*, and every family is still published as
leoflow_*. The control plane's/metricsendpoint exposes each
dexaflow_*family a second time under its pre-rename name, with the same
help, type, labels and values, so dashboards, alerts and recording rules
written againstleoflow_*keep working without a change. Moving a dashboard
to the new names is a rename of the metric, not of anything else. Each family
appears twice in a scrape; series counts for these families double.The soak monitor reads either name and counts each family once, so it keeps
working against control planes from before this release. -
The documentation moves to its own domain, https://dexaflow.dexadata.ai/.
The site used to live athttps://neochaotic.github.io/leoflow/, an address
tied to the account that owns the repository, so moving the repository would
have broken every link to the docs. Serving the site from a custom domain
decouples the two. The/leoflow/path prefix goes away with it: the latest
release is at the root,mainat/dev/, and archived releases at
/vX.Y.Z/, as before. Oldneochaotic.github.io/leoflow/...links redirect
to the same page on the new domain.The README's Pro install snippet also stops pointing
helm repo addat the
docs site, which never served a chart index. It now installs from the OCI
chart in GHCR, where the chart is actually published. -
The repository is now
github.com/dexadata/dexaflow. It moved from
neochaotic/leoflowto the DexaData organization and took the new name. Old
github.com/neochaotic/leoflowandgithub.com/dexadata/leoflowlinks,
clones, remotes and raw URLs keep working through GitHub's redirect; new
links, the install script and the release tooling use the new path. The
one-line installer is
curl -fsSL https://raw.githubusercontent.com/dexadata/dexaflow/main/install.sh | sh.Images and the Helm chart are published under
ghcr.io/dexadata. Every
earlier tag stays where it was, underghcr.io/neochaotic, and keeps pulling;
a pinnedghcr.io/neochaotic/...reference in your own values or Dockerfiles
does not need to change until you upgrade.The Go module path is
github.com/dexadata/dexaflow, so
go install github.com/dexadata/dexaflow/cmd/dexaflow@latestworks.
Release signatures made under the old repository names still verify; the
cosign commands in the docs accept both. -
The binaries are now
dexaflow,dexaflow-server,dexaflow-agentand
dexaflow-mcp, and everyleoflowname keeps working. The installer puts
the new binaries on your PATH together withleoflow*links to them, and
make builddoes the same in./bin, so scripts, aliases and muscle memory
that callleoflow liteorleoflow compilerun exactly as before. Started
asleoflowfrom an interactive terminal, the CLI prints a one-line note with
its new name; piped or scripted output is unchanged. The old names are not
scheduled for removal.The CLI finds its companion server and agent under either name, beside itself,
in~/.dexaflow/binor~/.leoflow/bin, on PATH or in./bin, so a mixed
install (a new CLI next to an olderleoflow-server) still starts.version
now reportsdexaflow. Release archives are named
dexaflow_<version>_<os>_<arch>.tar.gz; the installer still installs earlier
releases from theirleoflow_*archives when pinned withDEXAFLOW_VERSION
(orLEOFLOW_VERSION).Task images keep
leoflow-agentas a link todexaflow-agent: DAG images
built before this release, and control planes that launch the agent by its old
name (theexecutor.agent_pathdefault), keep working without a rebuild. The
control-plane image's entrypoint is now/dexaflow-server. -
A project is configured by
dexaflow.yaml, the environment uses
DEXAFLOW_*, and per-user state lives in~/.dexaflow. The old names keep
working.dexaflow initscaffoldsdexaflow.yaml; a project that only has
leoflow.yamlis read from it as before, and a note on an interactive terminal
says so. When a project has both,dexaflow.yamlwins and the note names the
ignored file, so an edit to the stale one is not lost silently.Every binary mirrors
LEOFLOW_*andDEXAFLOW_*onto each other at startup,
so either spelling configures the same key and processes they start inherit
both; when both are set and differ, theDEXAFLOW_*value wins and the
conflict is logged. The two filters that keep the agent's own variables away
from task code (the agent's secrets, the control-plane address) now cover both
prefixes, so a mirroredDEXAFLOW_AUTH_JWT_SECRETor an author's
DEXAFLOW_CONTROL_PLANE_ADDRis stripped exactly like itsLEOFLOW_*twin.On a machine with an existing
~/.leoflow, nothing is moved:~/.dexaflowis
created as a link to it, so a running Lite, its Postgres data and its cluster
mounts are untouched and both paths reach the same state.uninstallremoves
the data behind the link and the link itself.The control plane keeps passing
LEOFLOW_*to task pods, since agents built
before this release only read that prefix. Database names, the Lite cluster
name and theleoflow.io/*pod labels keep their names. -
Dependency refresh: nine modules updated, gRPC deliberately held back.
The AWS SDK, Google Cloud auth and storage, the MCP Go SDK and the Google API
client all move up.google.golang.org/grpcstays atv1.83.2. The current release,v1.84.0,
carries GO-2026-6443, a server panic
reachable through a missing authority or Host header, and our code calls that
path. It is not a matter of waiting for the next release either: the fix is
dated 2026-08-25 andv1.84.0shipped on 2026-09-17, so the release was cut
from a line that did not carry it, and the only version with the fix is an
unreleased commit.v1.83.2is a published release on the fixed side of its
own range, which is a better place to sit than an untagged commit governing
the agent gRPC channel. -
The rule that decides whether a pull request needs the heavy Kubernetes
jobs lives in one place, and is tested. Four jobs asked that question and
each carried its own copy of the same regex, with nothing reconciling them
and no way to ask "would this file list run or skip?" short of opening a pull
request and watching.That cost a cycle already. When changelog fragments landed, every pull
request started carrying one,.changes/was not in the list, and the
docs-only pull requests the rule exists to spare became the ones it stopped
sparing. It was found by reading, because there was no test to fail.scripts/ci-heavy-gate.shnow holds the rule, answers--decidefor a file
list on stdin, and has twelve self-test cases.test/README.mddocuments
every suite, what each proves and what it does not, and the self-test reads
the skip list out of that page and fails when the prose and the gate
disagree. -
Bumped
github/codeql-actionto v4.38.2 across all eleven steps in one
change. Dependabot opens one pull request per sub-action and CodeQL requires
them to match, so each of those pull requests fails its own CodeQL jobs;
scripts/check-codeql-action-pins.shrefuses the mixed state.
Fixed
-
dexaflow setupnames the new commands in its closing summary. The admin
banner readLEOFLOW LITE ADMINand the next-step hints suggested
leoflow lite; they now sayDEXAFLOW LITE ADMINanddexaflow lite. The
leoflowcommand keeps working. -
dexaflow compile <dir>wrotedag.jsoninto the directory you ran it from,
silently clobbering one that was already there. The--outputflag defaulted
to the bare namedag.json, which resolves against the current directory
rather than the project the command was pointed at. So compiling a scratch
project from a checkout overwrote that checkout's own tracked artifact: the
compile succeeded, the path it printed was correct, and the damage was to a
file the command was never asked to touch. It is recoverable through git when
the target happened to be tracked, and not necessarily otherwise.The default is now
<project>/dag.json, which is whatcompile <dir>reads
like, what the success line already implied, and whatdexaflow deployand
every e2e script had already been passing by hand. Compiling the directory you
are standing in still writes./dag.json, so the common interactive case and
every documented pipeline that reads the artifact back are unaffected; the
change is confined to the case that was broken. An explicit-ois used
verbatim, as before.Two consequences of moving the write into the source tree are handled with it.
A project directory that is not writable, which used to succeed because the
artifact went to the current directory instead, now fails with a message
naming the directory and pointing at-o, rather than aPermissionError
traceback out of the Python parser. Anddexaflow initscaffolds a
.gitignorecoveringdag.json, so the firstgit add .after the first
compile does not stage a build artifact (#1084). -
Two ways the wrong Python interpreter judged your DAG.
dexaflow validatecheckeddag.pyunder whatever interpreter it found,
never consultingpython_version. The failure was the annoying direction:
type X[T]is aSyntaxErroron 3.11, so validate rejected a DAG that
compiles and runs correctly in the cluster, with a message that reads as a
complaint about the author's code. It now asks the same questiondexaflow lite
asks, with the same exemptions (a defaulted version,base_imageset, a
deprecated version). When the declared interpreter is not installed the
fallback is asymmetric, because Python's grammar grows: an interpreter at
least as new accepts everything the declared minor accepts, so it is used
and a syntax error from it is real; only an older one is refused, with a
warning, since a check that cannot be trusted is worth less than no check
(#1094).A per-DAG venv whose interpreter went missing while its directory stayed,
from a partial delete or apythonsymlink into a build that was upgraded
away, was recreated over the existing directory. The freshness markers live
inside the venv and survived, so.leoflow-depsasserted that a brand-new
interpreter already had the project's dependencies, and it never got them: a
venv that looks provisioned and fails at import time. The markers are now
dropped whenever the interpreter had to be created (#1096). -
The last places that still said leoflow now say Dexaflow. The UI's Docs
and GitHub links pointed at the oldneochaotic.github.io/leoflowsite, which
no longer exists; they now open https://dexaflow.dexadata.ai/ and the
repository. The MCP server reports itself asdexaflow, CLI messages name
dexaflow, and the docs use thedexaflow*binaries, adexaflowrelease
name for new Helm installs, andcharts/leoflowfor upgrading a release
installed before the rename. The CI install snippets in the docs downloaded a
release asset that never existed; they now use the installer. -
Warm-pool tasks that run longer than the pod-lost grace period are no longer
failed aspod_lostwhile they run. A warm attempt runs in a shared warm
pod with no per-task labels, so the pod-lost reaper always found "no pod" for
it. That reaper now leaves warm attempts to the warm-worker-lost reaper, and
applies its grace period before its per-tick limit, so recent attempts no
longer crowd out the ones it can act on.A warm worker can only register for the DAG version its token was minted
for. The pool in the worker's register message was trusted as sent, so a
worker credential for one tenant's DAG version could register in another
pool and receive its assignments. Such a registration is now refused with
PermissionDenied. -
The v0.4.8 release page shipped empty, and the build stayed green. The
previous fix composed the notes fromCHANGELOG.mdand handed them to
GoReleaser with--release-notes. The notes were right, the flag was
accepted, and the page carried nothing: every check passed because nothing
downstream ever looked at the published release.The body is now written with
gh release editand then read back, and a
body under 200 bytes, or with no entries or no sections, fails the release.
Composing the right notes was never the goal; a page that carries them is,
and only reading the page apart tells those two apart.The v0.4.8 page was repaired by hand when this was found, and now carries its
17 entries. -
Code scanning stopped running whenever Dependabot bumped part of it.
Everygithub/codeql-action/*step has to be pinned to the same commit:
initwrites a configuration file stamped with its own version and
autobuildandanalyzeread it back, so a mixed set dies withLoaded a configuration file for version '4.38.1', but running version '4.35.5'.Dependabot treats each sub-action as its own dependency and opens one pull
request per sub-action, so a workflow whose steps disagree is the normal state
during a bump. The repository was already sitting at two versions before this,
and the failure surfaces inside CodeQL, which reads as a code-scanning problem
rather than a pinning one. The message even suggests the wrong remedy,
replacing autobuild with custom build steps.All eleven steps across three workflows now share one commit, and
scripts/check-codeql-action-pins.shfails the build when they drift apart
again. The gate found a fifth stale pin, in two workflows nobody had looked
at, the first time it ran. -
Every GA left the documentation root on the previous release. The version
menu is stated in two files:website/scripts/ci/versions.jsondrives the
published Pages tree, and the[[params.versions]]block in
website/hugo.tomlis what a plainhugobuild uses. The release cut
rewrote the first and left the second, socheck-docs-version-menu.shfailed
the docs-promotion pull request of every release, and while that pull request
sat unmerged the release was published with its documentation root still
serving the version before it. v0.4.8 shipped into exactly that state.The cut now renders both, through the same script that renders the Pages
overlay and from the same manifest, so the fallback cannot drift from what is
published. A self-test runs the promotion against a fixture and then asserts
the gate passes, which binds the generator to the checker rather than leaving
each to discover the other's omission once per release.
Security
-
Config values could add their own instructions to a generated Dockerfile.
#1066 gavedependenciesandsystem_packagesa line-break guard because a
newline ends theRUNinstruction. The same class lived in every other value
the generator interpolates, and those reachFROMandCOPY, where no shell
is involved and no quoting helps: the newline IS the instruction separator.
base_image: "python:3.11-slim\nRUN <cmd>"rendered as two instructions, and
dbt.project,dbt_groups.*.project,dag_sourceandinclude_pathsdid the
same in theirCOPYlines. So didexclude_pathsin the generated
.dockerignore, where an injected!secrets.envdefeats the author's own
exclusion of a credentials file.Whoever writes
leoflow.yamlis usually whoever runs the build, so locally no
boundary is crossed. What it means is that merge rights become arbitrary
instruction injection into a published image, on a runner holding the deploy
credentials.A line break, a vertical tab and a form feed are now refused in every
interpolated value, naming the field and the value. Vertical tab and form feed
are included because Docker splits a line on[\t\v\f\r ]+, so they are word
separators exactly like a space. ACOPYpath additionally refuses',",
\and$: after parsing, everyCOPYoperand goes through a lexer that
strips quotes, eats backslashes and expands$VARwhatever quoting the line
used, soCOPY ["d'a't.py", ...]would copydat.py. A leading--, which
Docker reads as aCOPYflag, is refused too, andbase_imagerefuses any
whitespace, whichFROMwould read as a stage name. A path containing a space
or starting with[is quoted rather than refused, so an existing project's
generated Dockerfile is unchanged unless it held one of the refused characters.dexaflow lite --executor=k8shas its own Dockerfile generator, which had the
whole class untouched plus the unquotedpip installof #1064. It now shares
the same guards. It was the worse of the two places to have them: it writes
<project>/Dockerfileand never removes it, and a project-supplied Dockerfile
is afterwards honoured verbatim, so one run would have persisted a poisoned
file that every latercompile --buildused (#1070). -
A new Dexaflow Lite install no longer encrypts connection passwords with a
key published in this repository. Every Lite install shared the same
constant, so anyone holding a Lite database file read every stored credential
in it without work, while the documentation said "encrypted at rest" (#486). A
database file travels far more easily than shell access does: a backup, a
synced home directory, a support bundle, a resold laptop.dexaflow setupnow generates a key for that install alone, and a fresh
install never accepts the published one.An install created before this is NOT migrated. Its secrets stay under the
shared key, anddexaflow litesays so on every start. Moving an existing
install means re-encrypting every stored credential, and three security
reviews of an attempt at it found ordering, interruption and privilege defects
that each destroyed credentials, so it was pulled rather than shipped
half-right. It is tracked on its own.dexaflow uninstallkeeps your datastore but removes the config holding its
key, so it now warns before doing that and tells you what to copy.DEXAFLOW_SECRET_KEYaccepts a comma-separated list for operators. The
first entry encrypts and decrypts, later entries only decrypt, and the control
plane re-encrypts onto the first entry at startup and reports what it moved.
Same rule as Airflow'sfernet_key. This replaces ADR 0019's previous
position, that a key change invalidates existing ciphertexts and connections
are re-entered. A row no configured key can open is left untouched and
reported, never overwritten, and a row another writer changed mid-pass is left
to that writer.Upgrade note: because the value is split on commas and trimmed, a raw
32-character passphrase containing a comma, or with leading or trailing
whitespace, no longer parses. Hex and base64 keys are unaffected.Two ways to lose credentials were closed along the way.
dexaflow lite reset-passwordread the key through the config loader, which overlays
LEOFLOW_*environment variables, and wrote the result back: an operator with
DEXAFLOW_SECRET_KEYexported would have had the shell value persisted over
the real key. And every rewrite ofconfig.yamlis now atomic, 0600 enforced
and owner preserving: it previously left an existing world-readable file
world-readable while adding an encryption key to it, and a crash mid-write
could take the encryption key, the JWT secret and the admin hash together.
Full commit log: v0.5.0-rc.1...v0.5.0
Artifacts are checksummed (SHA-256) and the checksums file is cosign-signed
(keyless). Verify with cosign verify-blob.