Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 59 additions & 59 deletions Fournos_Design_Document.md

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ dev-run:
FOURNOS_GC_INTERVAL_SEC=5 \
FOURNOS_NAMESPACE=$(or $(FOURNOS_NAMESPACE),fournos-local-dev) \
FOURNOS_SECRETS_NAMESPACE=$(or $(FOURNOS_SECRETS_NAMESPACE),psap-secrets) \
FOURNOS_RESOLVE_IMAGE=fournos-mock-resolve:dev \
FOURNOS_RESOLVE_JOB_TEMPLATE=dev/mock-resolve/resolve_job.yaml \
$(VENV_BIN)python -m fournos

Expand All @@ -94,7 +93,6 @@ ci-run:
FOURNOS_GC_INTERVAL_SEC=5 \
FOURNOS_NAMESPACE=$(or $(FOURNOS_NAMESPACE),psap-automation-ci-test) \
FOURNOS_SECRETS_NAMESPACE=$(or $(FOURNOS_SECRETS_NAMESPACE),psap-secrets) \
FOURNOS_RESOLVE_IMAGE=fournos-mock-resolve:dev \
FOURNOS_RESOLVE_JOB_TEMPLATE=dev/mock-resolve/resolve_job.yaml \
$(VENV_BIN)python -m fournos \
--liveness=http://0.0.0.0:8080/healthz > fournos.log 2>&1 & \
Expand Down
65 changes: 32 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

