Flyte owns the DAG and the data flow between nodes. Armada owns scheduling and execution. The bridge is a single Flyte 2 connector, plus a small set of changes to the Flyte 2 executor that let it route tasks to that connector.
This document is a reference for how the two systems fit together. Every claim is grounded in the code that implements it.
Flyte authors, Armada schedules. A user writes a normal Flyte 2 @env.task Python function. Flyte
renders it into a container whose entrypoint is a0 (the Flyte task runtime, which loads the code
bundle, reads typed inputs from the blob store, runs the function, and writes typed outputs back).
Instead of scheduling that container itself, Flyte routes the task to the Armada connector, which
submits it as an Armada job. Armada schedules the pod onto a cluster it manages, a0 runs there, and
the typed result flows back through the shared blob store. Flyte reads that result directly, so its
lineage and typed-data features are untouched. The connector delegates scheduling only. It never
touches the data plane and never synthesizes an output (connector.py:362-364).
The pieces (docs/getting-started.md):
- Flyte 2 runs as
flyte-binary: the control plane, the UI, and theTaskActionreconciler. - Armada's control plane submits and schedules jobs. Its executor creates the job pods on a Kubernetes cluster.
- The connector runs as a host process that bridges the two.
Where these run is up to you. The blob store is shared, so Flyte and the Armada pods address the same bucket.
The connector runs as an out-of-process gRPC service launched as c0, and the Flyte backend calls
CreateTask / GetTask / DeleteTask on it. It holds no per-job state in the object itself,
caching only the gRPC channel and its resolved config (connector.py:145-161). All per-job state
lives in ArmadaJobMetadata, which Flyte persists and hands back on every call.
src/armada_flyte/task.py is what a workflow author touches.
ArmadaConfig(task.py:19-38) is a dataclass of per-task Armada submission knobs:queue(default"flyte"),job_set_id(default"flyte-dag"),namespace(default"default"),priority(default1), optionalcpu/memoryoverrides (Nonemeans defer toflyte.Resources), and the gang triplegang_id/gang_cardinality/gang_node_uniformity_label. It is deliberately minimal. Resources are normally declared the stock-Flyte way, andArmadaConfigcarries only the Armada-specific fields.ArmadaFunctionTask(AsyncConnectorExecutorMixin, AsyncFunctionTaskTemplate)(task.py:41-64) is the task type._TASK_TYPE = "armada"(task.py:57), and__post_init__setsself.task_typeto it (task.py:59-61). That string routes the task to the connector.custom_config()returnsasdict(self.plugin_config)(task.py:63-64), which Flyte serializes into the task template'scustomfield.TaskPluginRegistry.register(ArmadaConfig, ArmadaFunctionTask)(task.py:69) wires the two together. AnyTaskEnvironment(plugin_config=ArmadaConfig(...))builds Armada-typed tasks.
ArmadaConfig is serialized and persisted in the control plane, which is why credentials never go
here (see next section).
src/armada_flyte/config.py holds the connector's own settings.
ConnectorConfig(config.py:23-36) is a frozen dataclass:armada_url(default"localhost:50051"), andblob_endpoint/blob_access_key/blob_secret_key(default empty). It is marked as the extension point forauth_token/tls/ca_cert(config.py:34-36).- Credentials live here, on the connector, never in
ArmadaConfig(config.py:4-6).ArmadaConfigis serialized into the task template and persisted, so a token there would leak into the control plane DB. - Resolution has three layers (
config.py:8-13): dataclass defaults, then environment (from_envreadsARMADA_URLandFLYTE_BLOB_ENDPOINT/FLYTE_BLOB_ACCESS_KEY/FLYTE_BLOB_SECRET_KEY,config.py:38-46), then in-codeconfigure(**kwargs)which validates keys and stashes them in a module-level_overrides(config.py:53-73).resolve_config()composes them:from_env()thenreplace(cfg, **_overrides)(config.py:76-79). - Resolution is lazy, on first connector use (
connector.py:148-152). There is no set-env-before-import trap, butconfigure()must run before the first task, because the client is built once and cached.
A single shared S3-compatible bucket is addressed by the backend, the client, and the Armada pods
alike. The connector injects the pod-reachable blob endpoint onto each job
(connector.py:_storage_env). The a0 container reads inputs and writes outputs.pb there, and
Flyte reads outputs.pb directly.
-
Author. A user writes
@env.taskin aTaskEnvironment(plugin_config=ArmadaConfig(...)). Flyte renders the task into a container whose entrypoint isa0, and serializesArmadaConfiginto the task template'scustomfield. -
Route. The task's
task_typeis"armada"(task.py:57), which matchesArmadaConnector.task_type_name(connector.py:138). The Flyte backend routes it to the connector service. -
create(connector.py:280-330). The connector reads backcfgfromtask_template.customviajson_format.MessageToDict(connector.py:288), defaultingqueueto"flyte"andjob_set_idto"flyte-dag"(connector.py:289-290). It requirescontainer.imageor raisesValueError(connector.py:292-296). This container is Flyte's rendereda0entrypoint. It wraps the container into a pod (_pod_from_flyte_container,connector.py:252-278): one container namedarmada-task, the image andcommandtaken verbatim from Flyte's rendered container (the connector does not synthesize thea0command),argspassed through_runtime_args(see below),imagePullPolicy="IfNotPresent"so akind loaded image is used, and env built from the rendered env then overlaid with_storage_env()so the pod-reachable blob endpoint wins over any in-cluster endpoint the backend baked in (connector.py:256-262). The pod spec setsterminationGracePeriodSeconds=0andrestartPolicy="Never"(connector.py:274-278). The connector builds a job request item withpriority,namespace, the pod spec, aflyte.org/connector: armadalabel, and gang annotations (connector.py:305-311), then callssubmit_jobs(queue, job_set_id, [item])through the_callwrapper (connector.py:312-318). Ifresp.job_response_items[0].erroris set it raisesRuntimeError(connector.py:319-321). It returnsArmadaJobMetadata(job_id, job_set_id, queue, output_prefix=...)(connector.py:325-330). -
Armada schedules the pod. Armada queues the job, schedules it under fair-share, and its executor creates the pod on the Armada cluster.
-
a0runs. It reads typed inputs from the blob store, runs the user function, and writes typedoutputs.pb(orerror.pbon failure) to its per-action blob prefix. Flyte readsoutputs.pbdirectly on success (connector.py:362-364). -
getpolls (connector.py:356-372). The connector callsget_job_status([resource_meta.job_id])through_call(connector.py:357-359), looks up the state (defaulting toUNKNOWNif absent,connector.py:360), and maps it to a Flyte phase via_ARMADA_STATE_TO_PHASE, defaulting toRUNNING(connector.py:361). On a terminalSUCCEEDED/FAILEDit checksa0's error file (see the handshake section) and can override the result toFAILED(connector.py:365-368). Otherwise it returns the mapped phase with a message naming the Armada state (connector.py:369-372). -
Terminal. Once the phase is terminal, the framework stops polling. On
SUCCEEDEDFlyte reads the typed output from the blob store.delete(connector.py:374-382) cancels the job viacancel_jobs(queue, job_set_id, job_id)using the persisted metadata (all three fields needed).
The connector maps Armada JobState onto Flyte's TaskExecution.Phase (connector.py:48-60):
| Armada JobState | Flyte phase | Note |
|---|---|---|
QUEUED, SUBMITTED, LEASED |
QUEUED |
|
PENDING |
INITIALIZING |
|
RUNNING |
RUNNING |
|
UNKNOWN |
RUNNING |
transient, keep polling |
SUCCEEDED |
SUCCEEDED |
|
FAILED, REJECTED |
FAILED |
|
CANCELLED |
ABORTED |
|
PREEMPTED |
RETRYABLE_FAILED |
preemption is expected, so Flyte retries |
Mapping PREEMPTED to RETRYABLE_FAILED is deliberate (connector.py:47). Armada preempts jobs as
part of normal fair-share scheduling, so the node should retry rather than fail the run. Any state
not in the table falls back to RUNNING (connector.py:361), which keeps the connector polling
rather than failing on an unrecognized state.
The task runs as a stock Flyte a0 container. The connector does not supply the data plane. It only
makes sure the pod can reach the blob store and that a0's arguments are complete.
_storage_env (connector.py:183-196) injects FLYTE_AWS_ENDPOINT / FLYTE_AWS_ACCESS_KEY_ID /
FLYTE_AWS_SECRET_ACCESS_KEY onto the pod, which a0 reads via flyte.storage.S3.auto(). The name
split is deliberate: the connector reads its own FLYTE_BLOB_* env (via ConnectorConfig) but
injects FLYTE_AWS_*, exactly as FlytePropeller does for in-cluster task pods. These are applied
after the container's own env (connector.py:260-262) so the pod-reachable NodePort endpoint
overrides any in-cluster endpoint the backend baked into the container. If blob_endpoint is empty,
nothing is injected (connector.py:190-191).
a0 can write error.pb while the pod still exits 0, so Armada, which only sees the pod exit, may
report the job SUCCEEDED even though the task failed (connector.py:336-339). So get reads the
error file on any terminal SUCCEEDED/FAILED and lets it decide (connector.py:365-368).
_task_error (connector.py:332-354) returns None if there is no output prefix, otherwise loads
the error via flyte._internal.runtime.io.load_error under asyncio.wait_for(..., timeout=8)
(connector.py:349-351) and returns err.message or None. A bare except Exception maps any
failure to None (connector.py:353). The 8-second bound matters because when the connector runs
as the c0 service, Flyte's global storage may not point at the pods' blob store, and an unbounded
read would hang the poll loop and never report the job as terminal. On timeout it falls back to the
Armada-reported phase (and on the backend, FlytePropeller reads error.pb itself anyway).
In backend (webapi/connector) execution there is no FlytePropeller to fill the runtime template
placeholders that Flyte leaves in a0's args, so the connector does that in _runtime_args
(connector.py:217-250). Every substitution is conditional, so it is a no-op on already-complete
local args (connector.py:224-225). It substitutes {{.runName}} and {{.actionName}} from the
task execution metadata (connector.py:227-242), pulling org from meta.labels.get("organization").
Because a0 requires it, it appends --run-base-dir if absent, computed from the output prefix by
stripping the trailing /<run>/<action>/<attempt> via rsplit("/", 2)[0] (connector.py:243-245).
It appends --org / --project / --domain if missing (connector.py:246-249).
_outputs_path (connector.py:206-215) scans the args for the --outputs-path value, the
per-action blob prefix a0 writes outputs.pb / error.pb to. This is distinct from the base
output_prefix passed to create. It is stored on ArmadaJobMetadata.output_prefix
(connector.py:329) so get can read error.pb from the right place.
ArmadaConfig exposes gang_id, gang_cardinality, and gang_node_uniformity_label
(task.py:36-38). _gang_annotations (connector.py:69-82) translates them into Armada gang
annotations armadaproject.io/gangId and armadaproject.io/gangCardinality, plus
armadaproject.io/gangNodeUniformityLabel when set (connector.py:64-66, 75-81). A job becomes a
gang member only when gang_id is set and gang_cardinality is 2 or more. Otherwise it is an
ordinary job with no gang annotations (connector.py:72-74). Jobs sharing a gang are scheduled
all-or-nothing together.
The gang_id is scoped per run. In create it is rewritten to f"{gang_id}-{run_name}"
(connector.py:301-303), where run_name comes from
task_execution_id.node_execution_id.execution_id.name (_run_name, connector.py:198-204). So
each run forms its own gang and concurrent runs do not collide, while every gang member within a run
still shares the same id.
Flyte 2 ships the webapi/connector plugin code, and as of
flyteorg/flyte#7565 its executor registers the plugin
and supplies the no-op ResourceManager, ResourceRegistrar, and SecretManager the plugin
machinery needs. Any stock flyte-binary-v2 build from that commit on routes armada tasks to the
connector. Earlier builds do not (see docs/getting-started.md).
flyteplugins/go/tasks/plugins/webapi/connector/plugin.go is a single remote plugin with ID
"connector-service" (plugin.go:34). RegisterConnectorPlugin gob-registers ResourceMetaWrapper
and ResourceWrapper and registers the remote plugin (plugin.go:496-500).
Create(plugin.go:124-225) reads the task template, renders the container args, resolves the connector for the task category, resolves anyConnectionsecrets viataskCtx.SecretManager().Get(plugin.go:170-184, which nil-derefs without a SecretManager), callsCreateTaskon the async connector client, and returns aResourceMetaWrappercarrying the opaqueConnectorResourceMetabytes plusOutputPrefix,TaskCategory,Connection,Project, andDomain(plugin.go:217-224). That wrapper is gob-registered so Flyte can persist it.Get(plugin.go:227-261) castsResourceMetaback to the wrapper, re-resolves the connector, and callsGetTask, returning aResourceWrapperwith the phase, outputs, and message.Delete(plugin.go:263-290) round-trips the resource meta and callsDeleteTask.Status(plugin.go:317+) maps the phase, and onSUCCEEDEDwrites the connector's returned outputs to the output path viawriteOutput.IsTerminal(plugin.go:93-96) stops polling once the phase isSUCCEEDED,FAILED,RETRYABLE_FAILED, orABORTED.
The resource_meta persistence is what makes the connector stateless. Flyte serializes the opaque
ConnectorResourceMeta (the Python ArmadaJobMetadata) and re-supplies it on every Get/Delete,
so the connector never has to remember anything about a job between calls.
executor/setup.go registers the plugin and supplies the managers stock v2 omits.
connectorplugin.RegisterConnectorPlugin(&connectorplugin.ConnectorService{})(setup.go:73), which must run before the plugin registry is snapshotted.- The setup context is built with
plugin.NewNoopSecretManager()andplugin.NewNoopResourceRegistrar()(setup.go:137-141). - The reconciler is given
plugin.NewNoopResourceManager()(setup.go:189) andplugin.NewNoopSecretManager()(setup.go:191-193).
The no-op managers live in executor/pkg/plugin. noopResourceManager.AllocateResource always
returns AllocationStatusGranted (resource_manager.go:32-34), matching FlytePropeller with no
quota backend. noopSecretManager.Get fails loudly with an error rather than handing a task a blank
value (secret_manager.go:19-21). Both files carry comments noting that the v2 executor never
reimplemented what FlytePropeller v1 provided (resource_manager.go:9-14,
secret_manager.go:10-14).
On the Go side, monitor (flyteplugins/go/tasks/pluginmachinery/internal/webapi/monitor.go:14-45)
drives an autorefresh cache keyed per task-execution GeneratedName and re-invokes the plugin's
Get/GetTask per task on the cache resync interval. Terminal items are queued for delayed
deletion (monitor.go:63-70). On the Python side a deployed backend polls get roughly every 3
seconds per task (connector.py:9-10). The net effect is one GetJobStatus gRPC per in-flight task
per resync tick, so polling is O(N). GetJobStatus is bulk-capable and the
connector keeps no per-job state in the object, so a future in-memory status cache keyed by job_id
(filled by one bulk poll or the job-set event stream) could collapse that to O(1) without changing
the Flyte contract.
The connector runs as a gRPC service (c0), pointed at Armada via ARMADA_URL and at the blob
store via FLYTE_BLOB_ENDPOINT / FLYTE_BLOB_ACCESS_KEY / FLYTE_BLOB_SECRET_KEY. Runs appear in
the Flyte UI. See docs/getting-started.md and deploy/README.md for running it.
The task image is a generic runtime: python:3.11-slim plus flyte and armada_flyte. The user's
own code is fast-registered and pulled by a0 at runtime, so one image serves any example. The image
must be available to the Armada cluster, loaded with kind load or pushed to a registry it can pull from.