Workflow Data Sharing
This document describes the v0.x design. Its API prerequisites, RuntimePodLocal binding, scheduler admission, runtimed workspace preparation, job-local Workflow workspaces, and job-to-job artifact transfer are implemented.
RuntimePodLocal binding, scheduler fencing, and runtimed preparation: Implemented
The goal is to define how Workflow jobs and Runs share data without making scheduler or runtimed understand Workflow-specific semantics. The design is driven by the v0.x workflow demo target: job-to-job data should move through artifacts, while Runs inside one job should be able to share a job-local workspace when the Workflow controller asks for co-location.
Current State
The current experimental Workflow API supports:
- jobs with
needs; - sequential steps inside a job;
- child Runs per step;
- bounded step outputs from
KRUNTIME_OUTPUTS; - cross-step and cross-job expression references for small string outputs.
Runtime.spec.workspacewith an inline KubernetesVolumeSourceand anemptyDirdefault;PersistentWorkspaceAPI types, CRD validation, status, and controller binding lifecycle with UID fencing;- generic Run workspace references and Kubernetes-style Run affinity fields.
- workspace-aware scheduler filtering and Pending Run wakeups on workspace changes;
- controller-managed workspace lifecycle and cleanup, including explicit deletion and unused-TTL E2E coverage;
- identity-based workspace authorization through admission-time
usereviews and a fenced controller ServiceAccount path for verified Workflow child Runs.
Goals
- Jobs exchange durable data through ArtifactStore-backed artifacts.
- Runs inside one Workflow job can share a job-local
PersistentWorkspace. - Workflow controller owns job/workflow semantics.
- Scheduler and runtimed stay workflow-agnostic. They expose generic placement and workspace primitives that other features can also use.
- The API keeps cross-job data durable, auditable, and independent of Runtime Pod placement.
- The design makes cleanup, failure recovery, and permission boundaries explicit before implementation.
Non-Goals
- This is not a full replacement for Argo Workflows or Tekton.
- This does not add a general distributed filesystem.
- This does not make Runtime Pods safe for arbitrary hostile code.
- This does not require scheduler or runtimed to know about Workflows, jobs, or steps.
- This does not make job-local workspaces cross-node or cross-Pod by default.
Data Sharing Model
There are two data-sharing paths:
| Boundary | Mechanism | Reason |
|---|---|---|
| Job to job | ArtifactStore-backed artifacts | Durable, auditable, works across Runtime Pods and nodes. |
| Run to Run inside one job | PersistentWorkspace plus Run affinity | Fast local sharing for sequential steps in the same job. |
Small scalar values continue to use bounded outputs:
step -> KRUNTIME_OUTPUTS -> Run.status.outputs -> Workflow status
Larger files should not be embedded in Workflow or Run status. They should move through artifact references or a referenced workspace.
Artifact Input Contract
Artifact transfer has two layers so that the Workflow controller never copies data and runtimed never needs Workflow knowledge:
- A generic Run artifact input contains an immutable
ArtifactRefand a relative destination path. Before execution, runtimed opens the reference through its configuredArtifactStoreand safely stages it below the Run working directory. File artifacts are copied to the destination path; directory artifacts are extracted into it without permitting symlinks or path traversal. - A Workflow step expresses the source as
jobs.<job-id>.artifacts.<artifact-name>. When its job becomes ready, the Workflow controller resolves that name from the producing job’s compact artifact status and materializes the generic Run input. Neither the Run API nor runtimed contains a Workflow, job, or step identifier.
The producing job must be a needs dependency of the consuming job. A missing
artifact after all dependencies have succeeded is a deterministic Workflow job
failure, rather than a request that waits forever.
An ArtifactRef identifies storage coordinates, not authorization. In v0.x,
jobs that exchange artifacts must use compatible Runtime.spec.artifactStore
configuration: the consuming runtimed must be able to open the producer’s
reference with its own credentials and store scope. This supports shared PVC
filesystem stores and shared S3 bucket/prefix access. A project-wide artifact
relay with independent credentials is a future feature; the Workflow controller
does not proxy artifact bytes.
Artifact names form one job-local namespace. Job status projects the most recent successful child Run reference for each name, so a later sequential step can intentionally replace an earlier artifact. Consumers read the final producer-job reference after that job succeeds.
PersistentWorkspace CRD
PersistentWorkspace represents a workspace boundary and lifecycle. It is not a
Workflow-specific object; Workflow is one consumer.
It does not select the underlying Kubernetes volume. A PersistentWorkspace is
bound to the workspace volume declared by the target Runtime.spec.workspace.
For the initial RuntimePodLocal mode, the workspace is implemented as a
subdirectory under that Runtime Pod’s mounted /workspace volume.
Target shape:
apiVersion: kruntimes.io/v1alpha1
kind: PersistentWorkspace
metadata:
name: ci-build-workspace
spec:
runtime: bash
mode: RuntimePodLocal
ttlSecondsAfterUnused: 3600
cleanupPolicy: DeleteAfterTTL
status:
phase: Bound
runtime: bash
boundPod: runtime-bash-7f587b4668-njcks
boundPodUID: 2c24c1f0-9f8f-4f80-82d5-3dd16a12d1e6
path: /workspace/persistent/ci-build-workspace
lastUsedTime: "2026-07-06T12:00:00Z"
The first supported mode should be RuntimePodLocal: the workspace lives on a
specific Runtime Pod and can be reused only by Runs scheduled to that Pod.
The durability and sharing characteristics come from the Runtime workspace
volume. If the Runtime workspace is an in-memory emptyDir, the
PersistentWorkspace is also Runtime-Pod-local and lost with the Pod. If the
Runtime workspace is backed by a PVC or another Kubernetes volume source in the
future, the workspace can inherit that backing store’s durability and attachment
rules.
Runtime Workspace Volume
Today Runtime.spec.workspace inlines Kubernetes VolumeSource fields, and the
controller creates the reserved workspace volume as emptyDir when no explicit
workspace volume source is set.
Target direction:
apiVersion: kruntimes.io/v1alpha1
kind: Runtime
metadata:
name: bash
spec:
workspace:
persistentVolumeClaim:
claimName: bash-workspace
The preferred API should inline Kubernetes corev1.VolumeSource fields under
spec.workspace instead of inventing a separate workspace volume model or
nesting another volumeSource object. emptyDir remains the default when no
explicit workspace volume source is set. EmptyDir options such as sizeLimit
should use the native workspace.emptyDir.sizeLimit shape instead of a
kruntimes-specific shorthand.
This Runtime workspace volume work is a prerequisite for durable or
PVC-backed PersistentWorkspace behavior. The first implementation can still
ship RuntimePodLocal against the existing emptyDir behavior, but the design
should not bake in emptyDir as the only backing store.
Proposed RuntimePodLocal Binding Lifecycle
The binding controller should use the following v0.x rules:
- An unbound workspace waits while its referenced Runtime has no ready Runtime Pods. It does not consume or reserve Run capacity while waiting or after it is bound.
- When candidates exist, the controller sorts ready Runtime Pods by
metadata.nameand selects one using a stable hash of the PersistentWorkspace UID. This spreads first bindings across ready Pods while keeping retries stable for the same candidate set; later scheduling work usesstatus.boundPodandstatus.boundPodUIDrather than trying to repeat this selection. - The controller records
status.phase: Bound,status.runtime,status.boundPod, immutablestatus.boundPodUID, andstatus.path: /workspace/persistent/<workspace-name>. It does not create the directory itself: runtimed creates it when a referenced Run starts. - A Bound workspace remains bound while its Pod exists, even if that Pod is temporarily not ready. The status conditions make the availability problem visible, and Runs referring to it stay Pending until later scheduler and runtimed work can use that binding safely.
- If the bound Pod is deleted, no longer exists, or a same-name Pod has a
different UID, the workspace becomes
Lost. The controller must not silently bind it to another Pod: forRuntimePodLocal, that would make a caller observe a new empty directory as though it contained the original data. Recovery requires an explicit new workspace or a future reviewed recovery API.
Binding is metadata-only in this slice. TTL cleanup, filesystem deletion,
lastUsedTime, and Run admission/preparation remain separate follow-up work.
Cleanup Protocol
PersistentWorkspace cleanup is a two-part protocol. The controller owns the
logical lifecycle; runtimed owns deletion of bytes in the Runtime Pod-local
mount. The controller must never use Pod exec or assume that a custom Runtime
implements a shell command merely to remove a workspace.
- The PersistentWorkspace controller indexes Runs by
spec.workspace.name. A workspace is active while any referencing Run is non-terminal. When the last active Run becomes terminal, the controller setsstatus.lastUsedTimeonce. A newly Bound workspace with no Runs starts its unused interval when it becomes Bound. cleanupPolicy: DeleteAfterTTLstarts cleanup only whenttlSecondsAfterUnusedis set and has elapsed. A nil TTL deliberately means no automatic deletion.Retainnever starts automatic cleanup.- On a cleanup request, the controller changes the workspace to
Released, then requests Kubernetes deletion while keeping a dedicated workspace finalizer. A Released workspace is terminal for scheduling: no new Run may claim it. - The runtimed instance on the recorded
status.boundPodwatches only workspace objects bound to its own Pod. After independently confirming that no local non-terminal Run references the workspace, it removes exactly/workspace/persistent/<workspace-name>and removes that finalizer. It does not write Workspace status. Runtimed does not need Workflow knowledge or any runtime-server extension for this operation. - The controller is the only status writer. If its bound Pod has disappeared,
the workspace is already
Lost; there is no remaining Pod-local data to remove, so the controller removes the finalizer without waiting for runtimed.
All bound workspaces use the same deletion protocol, regardless of cleanup
policy or deletion reason: the finalizer keeps the object until the bound
runtimed removes it after physical removal. Retain disables automatic TTL
deletion only; an explicit deletion still removes its Pod-local directory. If
a live bound Pod is unavailable, cleanup remains pending rather than risking a
directory on another Pod; deletion of that Pod transitions the workspace to
Lost and unblocks the finalizer.
Permission Boundary Design
Kubernetes RBAC on the PersistentWorkspace resource alone is insufficient:
it controls who can read or mutate the object, but not whether a principal that
can create a Run may consume a particular existing workspace. Scheduler and
runtimed cannot make that decision because they do not receive the original
Kubernetes request identity.
The implemented v1.0 model uses a Kubernetes-native use permission on the
persistentworkspaces/use subresource:
- A validating admission webhook handles direct Run creation with
spec.workspace. It requires that the referenced workspace already exists, checks that its Runtime matches the Run, then synchronously creates and waits for aSubjectAccessReviewfor the requesting principal. The review uses verbuse, resourcepersistentworkspaces, subresourceuse, and the workspace name as the resource name. The webhook must return an admission decision within a bounded timeout: it should give the review about two seconds, while the webhook configuration allows five seconds for the full request. A denied review, timeout, or API error rejects the Run rather than leaving an unauthorized reference Pending. The webhook configuration usesfailurePolicy: Fail, so an unreachable or timed-out webhook also fails closed. - Namespace administrators grant this permission through ordinary Role or
RoleBinding rules.
resourceNamescan restrict a principal to named workspaces without adding a kruntimes-specific ACL to the CRD. - The chart-configured controller ServiceAccount has a narrow internal path
for Workflow child Runs. The webhook permits that identity without a second
SubjectAccessReviewonly when it proves that the Run and referenced workspace have controller owner references to the same liveWorkflowRunUID, theirWorkflowRunUIDLabelvalues match that UID, and their workflow job labels match. A malformed, stale, or cross-job reference is denied. A user cannot obtain this path by copying labels or owner references because only the configured ServiceAccount identity enters it; every other caller follows the ordinaryusereview. - Scheduler and runtimed remain workflow-agnostic and authorization-agnostic. They continue to enforce only workspace existence, Runtime compatibility, binding, lifecycle, and Pod placement.
This changes direct Run behavior for a missing workspace reference: instead of being accepted and waiting for a future object, it is rejected at admission. Workflow-controlled child Runs are created only after their owned workspace exists, so they retain their normal asynchronous binding behavior.
The Helm chart deploys the webhook Service, a chart-managed TLS Secret, and a
ValidatingWebhookConfiguration with failurePolicy: Fail. It configures the
controller ServiceAccount identity explicitly on the webhook process. That
ServiceAccount may create SubjectAccessReview objects, but receives no
generic persistentworkspaces/use permission: workspace references are allowed
only through the verified Workflow child-Run path above.
Impersonation-focused integration and E2E coverage remain follow-up work.
Run Workspace Reference
Runs should be able to reference a workspace through a small typed object
reference. PersistentWorkspace is the default kind for this API, but the
reference shape leaves room for future workspace providers:
apiVersion: kruntimes.io/v1alpha1
kind: Run
metadata:
name: ci-build-package
spec:
runtime: bash
workspace:
name: ci-build-workspace
kind: PersistentWorkspace
apiGroup: kruntimes.io/v1alpha1
source:
inline: |
tar -czf "$KRUNTIME_ARTIFACTS_DIR/dist.tgz" src
kind and apiGroup are optional. When omitted, they default to
PersistentWorkspace and kruntimes.io/v1alpha1.
runtimed prepares the referenced workspace path before execution. The workspace
lifecycle is owned by the PersistentWorkspace controller. For task-mode Runs, runtimed stages
inline and Git sources under the workspace-reserved
.kruntimes/runs/<run-uid> directory, executes them with the workspace as the
current directory, and uses that same Run-local directory for outputs and
artifact staging. Runtimed does not delete any path in a referenced workspace;
the PersistentWorkspace controller applies its lifecycle and cleanup policy.
This keeps all runtimed-managed files from overwriting files that other Runs
intentionally share. Function-mode source remains at the workspace root so
handler module resolution stays relative to its working directory.
Run Affinity
Run affinity should use Kubernetes-style concepts because users already understand affinity and anti-affinity from Pods.
Target shape:
spec:
affinity:
runAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
workflows.kruntimes.io/workflow: ci-data-sharing-demo
workflows.kruntimes.io/job: build
topologyKey: kruntimes.io/runtime-pod
The exact type names may change during API design, but the concepts should stay close to Kubernetes:
- required vs preferred rules;
- label selectors;
- topology keys;
- affinity and anti-affinity.
For job-local workspace sharing, the Workflow controller can create the first Run in a job, bind or discover the workspace, and add required affinity to later Runs in the same job. The scheduler only evaluates generic Run placement rules.
Workflow API
Target workflow shape:
apiVersion: kruntimes.io/v1alpha1
kind: Workflow
metadata:
name: ci-data-sharing-demo
spec:
jobs:
build:
runs-on: bash
steps:
- name: checkout
run: |
mkdir -p src
echo 'print("hello")' > src/app.py
- name: test
run: |
test -f src/app.py
echo "tests=passed" >> "$KRUNTIME_OUTPUTS"
- name: package
run: |
mkdir -p "$KRUNTIME_ARTIFACTS_DIR"
tar -czf "$KRUNTIME_ARTIFACTS_DIR/dist.tgz" src
deploy:
runs-on: bash
needs:
- build
steps:
- name: verify-artifact
artifacts:
- from: jobs.build.artifacts.dist.tgz
path: dist.tgz
run: |
tar -tzf dist.tgz
echo "artifact verified"
Workflow spec does not expose workspace controls for this default job-local
sharing model. When a Workflow job runs multiple steps, the Workflow controller
creates and owns the job-local PersistentWorkspace, and its spec is controlled
by controller configuration. Users should not need to choose workspace names,
storage modes, TTLs, or cleanup policies in the common case.
This shape separates:
- job-local workspace sharing for
checkout,test, andpackage; - automatic artifact upload from
$KRUNTIME_ARTIFACTS_DIRat job scope; - explicit artifact transfer from
jobs.build.artifacts.dist.tgzintodeploy; - bounded scalar outputs for expressions.
Within a job, steps share the same KRUNTIME_ARTIFACTS_DIR namespace. Artifact
references therefore do not include the producing step name. A downstream job
imports an artifact with jobs.<job-id>.artifacts.<filename>.
Status Model
Workflow status should expose compact artifact references, not artifact contents:
status:
jobs:
build:
artifacts:
dist.tgz:
name: dist.tgz
driver: Filesystem
type: File
location:
filesystem:
path: runs/<run-uid>/artifacts/dist.tgz
steps:
- name: package
runName: ci-data-sharing-demo-build-package
outputs:
tests: passed
Workflow status should not expose workspace binding details. Those details live
on PersistentWorkspace objects for operators. Workflow should surface only
user-relevant conditions and messages, such as a job waiting for local
workspace capacity or failing because its controller-owned workspace was lost.
Component Boundaries
| Component | Responsibility |
|---|---|
| Workflow controller | Interprets job/step semantics, creates job-local workspaces from controller defaults, creates child Runs, wires artifact inputs, promotes outputs/artifact refs into Workflow status. |
| PersistentWorkspace controller | Owns workspace lifecycle, binding to Runtime workspace volumes, status, TTL, and cleanup. |
| Scheduler | Snapshots generic Run, Runtime Pod, and referenced workspace state; applies workspace fencing, Runtime capacity, and Run affinity/anti-affinity. It does not know about Workflows. |
| runtimed | Prepares referenced workspace paths, stages artifact inputs, and collects artifact outputs. It does not know about Workflows or delete referenced workspace paths. |
| ArtifactStore | Stores durable artifacts outside etcd. |
Failure and Recovery
- If a Runtime Pod disappears,
RuntimePodLocalworkspaces backed by that Pod’s workspace volume becomeLost; they are not automatically rebound to another Pod. - Runs that reference a missing, Pending, Lost, Runtime-incompatible, or UID-mismatched workspace Pod stay Pending with a clear message. Workspace changes requeue matching Pending Runs; none of these states is a scheduler terminal failure.
- The Workflow controller should surface workspace-related failures in Workflow conditions or messages without exposing workspace controls in Workflow spec.
- Workspace cleanup must not depend on the Runtime Pod still existing.
- Artifact transfer between jobs should remain valid after Runtime Pod loss because artifacts are stored outside the Pod.
Security and Isolation
PersistentWorkspace increases the blast radius within its boundary. The
initial model should treat shared workspace users as mutually trusted.
Required safeguards:
- namespace-scoped workspace references;
- owner references from auto-created workspaces to the Workflow or WorkflowRun;
- labels for workflow, job, and controller ownership;
- validation that rejects absolute paths and path traversal in artifact inputs;
- explicit cleanup policy and TTL;
- finalizer-based deletion, active-Run usage tracking, and runtimed-only physical cleanup for every bound workspace;
- documented warning that shared workspace is not hostile-code isolation.
Implementation Sequence
- Add this design document and review the API shape.
- Extend
Runtime.spec.workspaceto inline KubernetesVolumeSourcefields, while preserving the current emptyDir default behavior. - Add
PersistentWorkspaceAPI types, CRD validation, status, and controller skeleton. Binding to Runtime Pods, Run workspace references, and cleanup are separate follow-up implementation steps. - Add Run
workspacereference fields. - Add Kubernetes-style Run affinity/anti-affinity fields.
- Update scheduler placement to respect required/preferred Run affinity while keeping no-capacity Runs Pending.
- After reviewing the bound-Pod UID fencing amendment, add
status.boundPodUID, then bindRuntimePodLocalPersistentWorkspaces to ready Runtime Pods and record their lifecycle status without touching runtime filesystems. - Add a generic
Workspacescheduler Filter plugin. Implemented: the scheduling snapshot resolves a referenced workspace once; the filter rejects candidates whose Runtime does not match, and for a Bound RuntimePodLocal workspace admits only the fencedstatus.boundPodandstatus.boundPodUID. A Pending or Lost workspace has no eligible candidates, so its Run remains Pending with a clear scheduling message and is requeued when the workspace changes. - Update runtimed workspace preparation for referenced workspaces. Implemented: referenced Runs execute in the persistent workspace; outputs and artifact staging remain Run-local; task source is staged under the reserved per-Run directory. PersistentWorkspace lifecycle owns cleanup within the workspace.
- Compose job-local workspaces in the Workflow controller. Implemented: initialization creates one WorkflowRun-owned PersistentWorkspace for each inline job and every child Run for that job references it. Reusable Workflow-call jobs create no parent workspace; their materialized child WorkflowRun owns its own job workspaces.
- Add Workflow step artifact input fields and job-scoped artifact status.
Implemented: a step can reference
jobs.<job-id>.artifacts.<artifact-name>from a directneedsdependency; the controller resolves it into a genericRun.spec.artifactInputsentry. - Promote child Run artifact refs into Workflow status. Implemented: a
successful Job exposes the final successful ref for each artifact name in
status.jobs.<job-id>.artifacts. - Add E2E coverage for Runtime workspace volume sources, job-local workspace sharing, job-to-job artifact passing, Runtime Pod loss, cleanup, and permission boundaries. Implemented: E2E covers Runtime workspace sources, job-local sharing, Run artifact staging, job-to-job transfer, Runtime Pod loss, cleanup, and permission boundaries. Admission authorization and controller ownership also have focused unit and integration coverage.
- Implement the cleanup protocol: active-Run tracking, Released admission fencing, a workspace finalizer, runtimed local-path cleanup, and E2E coverage for TTL, explicit deletion, retained workspaces, and bound-Pod loss during cleanup.