Fournos is a Kubernetes operator that schedules benchmark jobs via
[Kueue](https://kueue.sigs.k8s.io/) and executes them as
[Tekton](https://tekton.dev/) PipelineRuns on remote clusters through the
FORGE framework.
[Tekton](https://tekton.dev/) PipelineRuns on remote clusters through a
pluggable execution engine.

Jobs are submitted as `FournosJob` custom resources. Every job first
passes through a mandatory **Resolving** phase where a Forge Job populates
GPU requirements and secret references directly on the FournosJob spec. The
operator then creates a Kueue Workload for quota management, waits for
admission, and launches the corresponding Tekton PipelineRun.
passes through a mandatory **Resolving** phase where a resolve Job (driven
by the configured execution engine) populates GPU requirements and secret
references directly on the FournosJob spec. The operator then creates a
Kueue Workload for quota management, waits for admission, and launches the
corresponding Tekton PipelineRun.

## Cluster dependencies

Expand Down Expand Up @@ -50,12 +51,13 @@ spec:
gpuType: a100
gpuCount: 2
pipeline: forge-full
forge:
project: llmd
args:
- cks
configOverrides:
batch_size: 64
executionEngine:
forge:
project: llmd
args:
- cks
configOverrides:
batch_size: 64
env:
OCPCI_SUITE: regression
OCPCI_VARIANT: nightly
Expand All @@ -74,26 +76,24 @@ oc delete FournosJob -n $FOURNOS_NAMESPACE <name> # cleanup

| Field | Required | Description |
|---|---|---|
| `spec.forge.project` | yes | FORGE project path |
| `spec.forge.args` | yes | List of arguments passed to FORGE |
| `spec.forge.configOverrides` | no | Arbitrary YAML overrides passed to the test framework |
| `spec.env` | no | Environment variables available to FORGE (read from the FournosJob spec via K8s API) |
| `spec.executionEngine` | yes | Execution engine configuration. The single top-level key is the engine name (e.g. `forge`); its value is opaque engine-specific config passed through as-is. |
| `spec.env` | no | Environment variables available to the execution engine (read from the FournosJob spec via K8s API) |
| `spec.cluster` | \* | Pin to a specific cluster (Kueue ResourceFlavor). Since `exclusive` defaults to `true`, this also locks the cluster — set `exclusive: false` for shared access. |
| `spec.hardware.gpuType` | \* | Short GPU model name — e.g. `a100`, `h200`. The operator prepends the `FOURNOS_GPU_RESOURCE_PREFIX` (default `fournos/gpu-`) automatically, so do **not** include the full resource path. |
| `spec.hardware.gpuCount` | with gpuType | Number of GPUs (minimum 1) |
| `spec.owner` | no | Team or individual that owns this job |
| `spec.displayName` | no | Human-readable job name (defaults to `metadata.name`) |
| `spec.pipeline` | no | Tekton Pipeline name (default: `fournos-full`) |
| `spec.pipeline` | no | Tekton Pipeline name (default: `fournos-full`). The Pipeline must carry a `fournos.dev/resolve-image` annotation with the full image reference for the resolve Job. |
| `spec.priority` | no | Kueue WorkloadPriorityClass name |
| `spec.secretRefs` | no | Vault-synced K8s Secret names (prefixed with `vault-`) to mount into the pipeline. Populated by Forge during the Resolving phase. The operator validates each name in `FOURNOS_SECRETS_NAMESPACE`, copies the secrets into the operator namespace, and mounts them as a projected volume at `/var/run/secrets/fournos/<entry-name>/`. |
| `spec.secretRefs` | no | Vault-synced K8s Secret names (prefixed with `vault-`) to mount into the pipeline. Populated by the execution engine during the Resolving phase. The operator validates each name in `FOURNOS_SECRETS_NAMESPACE`, copies the secrets into the operator namespace, and mounts them as a projected volume at `/var/run/secrets/fournos/<entry-name>/`. |
| `spec.exclusive` | no (default `true`) | If `true`, locks the target cluster so no other FournosJob can run there. Requires `spec.cluster`. Hardware is optional — when omitted the Workload only requests cluster-slot resources for locking. |
| `spec.shutdown` | no | Shutdown action: `Stop` cancels gracefully (Tekton `CancelledRunFinally` — runs `finally` tasks); `Terminate` cancels immediately (Tekton `Cancelled` — skips `finally` tasks). Both wait for the PipelineRun to finish before releasing Kueue quota. |

\* `spec.hardware` is required unless the job uses exclusive cluster locking
(`exclusive: true` + `cluster`), in which case it may be omitted — the
Workload only needs cluster-slot resources. Every job passes through the
Resolving phase where Forge populates `spec.hardware` (if not already set)
and `spec.secretRefs` directly on the FournosJob. Since `exclusive` defaults
Resolving phase where the execution engine populates `spec.hardware` (if
not already set) and `spec.secretRefs` directly on the FournosJob. Since `exclusive` defaults
to `true`, any job with `spec.cluster` locks the cluster exclusively —
including jobs that also specify `spec.hardware`. Set `exclusive: false` for
shared access (hardware is then required). Jobs without `spec.cluster` must
Expand Down Expand Up @@ -136,7 +136,7 @@ make dev-teardown # deletes the kind cluster

`dev-setup` installs real Tekton Pipelines and Kueue controllers into the kind
cluster, but substitutes lightweight mock Tasks (echo + sleep) in place of the
real FORGE runner. The dev environment uses its own Kueue config
real execution engine runner. The dev environment uses its own Kueue config
(`dev/mock-kueue-config.yaml`) with four mock clusters and synthetic GPU quotas,
plus matching kubeconfig Secrets (`kubeconfig-cluster-{1..4}`) in the dedicated
secrets namespace (`psap-secrets`).
Expand All @@ -150,7 +150,7 @@ make test # integration tests (operator must be running)

## Deployment

**FORGE on the hub:** [`config/forge/`](config/forge/) is the real OpenShift configuration for this repoImageStreams, Builds, Tekton Tasks and Pipelines, and sample jobs you apply to a cluster. It is **not** the same as the lightweight stand-ins under [`dev/mock-pipelines/`](dev/mock-pipelines/), which [`make dev-setup`](#local-development) installs on kind for local testing only.
**Execution engine on the hub:** [`config/forge/`](config/forge/) is the real OpenShift configuration for this repoImageStreams, Builds, Tekton Tasks and Pipelines, and sample jobs you apply to a cluster. It is **not** the same as the lightweight stand-ins under [`dev/mock-pipelines/`](dev/mock-pipelines/), which [`make dev-setup`](#local-development) installs on kind for local testing only.

Prepare the namespaces
```bash
Expand Down Expand Up @@ -213,14 +213,14 @@ oc get fournosjobs -n $FOURNOS_NAMESPACE -w # should reach Succeeded
```

This runs the `fournos-validate-only` pipeline, which only checks `oc
cluster-info` against the target — no FORGE workload is launched. If the job
cluster-info` against the target — no benchmark workload is launched. If the job
reaches `Succeeded`, the kubeconfig secret and Kueue quota are correctly
configured. If it fails, check the operator logs and the PipelineRun status for
details.

### Deploying the FORGE workflow configuration
### Deploying the execution engine workflow configuration

Apply the production FORGE assets from `config/forge/` (not the kind mocks in `dev/mock-pipelines/`). Deploy the cluster configuration (Builds + Tekton):
Apply the production execution engine assets from `config/forge/` (not the kind mocks in `dev/mock-pipelines/`). Deploy the cluster configuration (Builds + Tekton):

```bash
oc apply -n $FOURNOS_NAMESPACE -f config/forge/images/is_forge.yaml
Expand Down Expand Up @@ -272,7 +272,7 @@ make sync-vault-secrets-dry-run # preview only

The synced secrets are labelled `fournos.dev/vault-entry=true` and
`app.kubernetes.io/managed-by=fournos-vault-sync` for easy identification.
Secret references are populated by Forge during the Resolving phase directly
Secret references are populated by the execution engine during the Resolving phase directly
on the FournosJob `spec.secretRefs` field. The operator validates each
referenced Secret exists in the secrets namespace and carries the vault
label during the Resolving phase, then copies them into the operator
Expand Down Expand Up @@ -308,24 +308,23 @@ All settings are read from environment variables with the `FOURNOS_` prefix:
| `FOURNOS_GPU_RESOURCE_PREFIX` | `fournos/gpu-` | Resource name prefix for GPU types |
| `FOURNOS_LOG_LEVEL` | `INFO` | Logging level |
| `FOURNOS_GC_INTERVAL_SEC` | `300` | Resource GC interval (seconds) |
| `FOURNOS_RESOLVE_IMAGE` | `image-registry.openshift-image-registry.svc:5000/{namespace}/forge-core:main` | Container image for the resolve Job (`{namespace}` is substituted at runtime) |
| `FOURNOS_RESOLVE_DEADLINE_SEC` | `300` | Deadline for the resolve Job (seconds) |
| `FOURNOS_RESOLVE_JOB_TEMPLATE` | `config/forge/resolve_job.yaml` | Path (relative to project root) to the Job YAML template for the resolve step. Override with `dev/mock-resolve/resolve_job.yaml` for local dev/CI. |
| `FOURNOS_ARTIFACT_PVC_SIZE` | `1Gi` | Size of the per-PipelineRun PVC used for shared artifact storage across pipeline tasks |

## Architecture

```
FournosJob CR ──→ Operator ──→ Forge Resolve Job (patches FournosJob spec) ──→ Kueue Workload ──→ (admission) ──→ Tekton PipelineRun ──→ FORGE ──→ target cluster
FournosJob CR ──→ Operator ──→ Resolve Job (e.g. FORGE, patches FournosJob spec) ──→ Kueue Workload ──→ (admission) ──→ Tekton PipelineRun ──→ Execution Engine (e.g. FORGE) ──→ target cluster
```

The operator runs as a single-replica Deployment using
[kopf](https://kopf.dev/). On each `FournosJob`, it:

1. **Resolves** job requirements by launching a Forge K8s Job that populates the FournosJob spec with GPU type/count and secret references
1. **Resolves** job requirements by launching a resolve K8s Job (using the configured execution engine image) that populates the FournosJob spec with GPU type/count and secret references
2. **Creates** a Kueue Workload with the resolved GPU resources (owned by the FournosJob via `ownerReferences`)
3. **Polls** (5 s timer) for Kueue admission and assigned cluster
4. **Copies** referenced Vault secrets from the secrets namespace into the operator namespace (per-job copies with `ownerReferences` for automatic cleanup) and **launches** a Tekton PipelineRun with `FJOB_NAME` + `FOURNOS_NAMESPACE` (so FORGE can look up the full FournosJob spec), the secrets mounted as a projected volume at `/var/run/secrets/fournos/` (owned by the FournosJob via `ownerReferences`), and a shared `artifacts` workspace backed by a `volumeClaimTemplate` PVC for cross-task artifact storage (managed by Tekton)
4. **Copies** referenced Vault secrets from the secrets namespace into the operator namespace (per-job copies with `ownerReferences` for automatic cleanup) and **launches** a Tekton PipelineRun with `FJOB_NAME` + `FOURNOS_NAMESPACE` (so the execution engine can look up the full FournosJob spec), the secrets mounted as a projected volume at `/var/run/secrets/fournos/` (owned by the FournosJob via `ownerReferences`), and a shared `artifacts` workspace backed by a `volumeClaimTemplate` PVC for cross-task artifact storage (managed by Tekton)
5. **Watches** the PipelineRun until completion
6. **Deletes** the Workload to release Kueue quota

Expand All @@ -340,9 +339,9 @@ deleted and the job moves to `phase=Stopped`.
Deleting a FournosJob automatically cascade-deletes its Workload and
PipelineRun through Kubernetes owner references.

Target clusters need nothing installed — FORGE runs on the hub cluster inside
Tekton Task pods and communicates with targets via `oc`/`kubectl` through
kubeconfig Secrets.
Target clusters need nothing installed — the execution engine runs on the hub
cluster inside Tekton Task pods and communicates with targets via
`oc`/`kubectl` through kubeconfig Secrets.

For a detailed breakdown of the CRD, scheduling, operator internals, and key
design decisions, see the [Design Document](Fournos_Design_Document.md).
4 changes: 2 additions & 2 deletions config/forge/resolve_job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ spec:
fi
git show --quiet

FORGE_PROJECT=$(cat "$ARTIFACT_DIR/fournos_fjob.yaml" | yq -r .spec.forge.project)
FORGE_PROJECT=$(cat "$ARTIFACT_DIR/fournos_fjob.yaml" | yq -r .spec.executionEngine.forge.project)
if [[ -z "$FORGE_PROJECT" || "$FORGE_PROJECT" == null ]]; then
echo "ERROR: invalid .spec.forge.project='$FORGE_PROJECT' in $FOURNOS_NAMESPACE/$FJOB_NAME"
echo "ERROR: invalid .spec.executionEngine.forge.project='$FORGE_PROJECT' in $FOURNOS_NAMESPACE/$FJOB_NAME"
exit 1
fi

Expand Down
9 changes: 5 additions & 4 deletions config/forge/samples/job-full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ spec:
displayName: forge-full-sample
cluster: cluster-1
pipeline: forge-full
forge:
project: llm_d
args:
- cks
executionEngine:
forge:
project: llm_d
args:
- cks
env:
OCPCI_SUITE: regression
OCPCI_VARIANT: nightly
9 changes: 5 additions & 4 deletions config/forge/samples/job-test-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ spec:
displayName: forge-sample
cluster: cluster-1
pipeline: forge-test-only
forge:
project: llm_d
args:
- cks
executionEngine:
forge:
project: llm_d
args:
- cks
env:
OCPCI_SUITE: regression
OCPCI_VARIANT: nightly
2 changes: 2 additions & 0 deletions config/forge/workflows/pipeline-full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: forge-full
annotations:
fournos.dev/resolve-image: image-registry.openshift-image-registry.svc:5000/$NAMESPACE/forge-core:main
spec:
workspaces:
- name: artifacts
Expand Down
2 changes: 2 additions & 0 deletions config/forge/workflows/pipeline-test-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: forge-test-only
annotations:
fournos.dev/resolve-image: image-registry.openshift-image-registry.svc:5000/$NAMESPACE/forge-core:main
spec:
workspaces:
- name: artifacts
Expand Down
4 changes: 2 additions & 2 deletions config/forge/workflows/tasks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ spec:
fi
git show --quiet

FORGE_PROJECT=$(cat "$ARTIFACT_DIR/fournos_fjob.yaml" | yq -r .spec.forge.project)
FORGE_PROJECT=$(cat "$ARTIFACT_DIR/fournos_fjob.yaml" | yq -r .spec.executionEngine.forge.project)
if [[ -z "$FORGE_PROJECT" || "$FORGE_PROJECT" == null ]]; then
echo "ERROR: invalid .spec.forge.project='$FORGE_PROJECT' in $FOURNOS_NAMESPACE/$FJOB_NAME"
echo "ERROR: invalid .spec.executionEngine.forge.project='$FORGE_PROJECT' in $FOURNOS_NAMESPACE/$FJOB_NAME"
exit 1
fi

Expand Down
7 changes: 4 additions & 3 deletions config/fournos-validation/samples/test-connectivity-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ spec:
hardware:
gpuType: a100
gpuCount: 1
forge:
project: none
args: []
executionEngine:
forge:
project: none
args: []
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: fournos-validate-only
annotations:
fournos.dev/resolve-image: image-registry.openshift-image-registry.svc:5000/$NAMESPACE/forge-core:main
spec:
params:
- name: fjob-name
Expand Down
9 changes: 5 additions & 4 deletions dev/job-secret-demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ spec:
displayName: mock-secret-demo
cluster: cluster-1
pipeline: fournos-run-only
forge:
project: secret-demo
args:
- demo
executionEngine:
forge:
project: secret-demo
args:
- demo
2 changes: 2 additions & 0 deletions dev/mock-pipelines/pipeline-full.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: fournos-full
annotations:
fournos.dev/resolve-image: fournos-mock-resolve:dev
spec:
workspaces:
- name: artifacts
Expand Down
2 changes: 2 additions & 0 deletions dev/mock-pipelines/pipeline-run-only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: fournos-run-only
annotations:
fournos.dev/resolve-image: fournos-mock-resolve:dev
spec:
workspaces:
- name: artifacts
Expand Down
2 changes: 2 additions & 0 deletions dev/mock-pipelines/tasks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: fournos-run-only
annotations:
fournos.dev/resolve-image: fournos-mock-resolve:dev
spec:
workspaces:
- name: artifacts
Expand Down
23 changes: 12 additions & 11 deletions dev/sample-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ spec:
hardware:
gpuType: a100
gpuCount: 2
forge:
project: llm-d
args:
- cks
- something-else
configOverrides:
vllm:
version: 0.15.1
logging:
enabled: true
level: debug
executionEngine:
forge:
project: llm-d
args:
- cks
- something-else
configOverrides:
vllm:
version: 0.15.1
logging:
enabled: true
level: debug
env:
OCPCI_SUITE: regression
OCPCI_VARIANT: nightly
5 changes: 2 additions & 3 deletions fournos/core/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def create_job(
*,
name: str,
owner_ref: dict,
image: str,
) -> dict:
job_name = _resolve_job_name(name)
labels = {LABEL_MANAGED_BY: "fournos", LABEL_JOB_NAME: name}
Expand All @@ -64,9 +65,7 @@ def create_job(
body["spec"]["template"]["metadata"] = {"labels": labels}

container = body["spec"]["template"]["spec"]["containers"][0]
container["image"] = settings.resolve_image.format(
namespace=settings.namespace,
)
container["image"] = image

env_values = {
"FJOB_NAME": name,
Expand Down
12 changes: 12 additions & 0 deletions fournos/core/tekton.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@

TEKTON_GROUP = "tekton.dev"
TEKTON_VERSION = "v1"
TEKTON_PIPELINE_PLURAL = "pipelines"
TEKTON_PIPELINE_RUN_PLURAL = "pipelineruns"

ANNOTATION_RESOLVE_IMAGE = "fournos.dev/resolve-image"


def _build_secrets_volume(resolved: list[ResolvedSecret]) -> dict:
"""Build a single projected volume combining all per-job secret copies.
Expand Down Expand Up @@ -118,6 +121,15 @@ def create_pipeline_run(
logger.info("Created PipelineRun %s", name)
return result

def get_pipeline(self, name: str) -> dict:
return self._k8s.get_namespaced_custom_object(
group=TEKTON_GROUP,
version=TEKTON_VERSION,
namespace=settings.namespace,
plural=TEKTON_PIPELINE_PLURAL,
name=name,
)

def get_pipeline_run(self, name: str) -> dict:
return self._k8s.get_namespaced_custom_object(
group=TEKTON_GROUP,
Expand Down
Loading
Loading