Skip to content

Instantly share code, notes, and snippets.

@d3v-null
Last active August 27, 2026 14:55
Show Gist options
  • Select an option

  • Save d3v-null/953efe2cc776d0c65b0c3acbe394862e to your computer and use it in GitHub Desktop.

Select an option

Save d3v-null/953efe2cc776d0c65b0c3acbe394862e to your computer and use it in GitHub Desktop.
Switching the SRCNet SWF-21/SWF-22 demos over to Oracle OKE - data-locality placement, control plane, images, and the traps (2026-08-27)

Switching the SRCNet demos over to Oracle OKE

About this gist. Gists are flat, so the companion files below are prefixed (script-, manifest-, benchmon-) copies of the repository tree at docs/oke-demo/{scripts,manifests,benchmon}/ on the oke-demo-2026-08-27 branch of ska-src-api-deployment-stack. A path written as docs/oke-demo/scripts/remove-site1-ms.sh below is script-remove-site1-ms.sh here.

Replaces the previous contents of this gist. That version documented a demo that pinned execution with an explicit site="OKE_SITE1" selector; that selector no longer exists. Placement is now decided purely by data locality — the broker sends the job wherever the measurement set's Rucio replica lives. Switching to OKE therefore means moving the data, not changing a flag.

Verified end to end on 2026-08-27 against the merged MR set (htcondor !18–!21, broker !50/!53/!54, services-cd !331/!332, job-gateway !24).


The mental model

Rucio replica of the MS lives on OKE_SITE1_STORM3
        │
        ▼
rapthor_payload()/dynspec payload sets
  workflow_params.data_locations = [that RSE]
        │
        ▼
broker preselection rejects every site not advertising it
        │
        ▼
job dispatches to OKE_SITE1 -- no site flag anywhere

The corollary is the thing that catches people: as long as a replica also exists on site1, jobs will keep landing on site1. You force OKE by removing the site1 copy, not by asking for OKE.


1. Branches

Four repos carry OKE-specific deltas on oke-demo-2026-08-27:

Repo What it adds
ska-src-api-deployment-stack HTCONDOR_FORCE_TOKEN_MINT guard in include/env/dev/toolkit.mk, docs/oke-demo/ assets
ska-src-ef-computing-broker storm3 resolution, remote output fetch over https, distributed Toil-on-Kubernetes mode, the OKE run-ical.sh overlay
ska-src-skaosrc-services-cd htcondor OKE exposure: NodePorts, submit port/forwarding config, pilot-OKE_SITE1@condor identity
ska-src-ef-local-job-gateway nothing — !24's explicit storage-topology config supersedes the old conditional overlay

Point the deploy config at them:

# etc/env/dev/infra/ska-src-skaosrc-services-cd.yaml
# etc/env/dev/services/broker.yaml
branch: oke-demo-2026-08-27

2. Control plane: let OKE pilots reach the pool

HTCondor's CCB does not proxy connections, it reverses them, so the schedd must advertise an address an off-cluster pilot can actually dial.

stack redeploy htcondor    # creates the NodePorts + submit config from the overlay

That gives you htcondor-cm-external (9618:30618) and htcondor-submit-external (9622:30622), plus TCP_FORWARDING_HOST=10.0.0.90 and SHARED_PORT_PORT=9622 on the submit node.

Then forward those ports from the host the pilots dial:

NODEIP=$(docker inspect k3d-deploy-$USER-server-0 \
  --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
socat TCP-LISTEN:9618,fork,reuseaddr TCP:$NODEIP:30618 &
socat TCP-LISTEN:9622,fork,reuseaddr TCP:$NODEIP:30622 &

Three traps here, all previously hit:

  • PRIVATE_NETWORK_NAME must be blank. It defaults to $(FULL_HOSTNAME), which makes the schedd advertise an address only reachable in-cluster.
  • The collector must NOT get TCP_FORWARDING_HOST — that breaks in-cluster SHARED_PORT routing with SECMAN:2011.
  • Killing socat also breaks local pilots, because the schedd advertises 10.0.0.90:9622 to everyone. Revert the submit config before tearing the forwarders down.

3. The OKE gateway

kubectl -n job-gateway-oke set env deploy/job-gateway-oke \
    BATTLE_SHARES_PILOT_STORAGE=true
kubectl -n job-gateway-oke patch deploy job-gateway-oke --type=json \
    -p '[{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"IfNotPresent"}]'
kubectl -n job-gateway-oke scale deploy job-gateway-oke --replicas=1

BATTLE_SHARES_PILOT_STORAGE is mandatory since job-gateway!24: the gateway cannot discover what backs Battle's /srv/storage, so it refuses to start rather than guess and silently stage out an empty directory. true is correct on OKE — Battle's static PV and the gateway's dynamic PVC resolve to the same CephFS subvolume (csi-vol-c7e85225-…), reachable two different ways. Note that comparing the two PV manifests makes them look different: one expresses the location as rootPath, the other as subvolumeName, and only the embedded UUID reveals they match.

IfNotPresent is not optional on OKE. The gateway chart and the pilot provisioner both default to Always, and OKE has no egress to Docker Hub, so any pull attempt fails even when the image is sitting on the node.

4. Images must be in harbor-oke and current

Every image the payload uses must already exist in harbor-okeharbor.test is a dev-only domain OKE cannot resolve. Less obviously, the copies in harbor-oke drift: an older rapthor-mildtec:smoke there lacked DP3's dynspec step entirely (SWF-21 failed with Could not create step of type 'dynspec') and rejected the current sky-model header format.

docker tag  harbor.test/library/rapthor-mildtec:smoke \
            harbor-oke.137.23.15.233.nip.io/library/rapthor-mildtec:smoke-dynspec
docker push harbor-oke.137.23.15.233.nip.io/library/rapthor-mildtec:smoke-dynspec

Push under a new tag rather than overwriting :smoke — the stale copy is cached on the nodes and, with IfNotPresent, a same-tag push will not be picked up.

5. Data: replica, skymodel, and forcing locality

The MS must be materialized on storm3 and registered on OKE_SITE1_STORM3 (docs/oke-demo/scripts/build-storm3.sh, register-oke-rse.py).

The sky model is a separate trap: the code's expected filename changes when the sky model does, and OKE keeps its own copy. Confirm the name the payload asks for actually exists there:

kubectl -n job-gateway-oke exec dataprep -- \
  ls /srv/storage/storm3/sa/rapthor-mildtec/*.skymodel

Then remove the site1 replica so locality has only one answer:

docs/oke-demo/scripts/remove-site1-ms.sh     # run as root; files are owned by opc

It deletes the identity-LFN2PFN replica tree, the materialized copy, the provenance marker and the ingest staging copy. stack deploy broker restores all of it.

6. Running

export SWF21_DATASET_DID="SKA-Low.integration:eb-tg1-41-rapthor-mildtec-demo.SKAO-03FC553297"
export SWF21_IMAGE="docker://harbor-oke.137.23.15.233.nip.io/library/rapthor-mildtec:smoke-dynspec"
export BROKER_DEMO_ALLOW_FULL_SITE_CEILING=1
export BROKER_DEMO_SKIP_LIVE_CAPACITY=1
BROKER_DEMO_HEADLESS=1 python3 swf21_dynspec_broker_demo.py

BROKER_DEMO_SKIP_LIVE_CAPACITY=1 is the one that is easy to get wrong. The preflight probes each gateway's /capacity, but the OKE gateway lives in a different cluster and its service DNS does not resolve from dev — so there is no capacity row for OKE_SITE1, and the check treats "no row" as "does not fit":

JobUnschedulableError: Request exceeds every eligible site's budget ceiling
Required data locations: oke_site1_storm3
  - NODE1_SITE1 does NOT hold the required data
  - NODE2_SITE1 does NOT hold the required data
  - OKE_SITE1 holds the required data

Note the locality decision above is already correct there — only the capacity assertion fails. BROKER_DEMO_ALLOW_FULL_SITE_CEILING guards a different check and will not help.

Verified result: state=COMPLETE elapsed_s=190.5, run_site_id=OKE_SITE1, dynspec-field_000-dynspec.fits (5.9 MB) staged out to the OKE scratch tree.

7. Gotchas worth knowing before you start

  • OKE runs only already-cached images. No Docker Hub egress. If a pod gets rescheduled to a node without its image, it never recovers. This bit harbor itself: scaling harbor-database to 0 moved it to a node lacking goharbor/harbor-db:v2.11.0, taking the whole registry down. Both harbor-database and harbor-core are now pinned via nodeSelector to the node that has their images.
  • No internet egress also slows astropy. Every rapthor run spends minutes timing out on IERS earth-orientation downloads before falling back to the bundled IERS-B table.
  • There is a 907 GB measurement set on storm3 (eb-dsc-065-sp-6995.SKAO-3F8A21C4). The demo picker filters on eb-tg1-41-rapthor-mildtec-demo.* so it cannot be selected by accident, but pin the DID explicitly anyway.
  • The Ceph pool is size=1 and the OSDs sit on OCI block volumes raised to 50 VPU/GB. That took CephFS from 260 to 504 MB/s; the 26 GB MS stage-in now takes about 50 seconds.
  • Do not stack redeploy broker while OKE is the intended site: its post-deploy hook re-ingests the fixture and restores the site1 replica, putting a second DID in the dropdown and pulling jobs back to site1.

8. What actually broke (2026-08-27)

A full day of SWF-22 profiling on OKE surfaced seven distinct faults. Six are ours; 8.1 is upstream rapthor and silently affects every rapthor-on-Kubernetes deployment, not just this one.

8.1 rapthor forces serial execution on Kubernetes

rapthor/lib/operation.py:

if self.force_serial_jobs or self.batch_system in ("single_machine", "kubernetes"):
    self.max_nodes = 1
else:
    self.max_nodes = self.parset["cluster_specific"]["max_nodes"]

operation.max_nodes becomes Toil's --maxJobs / --maxLocalJobs, so with batch_system = kubernetes every CWL step runs one at a time and [cluster] max_nodes is discarded. The parset on disk reads max_nodes = 2; the live toil-cwl-runner argv reads --maxJobs 1.

Symptoms, each of which we chased down a wrong path first:

  • every "distributed" run put all workers on one node
  • 26+ issued jobs drained through a single worker pod
  • raising RAPTHOR_CPUS_PER_TASK to force a spread changed nothing
  • single_machine beat "distributed" by 1.73x — because distributed was single-node, just paying per-step pod overhead on top

Diagnose from the argv, not the parset. The parset lies:

kubectl -n job-gateway-oke exec <pilot> -c startd -- sh -c '
for p in /proc/[0-9]*; do
  c=$(tr "\0" " " < $p/cmdline 2>/dev/null)
  case "$c" in *toil-cwl-runner*) echo "$c" | tr " " "\n" | grep -A1 -E "^--maxJobs|^--maxCores";; esac
done'

The patch in run-ical.oke-distributed.sh drops "kubernetes" from the tuple. Keep the trailing comma: ("single_machine") without one is a string, and the in test degrades to a substring match.

Once patched: --maxJobs 2, two 20-core workers on two nodes, and calibrate_1 on SDP dials went 81m09s -> 42m38s (1.90x) on identical data, image and dials.

8.2 max_cores and max_threads are the same variable

The parset template wires three settings to one env var:

max_cores             = ${NUMTHREADS}
max_threads           = ${NUMTHREADS}
deconvolution_threads = ${NUMTHREADS}

NUMTHREADS defaults to RAPTHOR_CPUS_PER_TASK, so raising per-task cores to force a node spread also caps Toil's global core budget at one task's worth — guaranteeing serial execution even if --maxJobs were right. RAPTHOR_THREADS=40 with CPUS_PER_TASK=20 lifts the budget but oversubscribes threads 2x per pod. Clean fix: max_cores = max_nodes * cpus_per_task, max_threads = cpus_per_task.

8.3 ENOSPC while df still shows 150 GB free

predict_1 died at step 57/75 with:

RuntimeError: Operation predict_1 failed due to an error
OSError: [Errno 28] No space left on device

df inside the pod reported 150 GB free, which is why this is worth writing down: df is not the constraint. It reports the CephFS subvolume quota — an aggregate. Writes land on a specific OSD via a specific PG, and Ceph returns ENOSPC when any single OSD crosses full_ratio (0.95), while the aggregate still looks comfortable.

myfs-replicated  1.0 TiB stored  89.20 %USED  MAX AVAIL 128 GiB
                 size 1  min_size 1  flags hashpspool,nearfull
osd.0  100 GiB  85.88% USE   14 GiB avail   <- nearfull, the real ceiling
full_ratio 0.95  backfillfull_ratio 0.9  nearfull_ratio 0.85

Three things widen the gap between "free" and "writable" here:

  • size = 1, no replicas. Each PG lives on exactly one OSD. Nothing redistributes; if your write targets a PG on a hot OSD it fails regardless of space elsewhere.
  • Unequal OSD sizes in one pool — 100 GiB and 300 GiB devices. The small ones saturate first (82-86% vs 74%), and MAX AVAIL derives from the fullest, not the sum.
  • Unequal hosts. 10-20-15-75 contributes 200 GiB against 600 GiB from each of the other two, and carries the nearfull osd.0.

Real headroom at failure was ~14 GiB on osd.0, not 150 GB. The 933 GB storm3/sa/deterministic tree is the underlying cause — it is why the pool sits at 89% — but the mechanism is per-OSD full_ratio, not a full filesystem.

Check the pool, never df:

kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph df
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd df
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph health detail

benchmon is a co-conspirator on space: _temp_perf.data reaches 4.4 GB per node (8.8 GB for a two-node run) and is not reclaimed until the collectors finish.

Three consequences worth pre-empting:

  • HTCondor auto-retries the payload. The retry re-copies the 26 GB MS and fails identically, burning ~45 min and more space. Cancel through the broker rather than letting it cycle.
  • The retry wipes the previous attempt's output tree, so the failed run's rapthor.log and per-stage pipeline.log vanish before you can read them. Copy rapthor.log out the moment a run ends.
  • A near-full write corrupts benchmon traces. cpu_report.csv gains NUL bytes and sync_annotated.py dies with could not convert string to float. Repair with tr -d '\000' < cpu_report.csv.

8.4 The live-capacity preflight hard-blocks OKE_SITE1

common_lib.assert_request_schedulable keeps only capacity rows whose site_id is in the eligible set. The OKE gateway is in another cluster and its DNS does not resolve from dev, so OKE_SITE1 never returns a row — and an absent row is indistinguishable from "does not fit":

Request exceeds every eligible site's budget ceiling ...
Eligible sites: OKE_SITE1
| NODE1_SITE1 | ... | Fits request? yes |
| NODE2_SITE1 | ... | Fits request? yes |
         (no OKE_SITE1 row at all)

BROKER_DEMO_SKIP_LIVE_CAPACITY=1 is the escape, but it is only consulted when live_capacity is None. The marimo notebook fetches capacity in its own cell and passes it in, so setting the env var alone does nothing — that cell has to honour the flag too. The proper fix is to skip the ceiling test for eligible sites that returned no row, rather than disabling the check globally.

8.5 RAPTHOR_DISK default can never match a pilot

swf22_rapthor_lib.DEFAULT_DISK is 70G, but HTCondor matches RequestDisk against what the pilot advertises — the container overlay, ~12-15 GiB — not the PVC the MS lands on. A 70 GiB request matches nothing:

job RequestDisk : 73400320 KB (70 GiB)
OKE pilot slots : 15339332 KB / 12216744 KB
-> 0 slots match; "No machines matched the job's constraints"

The broker still shows RUNNING, because the leader job started — only the payload sits Idle forever. Set RAPTHOR_DISK=8G. DEFAULT_DISK is bound at import, so an env write at submit time is too late; override payload["workflow_engine_parameters"]["--disk"] after building the payload.

8.6 Phase timings come back empty on OKE

collect_phase_timings parses Operation X started|completed out of rapthor.log, which lives on pilot scratch — and the dev-side /srv/storage contains nothing but .ska-leader. The globs never match, so timing.json ships "steps": {} on every OKE run while the staging numbers (from SKA_TIMING markers in task-0.out) look perfectly healthy.

task-0.out does carry the Operation lines, but rapthor's console handler emits them without timestamps, and _OPERATION_RE is timestamp-anchored. Fix: have the payload replay the timestamped lines out of rapthor.log to stdout after rapthor exits (Condor ships stdout back), and parse task-*.out as well as rapthor.log. Preserve the exit code across the addition — the leader uses it to decide COMPLETE vs EXECUTOR_ERROR.

8.7 Stale scripts in the old ASTRON smoke image

The pre-2026-08-27 rapthor-mildtec:smoke carried /usr/local/bin/make_region_file.py from an older rapthor importing rapthor.lib.facet, while its installed package keeps those helpers in lsmtool.facet. image_1 died with ModuleNotFoundError: No module named 'rapthor.lib.facet'.

The Karabo-based rapthor-lean build (sha-073fa49) does not have this problem: /opt/view/bin/make_region_file.py imports lsmtool.facet and there is no stale /usr/local/bin copy. If you must run the older image, a rapthor/lib/facet.py shim re-exporting lsmtool.facet fixes it without a rebuild.

Measured results (SWF-22, 26 GB mildtec MS, OKE_SITE1 / storm3)

Per-stage, from rapthor.log "Operation ... started|completed" pairs:

config dials calibrate_1 predict_1 image_1
single_machine 16c demo 74.2 s 59.4 s 92.3 s
single_machine 8c demo 82 s 117 s 324 s
"distributed" 2x16c (pre-patch: 1 worker) demo 130.3 s 108.9 s 174.3 s
distributed, 1 worker sdp minus cellsize 81m09s ENOSPC -
distributed 2x20c, patched sdp minus cellsize 42m38s ENOSPC -

Before the max_nodes patch, "distributed" lost to single_machine by 1.73x. That comparison was never distributed-vs-single — it was pod-overhead-vs-none on a single node.

SDP dials multiply, and cellsize dominates: 15 -> 2 arcsec alone is 56x the work, and all five dials together are ~2465x the demo baseline (~246 h at 12 CPU, i.e. 10+ days). cellsize is the single dial that makes a full SDP run intractable on this hardware; the other four together are ~44x, which runs in a few hours.

#!/usr/bin/env python3
"""Turn a rapthor run into an ICAL-annotated benchmon multi-node sync plot.
benchmon's --annotate-with-log ical expects a `wflow-selfcal.*.log` beside each
node's traces, in a very specific whitespace-positional format (see
benchmon/visualization/utils.py:read_ical_log_file, which indexes
line.split(" ")[5] and [6]). rapthor writes something quite different, so this
translates one into the other and drops a copy in every traces directory.
Input : 2026-08-25 14:51:10,227 - INFO - rapthor:calibrate_1 - <ansi><-- Operation calibrate_1 started<ansi>
Output: 2026-08-25 14:51:10.227000 INFO - - Start calibrate_1 run_pipeline::calibrate_1
"""
import argparse
import glob
import os
import re
import sys
ANSI = re.compile(r"\x1b\[[0-9;]*m")
# rapthor tees stdout into rapthor.log, so the ANSI colouring survives; strip it
# before matching or the trailing "started" never anchors.
OP = re.compile(
r"^(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}),(?P<ms>\d{3})\s+-\s+INFO\s+-\s+"
r"rapthor:(?P<logger>\S+)\s+-\s+.*?Operation (?P<stage>\S+) started"
)
def stage_starts(rapthor_log: str) -> list[tuple[str, str, str]]:
out: list[tuple[str, str, str]] = []
seen: set[str] = set()
with open(rapthor_log, "r", encoding="utf-8", errors="replace") as fh:
for raw in fh:
m = OP.match(ANSI.sub("", raw).rstrip("\n"))
if not m:
continue
stage = m.group("stage")
if stage in seen:
continue
seen.add(stage)
out.append((m.group("date"), f"{m.group('time')}.{m.group('ms')}000", stage))
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--rapthor-log", required=True)
ap.add_argument("--save-dir", required=True)
args = ap.parse_args()
starts = stage_starts(args.rapthor_log)
if not starts:
print(f"ERROR: no 'Operation ... started' lines in {args.rapthor_log}", file=sys.stderr)
return 1
lines = [
f"{d} {t} INFO - - Start {s} run_pipeline::{s}\n" for d, t, s in starts
]
traces = sorted(glob.glob(os.path.join(args.save_dir, "benchmon_traces_*")))
if not traces:
print(f"ERROR: no benchmon_traces_* under {args.save_dir}", file=sys.stderr)
return 1
for tdir in traces:
dest = os.path.join(tdir, "wflow-selfcal.rapthor.log")
with open(dest, "w", encoding="utf-8") as fh:
fh.writelines(lines)
print(f"wrote {dest} ({len(lines)} stages)")
for d, t, s in starts:
print(f" {d} {t} {s}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
# Benchmon collectors for the multi-node rapthor run on the E6 pool.
#
# One privileged, hostPID collector per node, forced onto distinct nodes by
# pod anti-affinity. They start sampling immediately and poll for a STOP file,
# so the run is bracketed by: apply this -> submit the job -> touch STOP.
#
# Memory: perf with --call-graph dwarf OOMs below ~2Gi and produces empty
# trace data; the previous run settled on 8Gi requested / 24Gi limit.
apiVersion: v1
kind: ConfigMap
metadata:
name: benchmon-rapthor-e6
namespace: job-gateway-oke
data:
collector.sh: |
#!/bin/bash
set -euo pipefail
export PATH="/opt/view/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
SAVE_DIR="${SAVE_DIR:?}"
mkdir -p "$SAVE_DIR"
# benchmon keys its per-node trace dir off the hostname; the node IP with
# dots turned into dashes keeps the two collectors' dirs distinct and
# makes them trivially matchable back to `kubectl get pods -o wide`.
hn=$(echo "${NODE_NAME:-$(hostname)}" | tr '.' '-')
hostname "$hn" 2>/dev/null || true
benchmon-start --save-dir "$SAVE_DIR" --sys --sys-freq 10 \
--call --call-prof-freq 5 --call-keep-datafile --verbose
while [ ! -f "${SAVE_DIR}/STOP" ]; do sleep 5; done
benchmon-stop --save-dir "$SAVE_DIR" || true
touch "${SAVE_DIR}/done.$(hostname)"
---
apiVersion: batch/v1
kind: Job
metadata:
name: benchmon-rapthor-e6-collectors
namespace: job-gateway-oke
spec:
completions: 2
parallelism: 2
backoffLimit: 1
ttlSecondsAfterFinished: 7200
template:
metadata:
labels:
app: benchmon-rapthor-e6-collector
spec:
restartPolicy: Never
hostPID: true
nodeSelector:
name: pool3-e6
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: benchmon-rapthor-e6-collector
topologyKey: kubernetes.io/hostname
imagePullSecrets:
- name: harbor-oke-pull
securityContext:
runAsUser: 0
runAsGroup: 0
containers:
- name: collector
image: harbor-oke.137.23.15.233.nip.io/library/rapthor-jupyter:numpy2-pass
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "/scripts/collector.sh"]
env:
- name: SAVE_DIR
value: /srv/storage/benchmon-e6/rapthor-mn
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN", "PERFMON", "SYS_PTRACE"]
resources:
requests: {cpu: "500m", memory: 8Gi}
limits: {cpu: "2", memory: 24Gi}
volumeMounts:
- {name: storage, mountPath: /srv/storage}
- {name: scripts, mountPath: /scripts, readOnly: true}
volumes:
- name: storage
persistentVolumeClaim:
claimName: pilot-shared-storage
- name: scripts
configMap:
name: benchmon-rapthor-e6
defaultMode: 0755
#!/bin/bash
# Stop the benchmon collectors, annotate their traces with the rapthor ICAL
# stages, and render the multi-node sync plot.
#
# Run from the host; drives the in-cluster pods over kubectl.
set -euo pipefail
export PATH="/home/ubuntu/bin:$PATH"
NS=job-gateway-oke
RUN_ID="${RUN_ID:-oke-rapthor-mn-002}"
SAVE_DIR="${SAVE_DIR:-/srv/storage/benchmon-e6/rapthor-mn}"
RAPTHOR_LOG="/srv/storage/rapthor-runs/${RUN_ID}/rapthor_ska-low-aa2-mildtec-small/logs/rapthor.log"
echo "== signalling collectors to stop"
kubectl -n "$NS" exec dataprep -- touch "${SAVE_DIR}/STOP"
echo "== waiting for collectors to finish (perf stop can take minutes)"
# The collectors run benchmon-stop, which post-processes the perf capture; the
# Job only reaches Complete once both have flushed.
until [ "$(kubectl -n "$NS" get job benchmon-rapthor-e6-collectors -o jsonpath='{.status.succeeded}' 2>/dev/null)" = "2" ]; do
sleep 15
done
echo " collectors done at $(date +%H:%M:%S)"
echo "== writing ICAL stage annotations into each traces dir"
kubectl -n "$NS" exec benchmon-visu -- python3 /tmp/annotate.py \
--rapthor-log "$RAPTHOR_LOG" --save-dir "$SAVE_DIR"
echo "== rendering per-node + multi-node sync plots"
kubectl -n "$NS" exec benchmon-visu -- bash -lc "
cd '$SAVE_DIR' &&
benchmon-visu --cpu --mem --net --net-data --disk --disk-data \
--annotate-with-log ical --recursive '$SAVE_DIR' 2>&1 | tail -20
"
echo "== artifacts"
kubectl -n "$NS" exec dataprep -- find "$SAVE_DIR" -name "*.png" -printf '%p %s bytes\n'
# Distributed rapthor ICAL leader for the OKE multi-node benchmon run.
#
# Toil's kubernetes batch system dispatches each CWL step as its own worker pod;
# 5 tasks x 10 CPU = 50 vCPU deliberately exceeds one node's ~35.7 allocatable,
# which is what FORCES the two-node spread the sync plot needs (rather than
# hoping the scheduler spreads them).
#
# Uses /srv/storage/oke-scripts/run-ical.sh, NOT the copy inside the image: only
# the overlay understands TOIL_KUBERNETES_EXTRA_PVC_CLAIM /
# _REQUIRED_NODE_LABEL / _POD_TIMEOUT. The in-image script would fall back to a
# hostPath, which on a multi-node cluster gives every worker an empty node-local
# dir disconnected from the leader's data.
apiVersion: v1
kind: ConfigMap
metadata:
name: rapthor-mn-launcher
namespace: job-gateway-oke
data:
launch.sh: |
#!/bin/bash
set -euo pipefail
RUN="/srv/storage/rapthor-runs/${RUN_ID:?}"
SRC=/srv/storage/storm3/sa/rapthor-mildtec
mkdir -p "${RUN}/input"
# Toil needs a kubeconfig to create worker pods. Build it from this pod's
# own projected service-account token; tokenFile (not a literal token) so
# it keeps working after the kubelet rotates the token mid-run.
SA=/var/run/secrets/kubernetes.io/serviceaccount
cat > "${RUN}/kubeconfig" <<EOF
apiVersion: v1
kind: Config
clusters:
- name: oke
cluster:
server: https://kubernetes.default.svc
certificate-authority: ${SA}/ca.crt
users:
- name: toil
user:
tokenFile: ${SA}/token
contexts:
- name: oke
context:
cluster: oke
user: toil
namespace: job-gateway-oke
current-context: oke
EOF
export KUBECONFIG="${RUN}/kubeconfig"
# Rapthor mutates the measurement set in place, and the storm3 tree shares
# inodes with the registered Rucio replica (built with cp -al), so it must
# NOT be handed the SA copy directly -- take a real copy first.
if [ ! -f "${RUN}/input/ska-low-aa2-mildtec-small.ms/table.dat" ]; then
echo "STAGING: copying measurement set into ${RUN}/input"
cp -a "${SRC}/ska-low-aa2-mildtec-small.ms" "${RUN}/input/"
echo "STAGING: done ($(du -sh "${RUN}/input/ska-low-aa2-mildtec-small.ms" | cut -f1))"
fi
cp -f "${SRC}/lotss_p207+24_negdec.skymodel" "${RUN}/input/"
export DATA_DIR="${RUN}/input"
export OUTPUT_DIR="${RUN}"
export TOIL_WORKDIR="${RUN}/.toil-workdir"
export TMPDIR="${RUN}/.tmp"
mkdir -p "${TOIL_WORKDIR}" "${TMPDIR}"
sed 's|/mnt/scripts/run-ical.sh|/srv/storage/oke-scripts/run-ical.sh|' \
/mnt/scripts/wes-mildtec-small-smoke.sh > "${RUN}/wes.sh"
exec bash "${RUN}/wes.sh"
---
apiVersion: batch/v1
kind: Job
metadata:
name: rapthor-mn-leader
namespace: job-gateway-oke
spec:
backoffLimit: 0
completions: 1
parallelism: 1
template:
metadata:
labels:
app: rapthor-mn-leader
spec:
restartPolicy: Never
serviceAccountName: toil-worker
nodeSelector:
name: pool3-e6
imagePullSecrets:
- name: harbor-oke-pull
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
# The PVC holds ~26 GB; a blanket recursive chown on every pod start
# would add minutes to each run for no benefit.
fsGroupChangePolicy: OnRootMismatch
containers:
- name: leader
image: harbor-oke.137.23.15.233.nip.io/library/rapthor-mildtec:smoke
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "/launcher/launch.sh"]
env:
- name: RUN_ID
value: "oke-rapthor-mn-002"
- name: RAPTHOR_BATCH_SYSTEM
value: "kubernetes"
- name: RAPTHOR_MAX_NODES
value: "5"
- name: RAPTHOR_CPUS_PER_TASK
value: "10"
- name: NUMTHREADS
value: "10"
- name: MEM_PER_NODE_GB
value: "24"
# 0.05 (the notebook default) only splits the MS into 2 chunks, so
# there is almost nothing to parallelise and the work piles onto one
# node. 0.25 gives ~5 chunks -- matching the reference run that
# produced a well-populated two-node sync plot.
- name: SELFCAL_DATA_FRACTION
value: "0.25"
- name: MAXITER
value: "20"
- name: CELLSIZE_ARCSEC
value: "15.0"
- name: FAST_TIMESTEP_SEC
value: "40"
- name: PYTHONUNBUFFERED
value: "1"
- name: TOIL_APPLIANCE_SELF
value: "harbor-oke.137.23.15.233.nip.io/library/rapthor-mildtec:smoke"
# Both set together => the PVC is mounted AT the hostpath path in
# worker pods (see the overlay's kubernetes.py patch).
- name: TOIL_KUBERNETES_EXTRA_HOSTPATH
value: "/srv/storage"
- name: TOIL_KUBERNETES_EXTRA_PVC_CLAIM
value: "pilot-shared-storage"
- name: TOIL_KUBERNETES_REQUIRED_NODE_LABEL
value: "name=pool3-e6"
# Cold nodes pull a multi-GB appliance before the worker starts.
- name: TOIL_KUBERNETES_POD_TIMEOUT
value: "600"
resources:
requests: {cpu: "2", memory: 8Gi}
limits: {cpu: "4", memory: 24Gi}
volumeMounts:
- {name: storage, mountPath: /srv/storage}
- {name: launcher, mountPath: /launcher, readOnly: true}
volumes:
- name: storage
persistentVolumeClaim:
claimName: pilot-shared-storage
- name: launcher
configMap:
name: rapthor-mn-launcher
defaultMode: 0755
#!/usr/bin/env python3
"""Render the multi-node sync plot WITH ICAL stage markers.
benchmon annotates per-node figures (visualizer.py calls plot_ical_stages for
each subplot) but never does so for the multi-node sync figure -- run_sync_plots
just lays out the subplots and saves. This drives benchmon's own CLI with the
sync plotters wrapped so each subplot gets the same stage overlay.
It also re-clamps each y-axis afterwards: plot_ical_stages draws its dashed
markers over ``linspace(-0.1*ymax, 1.1*ymax)`` and puts the stage label above
that, which drags the autoscaled axis ~20% taller every time and visibly
squashes the traces (worse on the sync figure, where the memory panel is
stacked and already tall).
"""
import importlib.util
import os
import sys
from importlib.machinery import SourceFileLoader
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from benchmon.visualization.multi_node_visualizer import BenchmonMNSyncVisualizer # noqa: E402
from benchmon.visualization.utils import plot_ical_stages, read_ical_log_file # noqa: E402
CLI = "/opt/view/bin/benchmon-visu"
SYNC_PLOTTERS = ("plot_sync_cpu", "plot_sync_mem", "plot_sync_net", "plot_sync_disk")
def _first_traces_dir(save_dir: str) -> str:
for name in sorted(os.listdir(save_dir)):
full = os.path.join(save_dir, name)
if os.path.isdir(full) and "benchmon_traces" in name:
return full
raise SystemExit(f"no benchmon_traces_* under {save_dir}")
def main() -> int:
save_dir = sys.argv[1]
stages = read_ical_log_file(_first_traces_dir(save_dir))
print(f"ICAL stages: {sorted(stages)}")
def wrap(method_name):
original = getattr(BenchmonMNSyncVisualizer, method_name)
def wrapper(self, *args, **kwargs):
result = original(self, *args, **kwargs)
ax = plt.gca()
bottom, top = ax.get_ylim()
plot_ical_stages(stages, ymax=top)
# Undo plot_ical_stages' 1.1x overshoot.
ax.set_ylim(bottom, top)
return result
setattr(BenchmonMNSyncVisualizer, method_name, wrapper)
for name in SYNC_PLOTTERS:
wrap(name)
# benchmon-visu has no .py extension, so spec_from_file_location can't infer
# a loader and returns None; name one explicitly.
loader = SourceFileLoader("benchmon_visu_cli", CLI)
spec = importlib.util.spec_from_loader("benchmon_visu_cli", loader)
cli = importlib.util.module_from_spec(spec)
loader.exec_module(cli)
sys.argv = [
"benchmon-visu",
"--cpu", "--mem", "--net", "--net-data", "--disk", "--disk-data",
"--annotate-with-log", "ical",
"--recursive",
"--fig-fmt", "png",
save_dir,
]
return cli.main()
if __name__ == "__main__":
raise SystemExit(main())
# Long-lived pod for generating the annotated benchmon plots.
#
# Kept as a pod (not a Job) so the visu invocation can be iterated on without
# re-scheduling: benchmon-visu's arguments are fiddly and the y-axis clamp
# usually needs one look at the output before it's right.
apiVersion: v1
kind: Pod
metadata:
name: benchmon-visu
namespace: job-gateway-oke
labels:
app: oke-demo-benchmon-visu
spec:
restartPolicy: Never
nodeSelector:
name: pool1
imagePullSecrets:
- name: harbor-oke-pull
securityContext:
runAsUser: 0
runAsGroup: 0
containers:
- name: visu
image: harbor-oke.137.23.15.233.nip.io/library/rapthor-jupyter:numpy2-pass
imagePullPolicy: IfNotPresent
command: ["sleep", "infinity"]
resources:
requests: {cpu: "500m", memory: 2Gi}
limits: {cpu: "3", memory: 12Gi}
volumeMounts:
- {name: storage, mountPath: /srv/storage}
volumes:
- name: storage
persistentVolumeClaim:
claimName: pilot-shared-storage
PILOT_SITE = "OKE_SITE1"
# Resource (provisioner) this pilot runs on — the second matchmaking
# dimension beside PILOT_SITE. Jobs pick a pool via
# Requirements = (TARGET.PILOT_RESOURCE == "<kubernetes|slurm>").
# This ConfigMap is the config for K8S-spawned pilots, so the value is
# fixed "kubernetes"; slurm-spawned pilots set PILOT_RESOURCE=slurm in
# their own (sbatch-generated) condor config. Must stay in sync with the
# k8s provisioner's PilotSpec.pilot_resource, which the factory uses to
# select which idle tasks to serve.
PILOT_RESOURCE = "kubernetes"
STARTD_ATTRS = $(STARTD_ATTRS) PILOT_SITE PILOT_RESOURCE
# Idle pilots evaporate — the loser of a multi-site match must not linger.
STARTD_NOCLAIM_SHUTDOWN = 300
# Run the job payload as a dedicated account (uid 1000 = the single SRCNet
# user 'test1', matching the token the tests mint) that owns the StoRM RSE
# data and its output dir, instead of the condor default 'nobody'. The
# startd runs privileged as root and demotes the starter to SLOT1_USER, so
# the payload reads RSE data and writes its user output dir as the owner —
# no world-writable storage.
#
# One SINGLE STATIC slot (named slot1), NOT partitionable: condor only
# honours SLOT<N>_USER for statically-named slots — a partitionable slot's
# dynamic children (slot1_1, …) silently fall back to 'nobody' regardless
# of SLOT1_USER (verified). A pilot runs one payload task, so one full-size
# static slot suffices.
NUM_SLOTS = 1
NUM_SLOTS_TYPE_1 = 1
SLOT_TYPE_1 = cpus=100%,mem=100%,disk=100%
SLOT1_USER = test1
STARTER_ALLOW_RUNAS_OWNER = FALSE
# --- gateway.pilotExtraConfig ---------------------------------------
# Appended INTO this key rather than emitted as a separate 98-extra.conf.
# Both provisioners stage exactly one file by name — the k8s one
# subPath-mounts "99-pilot.conf" (kubernetes_provisioner.build_job_manifest)
# and the slurm one binds a single staged "99-pilot.conf"
# (slurm_provisioner) — so any additional ConfigMap key is silently never
# delivered to the pilot. A separate key looked correct and rendered fine,
# but the pilot never read it: off-cluster pilots came up with no
# CCB_ADDRESS, advertised their unroutable pod IP, matched jobs, and then
# every shadow failed to reach the startd.
# This pilot lives in a different cluster/network than CONDOR_HOST, so its
# pod IP is not routable from the schedd. Register with the collector's CCB
# relay and let the shadow reach the startd through it.
PRIVATE_NETWORK_NAME = oke-pilot
CCB_ADDRESS = $(CONDOR_HOST)
# The SRCNet PREPARE_JOB hook (start_payload.sh) is fail-closed on
# BATTLE_API_BASE: it does identity mapping and allocates the job's
# /srcnet/{input,output,work} dirs against the battle API. The original
# capacity PoC had no battle API here and blanked this keyword to run
# payloads directly -- but that also means NOTHING creates the /srcnet
# symlinks, so a payload's writes land on the pilot's container overlay
# instead of the shared PVC and a 26GB stage-in fills the node's root disk
# (observed: DiskPressure on 10.20.14.104).
#
# mock-battle-api-3 now serves this site and BATTLE_API_BASE is injected via
# the gateway's PILOT_EXTRA_ENV, so the hook is re-enabled. Keep this in sync:
# blanking the keyword again REQUIRES that no payload writes through /srcnet.
STARTER_JOB_HOOK_KEYWORD = pilot
apiVersion: v1
kind: Pod
metadata:
name: dataprep
namespace: job-gateway-oke
labels:
app: oke-demo-dataprep
spec:
restartPolicy: Never
nodeSelector:
name: pool1
containers:
- name: shell
image: debian:12-slim
command: ["sleep", "infinity"]
securityContext:
runAsUser: 0
resources:
requests: {cpu: "500m", memory: 1Gi}
limits: {cpu: "3", memory: 8Gi}
volumeMounts:
- name: storage
mountPath: /srv/storage
volumes:
- name: storage
persistentVolumeClaim:
claimName: pilot-shared-storage
# Headless stand-in for a JupyterHub singleuser pod, so the swf22 demo path can
# be driven end to end without a browser. Mirrors the mounts and env from
# apps/jupyterhub/overlays/dev/values.yaml.
apiVersion: v1
kind: Pod
metadata:
name: demo-runner
namespace: broker
labels:
app: oke-demo-runner
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: k3d-deploy-ubuntu-server-0
containers:
- name: runner
image: harbor.test/library/jupyterhub-singleuser-marimo:dev
imagePullPolicy: IfNotPresent
command: ["bash", "-c"]
args:
- |
cat /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ska-local-ca.crt > /tmp/combined-ca.crt
sleep infinity
securityContext:
runAsUser: 0
env:
- {name: PYTHONPATH, value: "/opt/src-test:/home/jovyan/work/demo/ska-src-ef-computing-broker"}
- {name: REQUESTS_CA_BUNDLE, value: /tmp/combined-ca.crt}
- {name: CURL_CA_BUNDLE, value: /tmp/combined-ca.crt}
- {name: SSL_CERT_FILE, value: /tmp/combined-ca.crt}
- {name: AAPI_URL, value: "https://aapi.test/api"}
- {name: IAM_URL, value: "https://iam.test"}
- {name: SCAPI_URL, value: "https://scapi.test/api/v1"}
- {name: BROKER_URL, value: "https://broker.test"}
- {name: BROKER_DEMO_INSECURE_IAM_TLS, value: "1"}
- {name: SCAPI_ASSETS_DIR, value: /opt/src-test/ska-src-site-capabilities-api/tests/assets/integration}
resources:
requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: "2", memory: 4Gi}
volumeMounts:
- {name: demo, mountPath: /home/jovyan/work/demo}
- {name: testsrc, mountPath: /opt/src-test}
- {name: storage, mountPath: /srv/storage}
- {name: ca-cert, mountPath: /etc/ssl/certs/ska-local-ca.crt, subPath: ca.crt}
volumes:
- name: demo
hostPath: {path: /srv/storage/jupyterhub/demo}
- name: testsrc
hostPath: {path: /srv/storage/jupyterhub/test/src}
- name: storage
hostPath: {path: /srv/storage}
- name: ca-cert
secret: {secretName: ska-local-ca-secret}
# Battle API for the OKE site (storm3).
#
# Modelled on the local mock-battle-api-2, with two differences that matter:
# * storage is the shared CephFS PVC, not a node hostPath -- on a multi-node
# cluster a hostPath would give each pod its own empty directory, which is
# exactly the trap the Toil EXTRA_PVC_CLAIM patch exists to avoid;
# * the registry describes storm3 and pilot-scratch-3, matching the Rucio RSE
# OKE_SITE1_STORM3 and the paths the demo's payload resolves.
apiVersion: v1
kind: Namespace
metadata:
name: mock-battle-api-3
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: battle3-shared-storage-pv
spec:
capacity:
storage: 300Gi
accessModes: [ReadWriteMany]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
claimRef:
namespace: mock-battle-api-3
name: battle3-shared-storage
csi:
driver: rook-ceph.cephfs.csi.ceph.com
volumeHandle: battle3-shared-storage-static
volumeAttributes:
clusterID: rook-ceph
fsName: myfs
staticVolume: "true"
rootPath: /volumes/csi/csi-vol-c7e85225-68c9-41db-98d6-62ee429ae233/95371c67-839a-4ff9-9fb0-8abeab60350d
# staticVolume mounts need userID/userKey, NOT rook's adminID/adminKey.
nodeStageSecretRef:
name: storm3-cephfs-node
namespace: rook-ceph
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: battle3-shared-storage
namespace: mock-battle-api-3
spec:
accessModes: [ReadWriteMany]
storageClassName: ""
volumeName: battle3-shared-storage-pv
resources:
requests:
storage: 300Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mock-battle-api-3
namespace: mock-battle-api-3
labels: {app: mock-battle-api-3}
spec:
replicas: 1
selector:
matchLabels: {app: mock-battle-api-3}
template:
metadata:
labels: {app: mock-battle-api-3}
spec:
nodeSelector:
name: pool1
imagePullSecrets:
- name: harbor-oke-pull
containers:
- name: mock-battle-api
image: harbor-oke.137.23.15.233.nip.io/library/mock-battle-api:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8765
env:
- name: LOG_LEVEL
value: INFO
- name: BATTLE_STORAGE
value: /srv/storage
- name: BATTLE_DATA_ROOTS
value: /srv/storage/storm3/sa
- name: BATTLE_SCRATCH_ROOT
value: /srv/storage/pilot-scratch-3
- name: BATTLE_SCRATCH
value: /srv/storage/pilot-scratch-3
- name: BATTLE_STORAGE_REGISTRY
value: |
[{"description":"Storm3 deterministic Rucio replica area","posix":"/srv/storage/storm3/sa/deterministic","rse":"STORM3","url":"davs://storm3.test:443/sa/deterministic"},
{"description":"Storm3 Rapthor mildtec demo MS/skymodel","posix":"/srv/storage/storm3/sa/rapthor-mildtec","rse":"STORM3","url":"dav://storm3.test/sa/rapthor-mildtec"},
{"description":"Site-local scratch job-output area (not an RSE)","posix":"/srv/storage/pilot-scratch-3/outputs","rse":"SCRATCH3","url":"scratch://storm3.test/outputs"}]
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: "1", memory: 1Gi}
volumeMounts:
- {name: shared-storage, mountPath: /srv/storage}
volumes:
- name: shared-storage
persistentVolumeClaim:
claimName: battle3-shared-storage
---
apiVersion: v1
kind: Service
metadata:
name: mock-battle-api-3
namespace: mock-battle-api-3
spec:
selector: {app: mock-battle-api-3}
ports:
- name: http
port: 8765
targetPort: 8765
apiVersion: v1
kind: Pod
metadata:
name: oke-rucio-admin
namespace: rucio
labels:
app: oke-demo-rucio-admin
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: k3d-deploy-ubuntu-server-0
containers:
- name: client
image: rucio/rucio-clients:release-41.0.0
imagePullPolicy: IfNotPresent
command: ["sleep", "infinity"]
env:
- name: RUCIO_CFG_CLIENT_ACCOUNT
value: root
- name: RUCIO_CFG_CLIENT_AUTH_HOST
value: https://rucio-auth.test
- name: RUCIO_CFG_CLIENT_AUTH_TYPE
value: userpass
- name: RUCIO_CFG_CLIENT_CA_CERT
value: /opt/rucio/etc/ca.crt
- name: RUCIO_CFG_CLIENT_RUCIO_HOST
value: https://rucio.test
- name: RUCIO_CFG_CLIENT_USERNAME
valueFrom:
secretKeyRef:
name: rucio-client
key: bootstrap-userpass-identity
- name: RUCIO_CFG_CLIENT_PASSWORD
valueFrom:
secretKeyRef:
name: rucio-client
key: bootstrap-userpass-pwd
volumeMounts:
- name: ca-cert
mountPath: /opt/rucio/etc/ca.crt
subPath: ca.crt
volumes:
- name: ca-cert
secret:
secretName: rucio-client
apiVersion: v1
kind: Pod
metadata:
name: oke-scapi-admin
namespace: rucio
labels:
app: oke-demo-scapi-admin
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: k3d-deploy-ubuntu-server-0
containers:
- name: client
image: harbor.test/library/integration:dev
imagePullPolicy: IfNotPresent
command: ["sleep", "infinity"]
env:
- name: IAM_URL
value: "https://iam.test"
- name: SCAPI_URL
value: "https://scapi.test/api/v1"
- name: REQUESTS_CA_BUNDLE
value: /etc/ssl/certs/ska-local-ca.crt
- name: SCAPI_CLIENT_ID
valueFrom:
secretKeyRef:
name: scapi-client
key: clientId
- name: SCAPI_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: scapi-client
key: clientSecret
volumeMounts:
- name: ca-cert
mountPath: /etc/ssl/certs/ska-local-ca.crt
subPath: ca.crt
volumes:
- name: ca-cert
secret:
secretName: ska-local-ca-secret
# storm-webdav storage areas for the OKE demo.
#
# anonymousReadEnabled=true on both areas: the marimo notebook runs in
# JupyterHub on the local dev cluster with no SKA IAM credentials, and has to
# GET the rendered FITS image (and, for Rucio PFN liveness, the MS files) over
# https. Write access stays OIDC-gated. This is a throwaway demo cluster.
apiVersion: v1
kind: ConfigMap
metadata:
name: storm-sa-config
namespace: storm-webdav
data:
sa.properties: |
# Name of the storage area
name=sa
# Root path for the storage area
rootPath=/data/sa
# Comma separated list of storage area access points.
accessPoints=/sa
# Comma-separated list of OAuth/OpenID Connect token issuers trusted in this storage area
orgs=https://ska-iam.stfc.ac.uk/
# Enables read access to anonymous users. Defaults to false.
anonymousReadEnabled=true
# Enables VO map files for this storage area. Defaults to true.
voMapEnabled=false
# Enables read access to storage area files to users authenticated using OAuth/OIDC.
orgsGrantReadPermission=true
# Enables write access to storage area files to users authenticated using OAuth/OIDC.
orgsGrantWritePermission=true
# Enables scope-based authorization following the rules imposed by the WLCG JWT profile. Defaults to false.
wlcgScopeAuthzEnabled=false
# Enables fine-grained authorization engine. Defaults to false.
fineGrainedAuthzEnabled=false
scratch.properties: |
# Pilot stage-out area: scratch://storm3.test/outputs/<job_id> lands in
# /srv/storage/pilot-scratch-3/outputs on the shared PVC, which is mounted
# here at /data/scratch.
name=scratch
rootPath=/data/scratch
accessPoints=/scratch
orgs=https://ska-iam.stfc.ac.uk/
anonymousReadEnabled=true
voMapEnabled=false
orgsGrantReadPermission=true
orgsGrantWritePermission=true
wlcgScopeAuthzEnabled=false
fineGrainedAuthzEnabled=false
# Give the storm-webdav namespace access to the SAME CephFS subvolume that
# job-gateway-oke/pilot-shared-storage uses, so storm-webdav can serve the
# storm3 storage area and the pilot scratch outputs directly.
#
# A RWX PVC cannot be shared across namespaces by reference, so this is a
# static PV pointing at the existing subvolume path (staticVolume: "true" makes
# the rook CSI driver mount rootPath as-is instead of provisioning). The
# reclaim policy is Retain and the volumeHandle is distinct from the dynamic
# PV's, so nothing here can cause the demo data to be deleted.
apiVersion: v1
kind: PersistentVolume
metadata:
name: storm3-shared-storage-pv
spec:
capacity:
storage: 300Gi
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
claimRef:
namespace: storm-webdav
name: storm3-shared-storage
csi:
driver: rook-ceph.cephfs.csi.ceph.com
volumeHandle: storm3-shared-storage-static
volumeAttributes:
clusterID: rook-ceph
fsName: myfs
staticVolume: "true"
rootPath: /volumes/csi/csi-vol-c7e85225-68c9-41db-98d6-62ee429ae233/95371c67-839a-4ff9-9fb0-8abeab60350d
# A staticVolume mount goes through ceph-csi's plain kernel-mount path,
# which reads userID/userKey -- NOT the adminID/adminKey that rook's own
# rook-csi-cephfs-node secret ships. Pointing at that secret fails with
# "missing ID field 'userID' in secrets" and the pod hangs in Init.
# storm3-cephfs-node re-keys the same credentials under the expected names.
nodeStageSecretRef:
name: storm3-cephfs-node
namespace: rook-ceph
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storm3-shared-storage
namespace: storm-webdav
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
volumeName: storm3-shared-storage-pv
resources:
requests:
storage: 300Gi
#!/bin/bash
set -euo pipefail
# Run the ICAL self-calibration pipeline via Rapthor.
#
# This script:
# 1. Generates a strategy file from environment variables
# 2. Templates the parset with concrete paths
# 3. Runs `rapthor <parset>`
#
# Required env vars:
# DATA_DIR – directory containing the MS and skymodel
# SCRIPTS_DIR – directory containing parset templates and this script
#
# Optional env vars (with sensible defaults for the dev target):
# DATA – dataset name stem [ska-low-aa2-mildtec-small]
# SKYMODEL_FILE – skymodel filename in DATA_DIR [lotss_p207+24_negdec.skymodel]
# SKYMODEL_MAX_SOURCES – max brightest skymodel components [200]
# SKYMODEL_MIN_FLUX_JY – min Stokes-I flux density [Jy] [0.0]
# NCYCLES – number of DD self-cal cycles [3]
# MAX_DIRECTIONS – max DD directions per cycle [3]
# MAXITER – DDECal max iterations [150]
# CELLSIZE_ARCSEC – imaging cell size [5.0]
# ROBUST – imaging robust weight [-0.5]
# IDG_MODE – IDG mode (cpu/hybrid/gpu) [cpu]
# SAVE_VISIBILITIES – save corrected visibilities [False]
# FAST_TIMESTEP_SEC – fast phase solution interval (s) [20]
# SLOW_TIMESTEP_SEC – slow gain solution interval (s) [120]
# GENERATE_SKYMODEL – let Rapthor image+extract skymodel [false]
# DDE_METHOD – imaging DD correction method [full]
# NUMTHREADS – max threads for Rapthor [nproc/2]
# MEM_PER_NODE_GB – GB of memory per worker node [0 = all]
# RAPTHOR_DEBUG_WORKFLOW – enable CWL debug mode (True/False) [False]
# SELFCAL_DATA_FRACTION – fraction of data for self-cal steps [1.0]
# FINAL_DATA_FRACTION – fraction for the optional final Rapthor cycle [same as SELFCAL_DATA_FRACTION]
# Set to 1.0 (with SELFCAL < 1) to run self-cal on a subset then a full-data final pass.
DATA_DIR="${DATA_DIR:-data}"
OUTPUT_DIR="${OUTPUT_DIR:-${DATA_DIR}}" # Separate writable output dir, defaults to DATA_DIR for backward compatibility
SCRIPTS_DIR="${SCRIPTS_DIR:-scripts}"
# HTCondor pilots run as nobody (HOME=/nonexistent); apptainer/Toil need a writable home.
export HOME="${OUTPUT_DIR}/.docker_home"
mkdir -p "${HOME}" "${HOME}/.casa"
export TOIL_WORKDIR="${TOIL_WORKDIR:-${OUTPUT_DIR}/.toil-workdir}"
export TMPDIR="${TMPDIR:-${TOIL_WORKDIR}}"
mkdir -p "${TOIL_WORKDIR}" "${TMPDIR}"
DATA="${DATA:-ska-low-aa2-mildtec-small}"
SKYMODEL_FILE="${SKYMODEL_FILE:-lotss_p207+24_negdec.skymodel}"
SKYMODEL_MAX_SOURCES="${SKYMODEL_MAX_SOURCES:-200}"
SKYMODEL_MIN_FLUX_JY="${SKYMODEL_MIN_FLUX_JY:-0.0}"
# Resolve MS
MS="${DATA_DIR}/${DATA}.ms"
if [ ! -d "${MS}" ]; then
echo "ERROR: MS not found: ${MS}" >&2
echo " Run 'make get-data' first, or set DATA= to match the extracted MS name." >&2
exit 2
fi
# Resolve skymodel
SKYMODEL="${DATA_DIR}/${SKYMODEL_FILE}"
GENERATE_SKYMODEL="${GENERATE_SKYMODEL:-false}"
# Pipeline parameters
NCYCLES="${NCYCLES:-3}"
MAX_DIRECTIONS="${MAX_DIRECTIONS:-3}"
MAXITER="${MAXITER:-150}"
CELLSIZE_ARCSEC="${CELLSIZE_ARCSEC:-5.0}"
ROBUST="${ROBUST:--0.5}"
IDG_MODE="${IDG_MODE:-cpu}"
SAVE_VISIBILITIES="${SAVE_VISIBILITIES:-False}"
FAST_TIMESTEP_SEC="${FAST_TIMESTEP_SEC:-20}"
SLOW_TIMESTEP_SEC="${SLOW_TIMESTEP_SEC:-120}"
DDE_METHOD="${DDE_METHOD:-full}"
NUMTHREADS="${NUMTHREADS:-$(($(nproc) / 2))}"
case "${NUMTHREADS}" in
'' | *[!0-9]*)
echo "ERROR: NUMTHREADS must be a positive integer, got '${NUMTHREADS}'" >&2
exit 2
;;
esac
if [ "${NUMTHREADS}" -lt 1 ]; then
NUMTHREADS=1
fi
SELFCAL_DATA_FRACTION="${SELFCAL_DATA_FRACTION:-1.0}"
FINAL_DATA_FRACTION="${FINAL_DATA_FRACTION:-$SELFCAL_DATA_FRACTION}"
# Batch system for Rapthor's internal CWL/Toil steps.
# single_machine – all steps run in this container (default, no extra deps)
# kubernetes – steps run as k8s pods; requires KUBECONFIG, a shared
# filesystem accessible to pods (TOIL_KUBERNETES_EXTRA_HOSTPATH),
# and TOIL_APPLIANCE_SELF set to the rapthor image.
# See wes-mildtec-k8s-smoke.sh for required env vars.
RAPTHOR_BATCH_SYSTEM="${RAPTHOR_BATCH_SYSTEM:-single_machine}"
# Max parallel CWL steps (Rapthor max_nodes / Toil --maxLocalJobs).
# single_machine: 1 = serial; >1 = parallel threads on same node.
# slurm_rest: each step becomes an independent Slurm job; set to the
# number of MS chunks (typically 5 for the mildtec smoke test).
# kubernetes: max concurrent k8s pods.
RAPTHOR_MAX_NODES="${RAPTHOR_MAX_NODES:-1}"
# CPUs requested per CWL step (Rapthor cpus_per_task).
# 0 = use all available on the node (default for single_machine).
# For slurm_rest, set this to match the actual CPU resources per Slurm job.
RAPTHOR_CPUS_PER_TASK="${RAPTHOR_CPUS_PER_TASK:-0}"
# Memory per node in GB used for Rapthor imaging/calibration steps.
# 0 means "use all available" (single_machine default).
# For kubernetes, set this to the memory you want each worker pod to have
# (e.g. 32 for 32 GB); Toil uses it as --defaultMemory for CWL steps.
MEM_PER_NODE_GB="${MEM_PER_NODE_GB:-0}"
# Enable Rapthor workflow debugging (generates verbose CWL logs and keeps temp files).
# Note: debug_workflow=True is incompatible with slurm_rest and slurm_static batch systems.
# Set to "True" for debugging, defaults to "False" for production use.
RAPTHOR_DEBUG_WORKFLOW="${RAPTHOR_DEBUG_WORKFLOW:-False}"
# Leave the successful workload process alive briefly for an external
# profiler to flush its perf capture before the HTCondor pilot is reaped.
BENCHMON_DRAIN_S="${BENCHMON_DRAIN_S:-0}"
case "${BENCHMON_DRAIN_S}" in
'' | *[!0-9]*)
echo "ERROR: BENCHMON_DRAIN_S must be a non-negative integer, got '${BENCHMON_DRAIN_S}'" >&2
exit 2
;;
esac
if [ "${RAPTHOR_BATCH_SYSTEM}" = "kubernetes" ]; then
# Kubernetes workers create tmp-out directories as root; the parent Rapthor
# process may run as a different UID and cannot safely clean them up.
RAPTHOR_KEEP_TEMPORARY_FILES="${RAPTHOR_KEEP_TEMPORARY_FILES:-True}"
else
RAPTHOR_KEEP_TEMPORARY_FILES="${RAPTHOR_KEEP_TEMPORARY_FILES:-False}"
fi
# Working directory
WORK_DIR="${OUTPUT_DIR}/rapthor_${DATA}"
LOG_DIR="${WORK_DIR}/logs"
mkdir -p "${LOG_DIR}"
# The pilot filesystem is read-only at /var/lib/toil. Keep Toil's local
# workflow state and temporary files in the shared, writable run directory.
export TOIL_WORKDIR="${TOIL_WORKDIR:-${WORK_DIR}/toil-workdir}"
export TMPDIR="${TMPDIR:-${WORK_DIR}/tmp}"
mkdir -p "${TOIL_WORKDIR}" "${TMPDIR}"
# Preserve the HTCondor stdout/stderr streams. The setup phase is copied into
# the shared log below; Rapthor writes its own structured log there directly.
exec 3>&1 4>&2
exec > >(tee -a "${LOG_DIR}/rapthor.log") 2>&1
if [ "${RAPTHOR_BATCH_SYSTEM}" = "kubernetes" ]; then
# Keep nested Toil worker pods on the same uid/gid as the parent Rapthor
# process. Otherwise cwltool creates root-owned 0700 tmp-out directories and
# the parent fails during output collection after the workflow has succeeded.
RAPTHOR_UID="$(id -u)"
RAPTHOR_GID="$(id -g)"
cat >"${WORK_DIR}/toil-k8s-pod-security-context.yaml" <<EOF
runAsUser: ${RAPTHOR_UID}
runAsGroup: ${RAPTHOR_GID}
fsGroup: ${RAPTHOR_GID}
fsGroupChangePolicy: OnRootMismatch
EOF
cat >"${WORK_DIR}/toil-k8s-container-security-context.yaml" <<EOF
runAsUser: ${RAPTHOR_UID}
runAsGroup: ${RAPTHOR_GID}
EOF
cp "${WORK_DIR}/toil-k8s-pod-security-context.yaml" "${WORK_DIR}/pod.yaml"
cp "${WORK_DIR}/toil-k8s-container-security-context.yaml" "${WORK_DIR}/container.yaml"
export TOIL_KUBERNETES_POD_SECURITY_CONTEXT="${WORK_DIR}/toil-k8s-pod-security-context.yaml"
export TOIL_KUBERNETES_SECURITY_CONTEXT="${WORK_DIR}/toil-k8s-container-security-context.yaml"
# Autoscaling nodes spend minutes in ContainerCreating (CSI + image
# pull). Toil's default 120s timeout treats that as a stuck mount.
export TOIL_KUBERNETES_POD_TIMEOUT="${TOIL_KUBERNETES_POD_TIMEOUT:-600}"
fi
# Rapthor invokes toil with --restart for pipeline steps. If a previous local
# run left job stores behind, newer toil/cwltool combinations can fail during
# restart before any work is redone. Start each local task run from a clean
# pipeline state inside WORK_DIR.
python3 - "${WORK_DIR}" <<'PYEOF'
from pathlib import Path
import shutil
import sys
work_dir = Path(sys.argv[1])
for jobstore in work_dir.glob("pipelines/**/jobstore"):
shutil.rmtree(jobstore, ignore_errors=True)
for output_json in work_dir.glob("pipelines/**/pipeline_outputs.json"):
output_json.unlink(missing_ok=True)
PYEOF
# Astropy (and other XDG-aware tools) write cache to ~/.astropy by default,
# which is not writable inside the container. Pin everything under DATA_DIR
# so the same writable volume is used throughout, including in toil sub-processes.
export XDG_CACHE_HOME="${OUTPUT_DIR}/.cache"
export ASTROPY_CACHE_DIR="${XDG_CACHE_HOME}/astropy"
mkdir -p "${ASTROPY_CACHE_DIR}"
# DP3 (and its OpenBLAS dependency) must not spawn multiple threads — multi-
# threaded OpenBLAS interferes with DP3's own threading and causes an immediate
# fatal exception ("std exception detected: …OpenBLAS multi-threading…") that
# terminates the process before any output is written. toil uses
# --preserve-entire-environment so this value is inherited by all CWL steps.
export OPENBLAS_NUM_THREADS=1
# Toil's workflow-history SQLite bookkeeping currently crashes on repeated local
# runs in this environment before CWL steps can execute. Disable history
# recording; this does not affect the actual pipeline work.
export TOIL_HISTORY=False
export TOIL_JOB_HISTORY=False
# ── Generate strategy file ───────────────────────────────────────────────────
# Follows the ICAL pipeline workflow (slides 28-29 of the Gecko presentation):
# - DD cycles 1-2: fast phase + medium phase only
# - DD cycle 3+: fast phase + medium phase + slow gain
# - Convergence check on middle cycles
MEDIUM_TIMESTEP_SEC=$(awk "BEGIN {printf \"%.1f\", ${FAST_TIMESTEP_SEC} * 2}")
FULLJONES_TIMESTEP_SEC="${SLOW_TIMESTEP_SEC}"
STRATEGY_FILE="${WORK_DIR}/strategy.py"
cat >"${STRATEGY_FILE}" <<STRATEGY_EOF
"""Custom Rapthor strategy generated by run-ical.sh"""
strategy_steps = []
n_cycles = ${NCYCLES}
for i in range(n_cycles):
strategy_steps.append({})
idx = i
strategy_steps[idx]['do_calibrate'] = True
strategy_steps[idx]['do_slowgain_solve'] = (i >= 2) # slow gain from cycle 3 (1-indexed)
strategy_steps[idx]['do_fulljones_solve'] = False
strategy_steps[idx]['peel_outliers'] = (i == 0)
strategy_steps[idx]['peel_bright_sources'] = False
strategy_steps[idx]['max_normalization_delta'] = 0.3
strategy_steps[idx]['scale_normalization_delta'] = True
strategy_steps[idx]['solve_min_uv_lambda'] = 50
strategy_steps[idx]['fast_timestep_sec'] = ${FAST_TIMESTEP_SEC}
strategy_steps[idx]['medium_timestep_sec'] = ${MEDIUM_TIMESTEP_SEC}
strategy_steps[idx]['slow_timestep_joint_sec'] = ${SLOW_TIMESTEP_SEC}
strategy_steps[idx]['slow_timestep_separate_sec'] = ${SLOW_TIMESTEP_SEC}
strategy_steps[idx]['fulljones_timestep_sec'] = ${FULLJONES_TIMESTEP_SEC}
strategy_steps[idx]['do_normalize'] = False
strategy_steps[idx]['do_image'] = True
strategy_steps[idx]['auto_mask'] = 3.0
strategy_steps[idx]['auto_mask_nmiter'] = 2
strategy_steps[idx]['max_nmiter'] = 8
strategy_steps[idx]['channel_width_hz'] = 4e6
strategy_steps[idx]['threshisl'] = 4.0
strategy_steps[idx]['threshpix'] = 5.0
strategy_steps[idx]['regroup_model'] = (i > 0)
strategy_steps[idx]['max_directions'] = ${MAX_DIRECTIONS}
strategy_steps[idx]['max_distance'] = None
# target_flux must be a positive float; None triggers a rapthor bug when
# max_directions is also set (total_flux < None comparison before the
# target_number branch populates target_flux from the N-brightest sort).
# A small value is fine: rapthor raises it to the Nth-brightest-source flux
# when max_directions drives selection.
# Use 0.01 Jy to ensure sources are found even in shallow images
strategy_steps[idx]['target_flux'] = 0.01
if i == 0 or i == n_cycles - 1:
strategy_steps[idx]['do_check'] = False
else:
strategy_steps[idx]['do_check'] = True
strategy_steps[idx]['convergence_ratio'] = 0.95
strategy_steps[idx]['divergence_ratio'] = 1.1
strategy_steps[idx]['failure_ratio'] = 10.0
# Final pass: duplicate last selfcal step
strategy_steps.append(strategy_steps[-1])
STRATEGY_EOF
# ── Skymodel configuration ───────────────────────────────────────────────────
if [[ "${GENERATE_SKYMODEL}" == "true" ]] || [[ ! -f "${SKYMODEL}" ]]; then
if [[ ! -f "${SKYMODEL}" ]]; then
echo "WARNING: skymodel not found: ${SKYMODEL}" >&2
echo " Rapthor will generate one from the data." >&2
fi
SKYMODEL_LINES="generate_initial_skymodel = True
generate_initial_skymodel_data_fraction = 0.2"
else
REDUCE_SKYMODEL=0
if [[ "${SKYMODEL_MAX_SOURCES}" != "0" ]]; then
REDUCE_SKYMODEL=1
elif awk "BEGIN { exit !(${SKYMODEL_MIN_FLUX_JY} > 0.0) }"; then
REDUCE_SKYMODEL=1
fi
if [[ "${REDUCE_SKYMODEL}" -eq 1 ]]; then
REDUCED_SKYMODEL="${WORK_DIR}/$(basename "${SKYMODEL_FILE%.skymodel}")_top${SKYMODEL_MAX_SOURCES}.skymodel"
python3 "${SCRIPTS_DIR}/reduce-skymodel.py" \
--input "${SKYMODEL}" \
--output "${REDUCED_SKYMODEL}" \
--max-sources "${SKYMODEL_MAX_SOURCES}" \
--min-flux "${SKYMODEL_MIN_FLUX_JY}"
SKYMODEL="${REDUCED_SKYMODEL}"
fi
echo "Using external skymodel: ${SKYMODEL}"
SKYMODEL_LINES="generate_initial_skymodel = False
input_skymodel = ${SKYMODEL}
regroup_input_skymodel = True"
fi
# ── Generate parset ──────────────────────────────────────────────────────────
# Based on rapthor_di_easy.parset but adapted for combined DI+DD flow
# and parameterised for the M2 Max dev target.
PARSET="${WORK_DIR}/rapthor.parset"
cat >"${PARSET}" <<PARSET_EOF
# Generated by run-ical.sh — $(date -u +%Y-%m-%dT%H:%M:%SZ)
# Based on rapthor_di_easy.parset (Vijay Mahatma, ska-sdp-ical)
[global]
dir_working = ${WORK_DIR}
input_ms = ${MS}
data_colname = DATA
${SKYMODEL_LINES}
download_initial_skymodel = False
strategy = ${STRATEGY_FILE}
selfcal_data_fraction = ${SELFCAL_DATA_FRACTION}
final_data_fraction = ${FINAL_DATA_FRACTION}
facet_layout = None
dde_mode = faceting
[calibration]
use_included_skymodels = False
use_image_based_predict = False
dd_interval_factor = 3
dd_smoothness_factor = 3
llssolver = qr
maxiter = ${MAXITER}
propagatesolutions = True
solveralgorithm = directioniterative
onebeamperpatch = False
stepsize = 0.02
stepsigma = 2.0
tolerance = 5e-3
fast_freqstep_hz = 1e6
fast_smoothnessconstraint = 3e6
fast_datause = single
medium_freqstep_hz = 1e6
medium_smoothnessconstraint = 6e6
medium_datause = single
slow_freqstep_hz = 1e6
slow_smoothnessconstraint = 3e6
slow_datause = dual
fulljones_freqstep_hz = 1e6
fulljones_smoothnessconstraint = 0.0
correct_time_frequency_smearing = False
[imaging]
cellsize_arcsec = ${CELLSIZE_ARCSEC}
robust = ${ROBUST}
min_uv_lambda = 50
max_uv_lambda = 5000
mgain = 0.8
do_multiscale_clean = True
dde_method = ${DDE_METHOD}
filter_skymodel = True
source_finder = bdsf
save_visibilities = ${SAVE_VISIBILITIES}
save_supplementary_images = False
compress_selfcal_images = True
compress_final_images = True
idg_mode = ${IDG_MODE}
mem_gb = ${MEM_PER_NODE_GB}
apply_diagonal_solutions = True
make_quv_images = False
use_mpi = False
reweight = False
skip_final_major_iteration = True
skip_corner_sectors = False
# Use the input skymodel for diagnostics since Pan-STARRS doesn't cover
# southern declinations (SKA-Low at dec ~ -32 deg)
photometry_skymodel = ${SKYMODEL}
astrometry_skymodel = ${SKYMODEL}
[cluster]
batch_system = ${RAPTHOR_BATCH_SYSTEM}
max_nodes = ${RAPTHOR_MAX_NODES}
cpus_per_task = ${RAPTHOR_CPUS_PER_TASK}
max_cores = ${NUMTHREADS}
max_threads = ${NUMTHREADS}
deconvolution_threads = ${NUMTHREADS}
mem_per_node_gb = ${MEM_PER_NODE_GB}
# Without this, cwlrunner.py's _get_tmp_outdir_prefix() returns None and
# --tmp-outdir-prefix is never passed to toil-cwl-runner. With --bypass-file-store
# (always on — see cwlrunner.py's CWLRunner.setup()), that leaves each CWL job's
# output location to cwltool's own per-job default, which is NOT guaranteed to be
# the same shared, persistent path a later job on a *different* worker pod looks
# for it at — surfacing as e.g. "FileNotFoundError: .../tmpXXXXXXXX/medium1_phase_0.h5parm"
# once a downstream collect_h5parms-style step runs on a different pod than the
# ddecal_solve step that produced it. Point it at shared storage so every pod sees
# the same location regardless of which one produced/consumes a given file.
global_scratch_dir = ${WORK_DIR}/.global_scratch
cwl_runner = toil
debug_workflow = ${RAPTHOR_DEBUG_WORKFLOW}
keep_temporary_files = ${RAPTHOR_KEEP_TEMPORARY_FILES}
# Disable internet access to skip downloading catalogs for diagnostics
# (Pan-STARRS doesn't cover southern declinations like SKA-Low at dec ~ -32 deg)
allow_internet_access = False
PARSET_EOF
# ── Log configuration ────────────────────────────────────────────────────────
echo "═══════════════════════════════════════════════════════════"
echo " ICAL self-calibration via Rapthor"
echo "═══════════════════════════════════════════════════════════"
echo " MS: ${MS}"
echo " Skymodel: ${SKYMODEL_FILE} (generate=${GENERATE_SKYMODEL})"
echo " Reduced skymodel: ${SKYMODEL}"
echo " Skymodel cap: ${SKYMODEL_MAX_SOURCES} brightest components"
echo " Work dir: ${WORK_DIR}"
echo " Strategy: ${STRATEGY_FILE}"
echo " DD cycles: ${NCYCLES}"
echo " Max directions: ${MAX_DIRECTIONS}"
echo " Max iterations: ${MAXITER}"
echo " Cell size: ${CELLSIZE_ARCSEC} arcsec"
echo " Robust: ${ROBUST}"
echo " Fast timestep: ${FAST_TIMESTEP_SEC} s"
echo " Slow timestep: ${SLOW_TIMESTEP_SEC} s"
echo " DDE method: ${DDE_METHOD}"
echo " Save vis: ${SAVE_VISIBILITIES}"
echo " Threads: ${NUMTHREADS}"
echo "═══════════════════════════════════════════════════════════"
echo ""
echo "── Parset ──"
cat "${PARSET}"
echo ""
echo "── Strategy ──"
cat "${STRATEGY_FILE}"
echo "═══════════════════════════════════════════════════════════"
# ── Writable package overlay (Docker --user host uid cannot write site-packages) ─
# Rapthor/Toil spawn child Python processes, so patches must live on disk. Copy
# lsmtool + rapthor + toil into WORK_DIR and prepend PYTHONPATH so edits apply
# everywhere. toil is included (not just lsmtool/rapthor) because the Kubernetes
# batch system patch below writes into toil/batchSystems/kubernetes.py; the real
# dist-packages copy is read-only in this container (Errno 30), same reason the
# other two packages are copied here rather than patched in place.
OVERLAY="${WORK_DIR}/.python_site_overlay"
LS_PKG=$(PYTHONPATH= python3 -c 'import lsmtool, pathlib; print(pathlib.Path(lsmtool.__file__).parent)')
RT_PKG=$(PYTHONPATH= python3 -c 'import rapthor, pathlib; print(pathlib.Path(rapthor.__file__).parent)')
TOIL_PKG=$(PYTHONPATH= python3 -c 'import toil, pathlib; print(pathlib.Path(toil.__file__).parent)')
rm -rf "${OVERLAY}"
mkdir -p "${OVERLAY}" "${WORK_DIR}/.global_scratch"
# Spack layouts symlink site-packages into the store; copy real files so patches can write.
cp -aL "${LS_PKG}" "${OVERLAY}/lsmtool"
cp -aL "${RT_PKG}" "${OVERLAY}/rapthor"
cp -aL "${TOIL_PKG}" "${OVERLAY}/toil"
export PYTHONPATH="${OVERLAY}${PYTHONPATH:+:${PYTHONPATH}}"
# ── Patch cwlrunner to pass an explicit Toil --maxCores ─────────────────────
# Without it Toil derives maxCores from the *local* machine (cgroup cpu quota /
# affinity). Inside a Slurm-launched pilot the coordinator's cgroup is sized to
# the outer job's request_cpus (2), so any CWL step asking for cpus_per_task=4
# is rejected with "requesting 4 cores, more than the maximum of 2". Kubernetes
# pilots dodge this only because their pods carry no cpu *limit*. TOIL_MAX_CORES
# bounds the whole pool, not one node, so cap = max_nodes * cpus_per_task.
# ``cpus_per_task=0`` means use available cores in Rapthor, but Toil rejects
# zero; use the explicitly configured thread count as the corresponding cap.
if [ -z "${TOIL_MAX_CORES:-}" ]; then
if [ "${RAPTHOR_CPUS_PER_TASK:-0}" -gt 0 ]; then
TOIL_MAX_CORES=$((${RAPTHOR_MAX_NODES:-4} * ${RAPTHOR_CPUS_PER_TASK}))
else
TOIL_MAX_CORES="${NUMTHREADS:-1}"
fi
fi
export TOIL_MAX_CORES
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
path = pathlib.Path(sys.argv[1]) / "rapthor/lib/cwlrunner.py"
src = path.read_text()
# Quote style differs between rapthor builds (pre/post black formatting).
markers = [
"self.args.extend(['--maxJobs', str(self.operation.max_nodes)])",
'self.args.extend(["--maxJobs", str(self.operation.max_nodes)])',
]
if "tiger-patch: explicit maxCores" not in src:
marker = next((m for m in markers if m in src), None)
assert marker, "cwlrunner.py maxJobs marker not found"
patch = (
marker
+ "\n # [tiger-patch: explicit maxCores — see run-ical.sh]"
+ "\n _max_cores = os.environ.get('TOIL_MAX_CORES')"
+ "\n if _max_cores:"
+ "\n self.args.extend(['--maxCores', _max_cores])"
)
src = src.replace(marker, patch, 1)
path.write_text(src)
PYEOF
# ── Pin tool PYTHONPATH via EnvVarRequirement in every step CWL ──────────────
# The leader exports PYTHONPATH=<overlay>:... and toil-cwl-runner runs with
# --preserve-entire-environment, so worker tool processes can inherit the
# overlay. That is poison for the /opt/view spack-env tools (e.g.
# make_region_file.py): the overlay lsmtool is copied from the *toil* env's
# older/partial lsmtool (no make_ds9_region_file), giving
# ImportError: cannot import name 'make_ds9_region_file' from 'lsmtool.facet'
# Pin the tool env at the CWL layer (cwltool applies EnvVarRequirement *after*
# preserved env, so this wins regardless of how the worker was launched): the
# CURRENT run's overlay first — tools must see the patched lsmtool (voronoi
# empty-points guard) and rapthor — then pysite for the fileJobStore fix. The
# overlay is copied from this same SIF, so tool/library versions always agree;
# a stale inherited PYTHONPATH from an older run's overlay cannot leak in.
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
import ruamel.yaml
TOOL_PYTHONPATH = f"{sys.argv[1]}:/srv/storage/pysite"
yaml = ruamel.yaml.YAML()
yaml.preserve_quotes = True
steps_dir = pathlib.Path(sys.argv[1]) / "rapthor/pipeline/steps"
patched = 0
for path in sorted(steps_dir.glob("*.cwl")):
doc = yaml.load(path.read_text())
if not isinstance(doc, dict) or doc.get("class") != "CommandLineTool":
continue
reqs = doc.setdefault("requirements", {})
if isinstance(reqs, list):
env_req = next((r for r in reqs if r.get("class") == "EnvVarRequirement"), None)
if env_req is None:
env_req = {"class": "EnvVarRequirement", "envDef": {}}
reqs.append(env_req)
env_def = env_req.setdefault("envDef", {})
else:
env_req = reqs.setdefault("EnvVarRequirement", {"envDef": {}})
env_def = env_req.setdefault("envDef", {})
if env_def.get("PYTHONPATH") == TOOL_PYTHONPATH:
continue
env_def["PYTHONPATH"] = TOOL_PYTHONPATH
with path.open("w") as fh:
yaml.dump(doc, fh)
patched += 1
print(f"Pinned tool PYTHONPATH={TOOL_PYTHONPATH} in {patched} step CWLs",
file=sys.stderr)
PYEOF
# ── Patch Rapthor CWL memory hints for Kubernetes workers ───────────────────
# Several Rapthor step tools ship without ResourceRequirement memory hints, so
# toil-cwl-runner starts them at 256 MiB and only retries at a higher value after
# an OOM. Give the heavy DP3 steps an explicit request up front.
if [ "${RAPTHOR_BATCH_SYSTEM}" = "kubernetes" ] && [ "${MEM_PER_NODE_GB}" != "0" ]; then
python3 - "${OVERLAY}" "${MEM_PER_NODE_GB}" <<'PYEOF'
import pathlib, sys
overlay = pathlib.Path(sys.argv[1])
ram_mib = int(float(sys.argv[2]) * 1024)
targets = [
overlay / "rapthor/pipeline/steps/ddecal_solve.cwl",
overlay / "rapthor/pipeline/steps/predict_model_data.cwl",
# subtract_sector_models.cwl has no ResourceRequirement at all (confirmed:
# no ramMin/ramMax anywhere in the file) and got OOMKilled (exit 137) at
# the default few-hundred-MiB cwltool/Toil fall back to — it does the same
# class of full-MS-data work as the two steps above.
overlay / "rapthor/pipeline/steps/subtract_sector_models.cwl",
]
for path in targets:
try:
src = path.read_text()
except FileNotFoundError:
print(f"WARNING: {path} not found — skipping ResourceRequirement patch", file=sys.stderr)
continue
if "class: ResourceRequirement" in src:
continue
marker = "hints:\n"
if marker not in src:
# No existing hints: block at all — 0-indent list items are valid CWL/YAML.
block = (
"- class: ResourceRequirement\n"
f" ramMin: {ram_mib}\n"
f" ramMax: {ram_mib}\n"
)
src = src.rstrip() + "\n\nhints:\n" + block
else:
# Match this file's own indent for existing "- class: ..." items under
# hints: (observed to vary between files — e.g. 0-indent in
# ddecal_solve.cwl/predict_model_data.cwl, 2-indent in
# subtract_sector_models.cwl). Mixing indents under one mapping key is
# invalid YAML ("expected <block end>, but found '-'").
after_marker = src[src.index(marker) + len(marker) :]
indent = after_marker[: len(after_marker) - len(after_marker.lstrip(" "))]
block = (
f"{indent}- class: ResourceRequirement\n"
f"{indent} ramMin: {ram_mib}\n"
f"{indent} ramMax: {ram_mib}\n"
)
src = src.replace(marker, marker + block, 1)
path.write_text(src)
print(f"Patched {path} ResourceRequirement ram={ram_mib} MiB", file=sys.stderr)
PYEOF
fi
# ── Patch CWL cores/ram so Toil worker pods request cpus_per_task ──────────
# Rapthor only puts a ResourceRequirement on some steps. The rest start at
# Toil's default 1 CPU, so five "8-core" workers still fit on one 36-vCPU
# pool2 node and NUMTHREADS is cgroup-throttled to 1. Merge coresMin/ramMin
# into an existing hints: list — never append a second hints: key (invalid YAML).
if [ "${RAPTHOR_BATCH_SYSTEM}" = "kubernetes" ] && [ "${RAPTHOR_CPUS_PER_TASK:-0}" -gt 0 ]; then
python3 - "${OVERLAY}" "${RAPTHOR_CPUS_PER_TASK}" "${MEM_PER_NODE_GB:-0}" <<'PYEOF'
import pathlib, re, sys
overlay = pathlib.Path(sys.argv[1])
cores = int(sys.argv[2])
ram_mib = int(float(sys.argv[3]) * 1024) if float(sys.argv[3]) > 0 else 0
steps = overlay / "rapthor/pipeline/steps"
if not steps.is_dir():
print(f"WARNING: {steps} not found — skipping coresMin patch", file=sys.stderr)
sys.exit(0)
def _stamp(src: str) -> str:
extra_fields = []
if not re.search(r"^\s*coresMin:", src, re.M):
extra_fields += [f"coresMin: {cores}", f"coresMax: {cores}"]
if ram_mib and not re.search(r"^\s*ramMin:", src, re.M):
extra_fields += [f"ramMin: {ram_mib}", f"ramMax: {ram_mib}"]
if not extra_fields:
return src
m = re.search(r"^([ \t]*)- class: ResourceRequirement\n", src, re.M)
if m:
indent = m.group(1)
extra = "".join(f"{indent} {line}\n" for line in extra_fields)
return src[: m.end()] + extra + src[m.end() :]
rr = "- class: ResourceRequirement\n" + "".join(
f" {line}\n" for line in extra_fields
)
hm = re.search(r"^hints:\n", src, re.M)
if hm:
after = src[hm.end() :]
indent = after[: len(after) - len(after.lstrip(" "))]
block = "".join(
(indent + line + "\n") if line else "\n" for line in rr.splitlines()
)
return src[: hm.end()] + block + src[hm.end() :]
return src.rstrip() + "\n\nhints:\n" + rr
n = 0
for path in sorted(steps.glob("*.cwl")):
src = path.read_text()
new = _stamp(src)
if new == src:
continue
if len(re.findall(r"^hints:\s*$", new, re.M)) > 1:
print(f"WARNING: {path.name} would have duplicate hints — skipped", file=sys.stderr)
continue
path.write_text(new)
n += 1
print(f"Patched coresMin={cores} ramMin={ram_mib} on {n} CWL steps", file=sys.stderr)
PYEOF
fi
# ── Python startup hook for nested Toil worker pods ─────────────────────────
# cwltool creates tmp-out directories with tempfile.mkdtemp(..., mode 0700).
# Kubernetes workers run as root while the parent Rapthor/Toil process may not,
# so Toil can finish the workflow but fail while collecting outputs from those
# root-owned directories. PYTHONPATH is preserved into nested workers; make their
# temp dirs group/world traversable as soon as they are created.
cat >"${OVERLAY}/sitecustomize.py" <<'PYEOF'
import importlib.util
import os
from pathlib import Path
import sys
import tempfile
_this = Path(__file__).resolve()
for _entry in sys.path:
_candidate = Path(_entry or os.getcwd()) / "sitecustomize.py"
try:
if not _candidate.is_file() or _candidate.resolve() == _this:
continue
except OSError:
continue
_spec = importlib.util.spec_from_file_location("_rapthor_upstream_sitecustomize", _candidate)
if _spec and _spec.loader:
_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_module)
globals().update({k: v for k, v in vars(_module).items() if not k.startswith("__")})
break
_original_mkdtemp = tempfile.mkdtemp
def _chmod_shared_tmpdir(path: str) -> None:
if "/tmp-out/" not in path:
return
current = path
while "/tmp-out/" in current:
try:
os.chmod(current, 0o770)
except OSError:
pass
parent = os.path.dirname(current)
if parent == current:
break
current = parent
def _shared_mkdtemp(*args, **kwargs):
path = _original_mkdtemp(*args, **kwargs)
_chmod_shared_tmpdir(path)
return path
os.umask(0o007)
tempfile.mkdtemp = _shared_mkdtemp
PYEOF
# ── Patch Toil Kubernetes security-context loader ───────────────────────────
# The dev image has a local Kubernetes batch-system patch where
# _load_kubernetes_object(file, cls) ignores file and always opens
# "container.yaml". That breaks absolute --kubernetes*SecurityContext paths.
# Patch the OVERLAY's copy of toil, not the real dist-packages one: this
# container's site-packages is read-only (Errno 30), which is exactly why
# lsmtool/rapthor/toil are all copied into OVERLAY above instead of patched
# in place. PYTHONPATH already puts OVERLAY first, so the overlay copy is
# what actually gets imported.
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
p = pathlib.Path(sys.argv[1]) / "toil" / "batchSystems" / "kubernetes.py"
if not p.is_file():
print(f"WARNING: {p} not found — skipping security-context loader patch", file=sys.stderr)
sys.exit(0)
src = p.read_text()
old = ' object_def = YAML.load(open("container.yaml").read())\n'
new = ' object_def = YAML.load(open(file).read())\n'
if old not in src:
print(f"WARNING: Toil Kubernetes security-context loader patch target not found in {p}", file=sys.stderr)
sys.exit(0)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (security-context loader uses requested file)", file=sys.stderr)
PYEOF
# ── Patch rapthor ToilRunner local workdir ───────────────────────────────────
# Rapthor only provides a work directory for Slurm/Kubernetes batch systems;
# single_machine therefore falls back to read-only /var/lib/toil in a pilot.
# Make its shared _get_workdir() helper return TOIL_WORKDIR for every backend.
python3 - <<'PYEOF'
import pathlib, sys
import rapthor.lib.cwlrunner as _cr
p = pathlib.Path(_cr.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: explicit toil workdir]"
if MARKER in src:
sys.exit(0)
old = " else:\n return None\n\n def _add_slurm_options"
new = (
" " + MARKER + "\n"
+ " _work_dir = os.environ.get('TOIL_WORKDIR')\n"
+ " if _work_dir:\n"
+ " return _work_dir\n"
+ " else:\n"
+ " return None\n\n"
+ " def _add_slurm_options"
)
if old not in src:
print(f"ERROR: Toil workdir patch target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (explicit Toil workdir)", file=sys.stderr)
PYEOF
# ── Patch rapthor ToilRunner Kubernetes security-context args ────────────────
# Toil supports env vars for these, but passing explicit absolute paths avoids
# stale/default relative names such as container.yaml in restarted WES runs.
python3 - <<'PYEOF'
import pathlib, sys
import rapthor.lib.cwlrunner as _cr
p = pathlib.Path(_cr.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: explicit kubernetes security context args]"
if MARKER in src:
sys.exit(0)
old = (
" for k in k8s_keys:\n"
" _os.environ[k] = _os.environ[k] # ensure visible to child process\n"
)
new = (
old
+ " " + MARKER + "\n"
+ " _pod_sec = _os.environ.get('TOIL_KUBERNETES_POD_SECURITY_CONTEXT')\n"
+ " if _pod_sec:\n"
+ " self.args.extend(['--kubernetesPodSecurityContext', _pod_sec])\n"
+ " _container_sec = _os.environ.get('TOIL_KUBERNETES_SECURITY_CONTEXT')\n"
+ " if _container_sec:\n"
+ " self.args.extend(['--kubernetesSecurityContext', _container_sec])\n"
)
if old not in src:
# The k8s_keys block is added by patch-rapthor-k8s.py in the old rapthor
# image; the Karabo-based image ships kubernetes support natively and no
# longer has it. The patch only matters for the kubernetes batch system.
print(f"WARNING: Kubernetes security-context patch target not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (kubernetes security-context args)", file=sys.stderr)
PYEOF
# ── Inject slurm_rest Toil batch system ──────────────────────────────────────
# Copies the slurmrest dispatcher into the overlay so Rapthor can load it as
# a batch system plugin regardless of whether it ships in the container image.
# Looks first in SCRIPTS_DIR (inside the container), then on shared storage.
_SLURMREST_SRC="${SCRIPTS_DIR}/toil_batch_system_slurmrest.py"
if [ ! -f "${_SLURMREST_SRC}" ]; then
_SLURMREST_SRC="/srv/storage/toil_batch_system_slurmrest.py"
fi
if [ -f "${_SLURMREST_SRC}" ]; then
cp "${_SLURMREST_SRC}" "${OVERLAY}/rapthor/lib/toil_batch_systems/toil_batch_system_slurmrest.py"
echo "Injected slurm_rest batch system from ${_SLURMREST_SRC}" >&2
else
echo "WARNING: toil_batch_system_slurmrest.py not found; slurm_rest batch system unavailable" >&2
fi
# ── Inject htcondor pool Toil batch system ───────────────────────────────────
# Same pattern as slurm_rest above: prefer the staged copy (tiger-stage-scripts
# stages images/rapthor-htcondor/toil_batch_system_condor.py) over the one baked
# into the container image, so batch-system fixes don't require an image rebuild.
_CONDORBS_SRC="${SCRIPTS_DIR}/toil_batch_system_condor.py"
if [ ! -f "${_CONDORBS_SRC}" ]; then
_CONDORBS_SRC="/srv/storage/toil_batch_system_condor.py"
fi
if [ -f "${_CONDORBS_SRC}" ]; then
cp "${_CONDORBS_SRC}" "${OVERLAY}/rapthor/lib/toil_batch_systems/toil_batch_system_condor.py"
echo "Injected htcondor pool batch system from ${_CONDORBS_SRC}" >&2
else
echo "WARNING: toil_batch_system_condor.py not found; htcondor pool batch system unavailable" >&2
fi
# ── Patch lsmtool: guard voronoi() against empty bounding-box filter ─────────
# lsmtool.facet.prepare_points_for_tessellate already handles the case where
# every calibration source falls outside the image bounding box – it returns
# an empty array early. The caller, voronoi(), never checked for that before
# handing `points` to scipy.spatial.Voronoi, which raises:
# ValueError: No points given
# This happens with MAX_DIRECTIONS=1 because the single calibration patch can
# sit outside the tight pixel footprint used for tessellation plotting.
# Fix: avoid scipy on empty points, but still emit one facet per calibrator so
# WSClean facet count matches the H5 (empty facet list → "1 directions vs 0 facets").
python3 - <<'PYEOF'
import pathlib, sys
import lsmtool.facet as _lf
p = pathlib.Path(_lf.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: guard voronoi against empty points_centre]"
MARKER2 = "# [rapthor-patch: voronoi full-bbox fallback for out-of-box cals]"
MARKER3 = "# [rapthor-patch: voronoi fallback facet centers at bbox middle]"
if MARKER3 in src:
sys.exit(0) # idempotent – already applied
# Upgrade: cal-pixel centers can sit outside the WCS footprint → invalid polygons / empty centroid.
BUG_RETURN = " return np.asarray(cal_coords, dtype=float).reshape(-1, 2), rect, regions\n"
FIX_RETURN = (
" cx = 0.5 * (minx + maxx)\n"
" cy = 0.5 * (miny + maxy)\n"
" ncal = len(cal_coords)\n"
" centers = np.tile(np.array([[cx, cy]], dtype=float), (ncal, 1))\n"
" return centers, rect, regions\n"
)
if BUG_RETURN in src:
src = src.replace(BUG_RETURN, FIX_RETURN, 1)
if MARKER3 not in src:
src = src.replace(
MARKER2 + "\n",
MARKER2 + "\n " + MARKER3 + "\n",
1,
)
p.write_text(src)
print(f"Upgraded voronoi fallback centers in {p}", file=sys.stderr)
sys.exit(0)
# Upgrade path: replace the old "return empty regions" patch body.
old_broken = (
" " + MARKER + "\n"
" # All calibrators lie outside the bounding box; return an empty\n"
" # tessellation instead of crashing scipy.spatial.Voronoi with a\n"
" # zero-length array (e.g. MAX_DIRECTIONS=1 smoke-test runs).\n"
" if len(points_centre) == 0:\n"
" return points_centre, np.zeros((0, 2)), []\n"
)
new_broken = (
" " + MARKER + "\n"
" " + MARKER2 + "\n"
" " + MARKER3 + "\n"
" # Calibrators outside the tessellation bbox: scipy Voronoi cannot run on\n"
" # zero points. WSClean still needs one facet per direction in the H5.\n"
" # Use one full-field rectangle per calibrator; facet centers must lie\n"
" # inside the bbox (not raw cal pixels) so Rapthor/Shapely get valid polygons.\n"
" if len(points_centre) == 0:\n"
" if len(cal_coords) == 0:\n"
" return points_centre, np.zeros((0, 2)), []\n"
" minx, maxx, miny, maxy = bounding_box\n"
" rect = np.array(\n"
" [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy]], dtype=float\n"
" )\n"
" regions = [list(range(4)) for _ in range(len(cal_coords))]\n"
" cx = 0.5 * (minx + maxx)\n"
" cy = 0.5 * (miny + maxy)\n"
" ncal = len(cal_coords)\n"
" centers = np.tile(np.array([[cx, cy]], dtype=float), (ncal, 1))\n"
" return centers, rect, regions\n"
)
old_pristine = (
" points_centre, points = prepare_points_for_tessellate(\n"
" cal_coords, bounding_box\n"
" )\n"
"\n"
" # Compute Voronoi, sorting the output regions to match the order of the\n"
" # input coordinates\n"
" vor = scipy.spatial.Voronoi(points)"
)
new_pristine = (
" points_centre, points = prepare_points_for_tessellate(\n"
" cal_coords, bounding_box\n"
" )\n"
"\n"
+ new_broken
+ "\n"
" # Compute Voronoi, sorting the output regions to match the order of the\n"
" # input coordinates\n"
" vor = scipy.spatial.Voronoi(points)"
)
if old_broken in src:
src = src.replace(old_broken, new_broken, 1)
elif old_pristine in src:
src = src.replace(old_pristine, new_pristine, 1)
else:
print(f"WARNING: patch target not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
p.write_text(src)
print(f"Patched {p}", file=sys.stderr)
PYEOF
# ── Patch rapthor facet.py: wrap download_panstarrs call in try/except ───────
# Pan-STARRS doesn't cover southern declinations (dec < -30°), so download_panstarrs()
# returns an empty file which causes lsmtool to crash with "No data lines found".
# Patch: wrap the call in find_astrometry_offsets with try/except to gracefully skip.
python3 - <<'PYEOF'
import pathlib, sys
try:
import rapthor.lib.facet as _rf
except ModuleNotFoundError:
# Newer (Karabo-based) rapthor moved facet code into lsmtool.facet.
print("WARNING: rapthor.lib.facet not present — skipping panstarrs patch", file=sys.stderr)
sys.exit(0)
p = pathlib.Path(_rf.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: wrap download_panstarrs in try/except]"
if MARKER in src:
print(f"Patch already applied to {p}", file=sys.stderr)
sys.exit(0)
# The code structure is:
# if comparison_skymodel is None:
# comparison_skymodel = self.download_panstarrs()
# We need to wrap the download_panstarrs() call in try/except
old = """ if comparison_skymodel is None:
comparison_skymodel = self.download_panstarrs()"""
new = """ if comparison_skymodel is None:
""" + MARKER + """
try:
comparison_skymodel = self.download_panstarrs()
except Exception as e:
import logging
logging.warning(f'Pan-STARRS download failed (likely outside coverage): {e}')
return"""
if old not in src:
print(f"WARNING: find_astrometry_offsets patch target not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p}", file=sys.stderr)
PYEOF
# ── Patch lsmtool Facet.__init__ (new home of rapthor.lib.facet.Facet) ───────
# Same robustness fix as the rapthor.lib.facet block below: WSClean-produced
# facet regions can yield invalid Shapely polygons (NaN pixels, bow-ties);
# .centroid is then an empty Point and calculate_image_diagnostics crashes with
# "Error: getX called on empty Point". buffer(0) repairs the polygon and
# representative_point() always returns an interior point.
python3 - <<'PYEOF'
import pathlib, sys
import lsmtool.facet as _lf
p = pathlib.Path(_lf.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: lsmtool Facet polygon buffer + representative_point]"
if MARKER in src:
sys.exit(0)
old_poly = """ polygon_vertices = [(x, y) for x, y in zip(x_values, y_values)]
self.polygon = Polygon(polygon_vertices)
"""
new_poly = """ polygon_vertices = [(x, y) for x, y in zip(x_values, y_values)]
""" + MARKER + """
_poly = Polygon(polygon_vertices)
if not _poly.is_valid:
_poly = _poly.buffer(0)
self.polygon = _poly
"""
old_centroid = """ self.ra_centroid, self.dec_centroid = map(
float,
self.wcs.wcs_pix2world(
self.polygon.centroid.x,
self.polygon.centroid.y,
WCS_ORIGIN,
),
)
"""
new_centroid = """ _rp = self.polygon.representative_point()
self.ra_centroid, self.dec_centroid = map(
float,
self.wcs.wcs_pix2world(_rp.x, _rp.y, WCS_ORIGIN),
)
"""
if old_poly not in src or old_centroid not in src:
print(f"WARNING: lsmtool Facet patch targets not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
src = src.replace(old_poly, new_poly, 1)
src = src.replace(old_centroid, new_centroid, 1)
p.write_text(src)
print(f"Patched {p} (lsmtool Facet robust polygon)", file=sys.stderr)
PYEOF
# ── Patch rapthor Facet.__init__: robust pixel polygon for ds9 / diagnostics ──
# WSClean-produced facet regions can yield invalid Shapely polygons (NaN pixels,
# bow-ties); .centroid is then an empty Point and calculate_image_diagnostics crashes.
python3 - <<'PYEOF'
import pathlib, sys
try:
import rapthor.lib.facet as _rf
except ModuleNotFoundError:
print("WARNING: rapthor.lib.facet not present — skipping Facet polygon patch", file=sys.stderr)
sys.exit(0)
p = pathlib.Path(_rf.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: Facet polygon buffer + representative_point]"
if MARKER in src:
sys.exit(0)
old = """ polygon_vertices = [(x, y) for x, y in zip(x_values, y_values)]
self.polygon = Polygon(polygon_vertices)
# Find the size and center coordinates of the facet
xmin, ymin, xmax, ymax = self.polygon.bounds
self.size = min(0.5, max(xmax-xmin, ymax-ymin) *
abs(self.wcs.wcs.cdelt[0])) # degrees
self.x_center = xmin + (xmax - xmin)/2
self.y_center = ymin + (ymax - ymin)/2
self.ra_center, self.dec_center = map(
float, self.wcs.wcs_pix2world(self.x_center, self.y_center, misc.WCS_ORIGIN)
)
# Find the centroid of the facet
self.ra_centroid, self.dec_centroid = map(
float, self.wcs.wcs_pix2world(self.polygon.centroid.x,
self.polygon.centroid.y,
misc.WCS_ORIGIN)
)"""
new = """ polygon_vertices = [(x, y) for x, y in zip(x_values, y_values)]
""" + MARKER + """
_poly = Polygon(polygon_vertices)
if not _poly.is_valid:
_poly = _poly.buffer(0)
self.polygon = _poly
# Find the size and center coordinates of the facet
xmin, ymin, xmax, ymax = self.polygon.bounds
self.size = min(0.5, max(xmax-xmin, ymax-ymin) *
abs(self.wcs.wcs.cdelt[0])) # degrees
self.x_center = xmin + (xmax - xmin)/2
self.y_center = ymin + (ymax - ymin)/2
self.ra_center, self.dec_center = map(
float, self.wcs.wcs_pix2world(self.x_center, self.y_center, misc.WCS_ORIGIN)
)
# Interior reference point in pixel space (centroid can be empty for invalid polys)
_rp = self.polygon.representative_point()
self.ra_centroid, self.dec_centroid = map(
float, self.wcs.wcs_pix2world(_rp.x, _rp.y, misc.WCS_ORIGIN)
)"""
if old not in src:
print(f"WARNING: Facet.__init__ patch target not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p}", file=sys.stderr)
PYEOF
# ── Patch rapthor image op: allow missing h5parm without disabling facets ─────
# Some runs have no h5parm for early image steps; keep faceting inputs enabled
# and only null out h5parm serialization when filename is absent.
python3 - <<'PYEOF'
import pathlib, sys
import rapthor.operations.image as _img
p = pathlib.Path(_img.__file__)
src = p.read_text()
OLD_MARKER = "# [rapthor-patch: Image set apply_none when h5parm missing]"
NEW_MARKER = "# [rapthor-patch: Image guard missing h5parm path]"
if NEW_MARKER in src:
sys.exit(0)
# Undo older patch if present, then apply the safer replacement.
src = src.replace(
" " + OLD_MARKER + "\n"
" if self.field.h5parm_filename is None:\n"
" self.apply_none = True\n",
"",
)
old = " h5parm = CWLFile(self.field.h5parm_filename).to_json() if not self.apply_none else None\n"
new = (
" " + NEW_MARKER + "\n"
" h5_ok = (self.field.h5parm_filename is not None)\n"
" h5parm = CWLFile(self.field.h5parm_filename).to_json() if (h5_ok and not self.apply_none) else None\n"
)
if old not in src:
print(f"WARNING: image.py h5parm patch target not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
src = src.replace(old, new, 1)
p.write_text(src)
print(f"Patched {p}", file=sys.stderr)
PYEOF
# ── Patch rapthor parset validation: allow batch_system=kubernetes ──────────
# parset.py's Parset.__check_and_adjust only allows single_machine/slurm/
# slurm_static for [cluster] batch_system, so RAPTHOR_BATCH_SYSTEM=kubernetes
# fails parset validation before any CWL step runs — even though ToilRunner
# elsewhere (see the teardown patch below) already recognizes 'kubernetes' as
# a valid self.operation.batch_system value.
python3 - <<'PYEOF'
import pathlib, sys
import rapthor.lib.parset as _ps
p = pathlib.Path(_ps.__file__)
src = p.read_text()
old = '"batch_system": ("single_machine", "slurm", "slurm_static"),'
new = '"batch_system": ("single_machine", "slurm", "slurm_static", "kubernetes"),'
if new in src:
sys.exit(0) # idempotent - already applied
if old not in src:
print(f"ERROR: parset.py batch_system validation target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (batch_system accepts 'kubernetes')", file=sys.stderr)
PYEOF
# ── Patch Toil appliance-image existence check ──────────────────────────────
# checkDockerImageExists() does an anonymous HEAD request against the
# registry's v2 manifest API before Toil's kubernetes batchSystem launches
# worker pods. Harbor requires auth for that, so it 401s and Toil raises
# ApplianceImageNotFound even though Kubernetes (using its own
# imagePullSecrets) pulls the same image just fine. There is no supported env
# var to skip this (TOIL_SKIP_IMAGE_CHECK is not read anywhere in this Toil
# version) — patch the redundant pre-check out instead. Patched on the
# OVERLAY's toil copy (see the writable overlay at the top of this script).
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
p = pathlib.Path(sys.argv[1]) / "toil" / "__init__.py"
if not p.is_file():
print(f"WARNING: {p} not found — skipping appliance image check patch", file=sys.stderr)
sys.exit(0)
src = p.read_text()
MARKER = "# [rapthor-patch: skip appliance image existence check]"
if MARKER in src:
sys.exit(0) # idempotent - already applied
old = (
" if currentCommit in appliance:\n"
" return appliance\n"
" registryName, imageName, tag = parseDockerAppliance(appliance)\n"
)
new = (
" if currentCommit in appliance:\n"
" return appliance\n"
" " + MARKER + "\n"
" return appliance\n"
" registryName, imageName, tag = parseDockerAppliance(appliance)\n"
)
if old not in src:
print(f"ERROR: Toil appliance image check patch target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (skip appliance image existence check)", file=sys.stderr)
PYEOF
# ── Patch Toil kubernetes worker pods to mount shared storage ───────────────
# Toil's file jobStore (--jobStore file:/srv/storage/...) must be visible at
# the SAME absolute path inside every worker pod it launches, or the worker
# fails instantly with NoSuchJobStoreException. None of Toil's built-in
# TOIL_KUBERNETES_* env vars do this — TOIL_KUBERNETES_HOST_PATH only mounts
# Toil's own internal per-node workDir/coordination dirs, not arbitrary paths.
# wes-mildtec-k8s-smoke.sh already documents/exports a
# TOIL_KUBERNETES_EXTRA_HOSTPATH env var for exactly this, but nothing ever
# taught Toil to read it — add that mount here (idempotent, keyed by env var
# presence so this is a no-op unless the caller opts in).
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
p = pathlib.Path(sys.argv[1]) / "toil" / "batchSystems" / "kubernetes.py"
if not p.is_file():
print(f"WARNING: {p} not found — skipping extra hostpath mount patch", file=sys.stderr)
sys.exit(0)
src = p.read_text()
MARKER = "# [rapthor-patch: mount TOIL_KUBERNETES_EXTRA_HOSTPATH]"
if MARKER in src:
sys.exit(0) # idempotent - already applied
# TOIL_KUBERNETES_EXTRA_PVC_CLAIM (+ the existing TOIL_KUBERNETES_EXTRA_HOSTPATH
# as the mount path) binds a PersistentVolumeClaim instead of a hostPath. A
# hostPath is only actually shared storage on a single-node cluster where
# every worker pod lands on the same node as the leader — on a real multi-node
# cluster (this patch's whole reason to exist) each worker pod would get an
# empty, node-local directory disconnected from the leader's data instead.
# Falls back to hostPath when no PVC claim is given, so the local dev cluster
# (which has no such PVC) is unaffected.
old = " if self.host_path is not None:\n"
new = (
" " + MARKER + "\n"
+ " _extra_hostpath = os.environ.get(\"TOIL_KUBERNETES_EXTRA_HOSTPATH\")\n"
+ " _extra_pvc_claim = os.environ.get(\"TOIL_KUBERNETES_EXTRA_PVC_CLAIM\")\n"
+ " if _extra_pvc_claim and _extra_hostpath:\n"
+ " from kubernetes.client import V1PersistentVolumeClaimVolumeSource\n"
+ " _pvc_source = V1PersistentVolumeClaimVolumeSource(claim_name=_extra_pvc_claim)\n"
+ " volumes.append(V1Volume(name=\"extra-pvc\", persistent_volume_claim=_pvc_source))\n"
+ " mounts.append(V1VolumeMount(mount_path=_extra_hostpath, name=\"extra-pvc\"))\n"
+ " elif _extra_hostpath:\n"
+ " mount_host_path(\"extra-hostpath\", _extra_hostpath, _extra_hostpath)\n"
+ old
)
if old not in src:
print(f"ERROR: Toil kubernetes extra-hostpath patch target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (mounts TOIL_KUBERNETES_EXTRA_HOSTPATH/EXTRA_PVC_CLAIM into worker pods)", file=sys.stderr)
PYEOF
# ── Patch Toil kubernetes worker pods with a node-local emptyDir scratch ────
# CephFS PVC I/O showed up as iowait in benchmon. Keep the shared jobStore
# on the PVC, but put TMPDIR / CWL temp on an emptyDir so hot intermediates
# stay on the node. Opt-in via TOIL_KUBERNETES_LOCAL_SCRATCH=1; mount path
# defaults to /scratch (override with TOIL_KUBERNETES_LOCAL_SCRATCH_PATH).
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
p = pathlib.Path(sys.argv[1]) / "toil" / "batchSystems" / "kubernetes.py"
if not p.is_file():
print(f"WARNING: {p} not found — skipping local scratch patch", file=sys.stderr)
sys.exit(0)
src = p.read_text()
MARKER = "# [rapthor-patch: mount TOIL_KUBERNETES_LOCAL_SCRATCH emptyDir]"
if MARKER in src:
sys.exit(0)
old = " if self.host_path is not None:\n"
if old not in src:
# Prefer anchoring after the EXTRA_PVC block if the host_path line moved.
old = " " + "# [rapthor-patch: mount TOIL_KUBERNETES_EXTRA_HOSTPATH]\n"
if old not in src:
print(f"ERROR: Toil kubernetes local-scratch patch target not found in {p}", file=sys.stderr)
sys.exit(1)
new = (
" " + MARKER + "\n"
# Use module-level V1EmptyDirVolumeSource — a local import would shadow it
# and raise UnboundLocalError on Toil's own emptyDir workdir path when
# LOCAL_SCRATCH is unset.
+ " if os.environ.get(\"TOIL_KUBERNETES_LOCAL_SCRATCH\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n"
+ " _scratch_path = os.environ.get(\"TOIL_KUBERNETES_LOCAL_SCRATCH_PATH\", \"/scratch\")\n"
+ " volumes.append(V1Volume(name=\"local-scratch\", empty_dir=V1EmptyDirVolumeSource()))\n"
+ " mounts.append(V1VolumeMount(mount_path=_scratch_path, name=\"local-scratch\"))\n"
+ old
)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (mounts emptyDir local scratch into worker pods)", file=sys.stderr)
PYEOF
# ── Patch Toil kubernetes worker pods to require a specific node pool ───────
# Toil's own Placement.required_labels mechanism (kubernetes.py) already
# builds a proper nodeAffinity — it's just only ever populated from CWL
# accelerator/GPU hints. This cluster's small static pool (pool1) also runs
# rook-ceph/Harbor/job-gateway itself, and sizing a task to *accidentally*
# overflow it (the previous approach) only forces a scale-up on the first
# task that doesn't fit — every other task keeps landing on pool1 and
# contending with cluster infra. Read TOIL_KUBERNETES_REQUIRED_NODE_LABEL
# ("key=value") and require it directly so every worker pod targets the
# large autoscaling pool explicitly. No-op (falls back to default scheduling)
# when unset, so clusters without such a label are unaffected.
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib, sys
p = pathlib.Path(sys.argv[1]) / "toil" / "batchSystems" / "kubernetes.py"
if not p.is_file():
print(f"WARNING: {p} not found — skipping required-node-label patch", file=sys.stderr)
sys.exit(0)
src = p.read_text()
MARKER = "# [rapthor-patch: require TOIL_KUBERNETES_REQUIRED_NODE_LABEL]"
if MARKER in src:
sys.exit(0) # idempotent - already applied
old = " placement.set_preemptible(job_desc.preemptible)\n"
new = (
old
+ " " + MARKER + "\n"
+ " _required_label = os.environ.get(\"TOIL_KUBERNETES_REQUIRED_NODE_LABEL\")\n"
+ " if _required_label and \"=\" in _required_label:\n"
+ " _label_key, _label_value = _required_label.split(\"=\", 1)\n"
+ " placement.required_labels.append((_label_key, [_label_value]))\n"
)
if old not in src:
print(f"ERROR: Toil kubernetes placement patch target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (requires TOIL_KUBERNETES_REQUIRED_NODE_LABEL on worker pods)", file=sys.stderr)
PYEOF
# ── Patch Toil's appliance-existence check for token-auth registries ────────
# Toil validates TOIL_APPLIANCE_SELF by GETting the registry manifest URL with
# no auth. Harbor answers 401 with a Bearer challenge even for PUBLIC projects
# (the standard Docker token dance, which Toil does not perform), so the check
# fails and the batch system aborts before it ever creates a worker pod:
# toil.ApplianceImageNotFound: ... The HTTP status returned was 401.
# forceDockerAppliance is Toil's own supported bypass. Worker pods still pull
# the image normally -- they inherit imagePullSecrets from the pilot's
# TOIL_KUBERNETES_SERVICE_ACCOUNT.
python3 - "${OVERLAY}" <<'PYEOF'
import pathlib
import sys
p = pathlib.Path(sys.argv[1]) / "toil" / "batchSystems" / "kubernetes.py"
src = p.read_text()
old = "applianceSelf()"
new = "applianceSelf(forceDockerAppliance=True)"
if new in src:
sys.exit(0)
if old not in src:
print(f"ERROR: appliance-check patch target not found in {p}", file=sys.stderr)
sys.exit(1)
p.write_text(src.replace(old, new, 1))
print(f"Patched {p} (skip appliance manifest check; registry uses token auth)", file=sys.stderr)
PYEOF
# ── Patch rapthor ToilRunner teardown for Kubernetes cross-UID tmp dirs ──────
# Kubernetes worker pods can create tmp-out directories as root. The parent
# Rapthor process may run as UID 1000 and must not rmtree those directories.
python3 - <<'PYEOF'
import pathlib, sys
import rapthor.lib.cwlrunner as _cr
p = pathlib.Path(_cr.__file__)
src = p.read_text()
MARKER = "# [rapthor-patch: skip kubernetes tmp-out cleanup]"
if MARKER in src:
sys.exit(0)
old = (
" \"\"\"\n"
" if not self.operation.keep_temporary_files:\n"
" # Remove directories used for storing intermediate job results"
)
new = (
" \"\"\"\n"
" " + MARKER + "\n"
" if self.operation.batch_system == 'kubernetes':\n"
" return super().teardown()\n"
" if not self.operation.keep_temporary_files:\n"
" # Remove directories used for storing intermediate job results"
)
start = src.find("class ToilRunner(CWLRunner):")
end = src.find("class CWLToolRunner(CWLRunner):")
if start == -1 or end == -1:
print(f"WARNING: ToilRunner class bounds not found in {p} — skipping", file=sys.stderr)
sys.exit(0)
before, body, after = src[:start], src[start:end], src[end:]
if old not in body:
print(f"ERROR: ToilRunner teardown patch target not found in {p}", file=sys.stderr)
sys.exit(1)
body = body.replace(old, new, 1)
patched = before + body + after
if MARKER not in patched:
print(f"ERROR: ToilRunner teardown marker missing after patching {p}", file=sys.stderr)
sys.exit(1)
p.write_text(patched)
print(f"Patched {p}", file=sys.stderr)
PYEOF
# ── Clean up any stale pipeline state from failed image_* runs ──────────────
# toil job stores and CWL pipelines can get into a stuck state if a previous run failed
# Remove the entire image_N pipeline folder so rapthor regenerates with fresh config
for i in 0 1 2 3 4; do
pipeline_dir="${WORK_DIR}/pipelines/image_${i}"
log_dir="${WORK_DIR}/logs/image_${i}"
if [ -d "${pipeline_dir}/jobstore" ]; then
echo "Removing stale pipeline: ${pipeline_dir}"
rm -rf "${pipeline_dir}"
rm -rf "${log_dir}"
fi
done
# ── Run Rapthor ──────────────────────────────────────────────────────────────
# Do not append Rapthor's console logger to rapthor.log: it already writes the
# same records to that file, which otherwise produces duplicate lines.
exec 1>&3 2>&4
rapthor "${PARSET}"
if [ "${BENCHMON_DRAIN_S}" -gt 0 ]; then
echo "Keeping pilot alive for ${BENCHMON_DRAIN_S}s so BenchMon can flush..."
sleep "${BENCHMON_DRAIN_S}"
fi
echo "Done."
#!/bin/bash
# Build the storm3 storage-area tree on the OKE pilot-shared-storage PVC.
#
# The measurement set already present on the PVC (staged for the previous OKE
# demo) is byte-identical to the Rucio-catalogued copy: 81 files, same sizes.
# Everything below is hardlinked from it, so the deterministic RSE tree and the
# materialized copy cost no additional space and are created instantly.
set -euo pipefail
SCOPE=SKA-Low.integration
EB=eb-tg1-41-rapthor-mildtec-demo
DSID="${DSID:?DSID must be set}"
DATASET="${EB}.${DSID}"
MSNAME=ska-low-aa2-mildtec-small.ms
SKYMODEL=lotss_p207+24_negdec.skymodel
SRC_MS="/srv/storage/storm2/sa/rapthor-mildtec/${MSNAME}"
SRC_SKY="/srv/storage/storm2/sa/rapthor-mildtec/${SKYMODEL}"
# Identity LFN2PFN: PFN = <prefix>/<scope>/<file-did-name>, and the file DID
# names produced by the ingest embed a doubled MS path component.
DET_ROOT="/srv/storage/storm3/sa/deterministic/${SCOPE}/${DATASET}/${MSNAME}"
DET_MS="${DET_ROOT}/${MSNAME}"
MAT_DIR="/srv/storage/storm3/sa/rapthor-mildtec"
MAT_MS="${MAT_DIR}/${MSNAME}"
[ -d "$SRC_MS" ] || { echo "FATAL: source MS missing: $SRC_MS" >&2; exit 1; }
[ -f "$SRC_SKY" ] || { echo "FATAL: source skymodel missing: $SRC_SKY" >&2; exit 1; }
echo "== dataset: ${SCOPE}:${DATASET}"
# cp -al nests if the destination already exists, so clear it first.
rm -rf "$DET_ROOT" "$MAT_MS"
mkdir -p "$DET_ROOT" "$MAT_DIR" /srv/storage/pilot-scratch-3/outputs
echo "== hardlinking deterministic RSE tree"
cp -al "$SRC_MS" "$DET_MS"
echo "== hardlinking materialized copy"
cp -al "$SRC_MS" "$MAT_MS"
echo "== skymodel + DID marker"
cp -f "$SRC_SKY" "${MAT_DIR}/${SKYMODEL}"
printf '%s:%s\n' "$SCOPE" "$DATASET" > "${MAT_MS}.rucio_dataset_did"
# The scratch area must be group-writable: pilots stage out as a non-root uid.
chmod 2777 /srv/storage/pilot-scratch-3 /srv/storage/pilot-scratch-3/outputs
echo "== verification"
echo "deterministic files: $(find "$DET_MS" -type f | wc -l) (expect 81)"
echo "materialized files: $(find "$MAT_MS" -type f | wc -l) (expect 81)"
echo "link count on the 27GB table (expect >=3): $(stat -c %h "${DET_MS}/table.f5_TSM0")"
echo "marker: $(cat "${MAT_MS}.rucio_dataset_did")"
echo "apparent size: $(du -sh --apparent-size "$DET_MS" | cut -f1); physical added: $(du -sh /srv/storage/storm3 | cut -f1)"
#!/usr/bin/env python3
"""Emit one JSON record per file in the storm3 deterministic MS tree.
Rucio needs (name, bytes, adler32) for add_replicas. The names are the identity
LFN2PFN file DIDs, i.e. exactly the path under the scope directory.
"""
import json
import os
import sys
import zlib
SCOPE = "SKA-Low.integration"
EB = "eb-tg1-41-rapthor-mildtec-demo"
MSNAME = "ska-low-aa2-mildtec-small.ms"
CHUNK = 16 << 20
dsid = os.environ["DSID"]
dataset = f"{EB}.{dsid}"
det_ms = f"/srv/storage/storm3/sa/deterministic/{SCOPE}/{dataset}/{MSNAME}/{MSNAME}"
records = []
for dirpath, _dirnames, filenames in os.walk(det_ms):
for fn in sorted(filenames):
path = os.path.join(dirpath, fn)
rel = os.path.relpath(path, det_ms)
checksum = 1
size = 0
with open(path, "rb") as fh:
while True:
block = fh.read(CHUNK)
if not block:
break
size += len(block)
checksum = zlib.adler32(block, checksum)
records.append(
{
"scope": SCOPE,
"name": f"{dataset}/{MSNAME}/{MSNAME}/{rel}",
"bytes": size,
"adler32": format(checksum & 0xFFFFFFFF, "08x"),
}
)
print(f" {rel}: {size} bytes", file=sys.stderr, flush=True)
json.dump({"scope": SCOPE, "dataset": dataset, "files": records}, sys.stdout, indent=1)
print(f"\nDONE {len(records)} files", file=sys.stderr)
#!/usr/bin/env python3
"""Register the OKE storage area as a Rucio RSE and catalogue the staged MS.
The measurement set was placed on the OKE CephFS PVC by build-storm3.sh and
checksummed by checksum-storm3.py; every file's adler32 was verified to match
the local NODE1_SITE1_STORM1 catalogue byte for byte, so this registers a
genuine replica of the same data at the OKE site.
The RSE deliberately does NOT get ``rse_healthy=True``: the ingestor's
post-upload rule uses ``rse_expression="rse_healthy=True"`` and would otherwise
start scheduling transfers to OKE for every future ingest.
"""
import json
import sys
from rucio.client import Client
from rucio.common.exception import Duplicate, RucioException
RSE = "OKE_SITE1_STORM3"
HOST = "storm-webdav-dev.aussrc.org"
PREFIX = "/sa/deterministic/"
with open("/tmp/storm3-replicas.json", encoding="utf-8") as fh:
payload = json.load(fh)
scope = payload["scope"]
dataset = payload["dataset"]
files = payload["files"]
c = Client()
# --- RSE -------------------------------------------------------------------
try:
c.add_rse(RSE)
print(f"[rse] created {RSE}")
except Duplicate:
print(f"[rse] {RSE} already exists")
for key, value in [("istape", "False"), ("oidc_support", "True"), ("site", "OKE_SITE1")]:
try:
c.add_rse_attribute(RSE, key, value)
except Duplicate:
pass
print(f"[rse] attributes set (no rse_healthy, no greedyDeletion by design)")
try:
c.add_protocol(
RSE,
{
"scheme": "davs",
"hostname": HOST,
"port": 443,
"prefix": PREFIX,
"impl": "rucio.rse.protocols.gfal.Default",
"domains": {
"lan": {"read": 1, "write": 1, "delete": 1},
"wan": {
"read": 1,
"write": 1,
"delete": 1,
"third_party_copy_read": 1,
"third_party_copy_write": 1,
},
},
},
)
print(f"[rse] davs protocol -> {HOST}:443{PREFIX}")
except Duplicate:
print("[rse] protocol already present")
try:
c.set_local_account_limit("root", RSE, -1)
except RucioException as exc:
print(f"[rse] WARNING account limit: {exc}")
# --- replicas --------------------------------------------------------------
replicas = [
{"scope": f["scope"], "name": f["name"], "bytes": f["bytes"], "adler32": f["adler32"]}
for f in files
]
total = sum(r["bytes"] for r in replicas)
c.add_replicas(rse=RSE, files=replicas)
print(f"[replicas] registered {len(replicas)} files ({total/2**30:.1f} GiB) on {RSE}")
# --- dataset ---------------------------------------------------------------
try:
c.add_did(scope, dataset, "DATASET")
print(f"[did] dataset {scope}:{dataset} created")
except Duplicate:
print(f"[did] dataset {scope}:{dataset} already exists")
try:
c.attach_dids(
scope, dataset, [{"scope": r["scope"], "name": r["name"]} for r in replicas]
)
print(f"[did] attached {len(replicas)} files to the dataset")
except Duplicate:
print("[did] files already attached")
# --- rule: pin the replicas so the reaper never tombstones them -------------
existing = [
r
for r in c.list_did_rules(scope, dataset)
if r["rse_expression"] == RSE
]
if existing:
print(f"[rule] already pinned by rule {existing[0]['id']}")
else:
rule_ids = c.add_replication_rule(
dids=[{"scope": scope, "name": dataset}],
copies=1,
rse_expression=RSE,
grouping="DATASET",
account="root",
lifetime=None,
locked=False,
comment="OKE demo: pin the staged rapthor MS replica",
)
print(f"[rule] created {rule_ids}")
print(f"\nOK {scope}:{dataset} -> {RSE}")
sys.exit(0)
#!/bin/bash
# Remove the rapthor mildtec MS from site1 (NODE1_SITE1 / storm1), so the only
# replica of the demo measurement set is the one at OKE and the DID dropdown
# offers exactly one choice.
#
# RESTORE: `stack deploy broker` puts it all back, because
# * include/env/dev/toolkit.mk:stage-swf21-mildtec-fixtures re-fetches the MS
# when <MS>/table.dat is missing (from Pawsey; see FAST RESTORE below), and
# * scripts/swf21-mildtec/deploy-swf21-mildtec.sh re-runs the whole Rucio
# round-trip when the .rucio_dataset_did marker is missing -- which is why
# the marker is deleted here too, not just the data.
#
# The Rucio-side handoff is safe: materialize-...py's candidate loop treats a
# dataset with no locally-visible replica as SystemExit and moves on to the
# next; when every candidate fails it exits 3 (NOT_READY), which is exactly the
# "go and re-ingest" signal deploy-swf21-mildtec.sh waits for. The OKE dataset
# is skipped by that same rule (its PFN host maps to /srv/storage/storm-webdav-dev,
# which does not exist locally), so it cannot hijack the restore.
#
# FAST RESTORE (avoids re-downloading ~25GB from Pawsey) -- the pristine
# archive is already on this host:
# make ... SWF21_MS_ARCHIVE_URL=file:///home/ubuntu/src-workloads/tasks/rapthor/data/SP-5941_AA2_25G.zip
set -euo pipefail
VOL=/home/ubuntu/ska-src-api-deployment-stack/out/deploy-ubuntu/volumes
SA="${VOL}/storm1/sa"
MS=ska-low-aa2-mildtec-small.ms
EB=eb-tg1-41-rapthor-mildtec-demo
echo "== before"; df -h /home/ubuntu | tail -1
# 1. The Rucio replica trees. These directory names ARE the dataset DIDs, and
# listing them is what puts the local DIDs in the notebook dropdown.
echo "== removing identity-LFN2PFN replica trees under storm1"
rm -rf "${SA}/deterministic/SKA-Low.integration/${EB}."*
# 2. The materialized copy. Note this is the SAME path the raw Pawsey fixture
# is staged to (SWF21_MS_DEST == MATERIALIZED_DIR), so this one delete
# removes both roles.
echo "== removing materialized/staged MS"
rm -rf "${SA}/rapthor-mildtec/${MS}"
# 3. The provenance marker -- without this deleted, the ingest hook short
# circuits ("already materialized ... skipping Rucio ingest entirely") and
# would NOT restore anything.
echo "== removing provenance marker"
rm -f "${SA}/rapthor-mildtec/${MS}.rucio_dataset_did"
# 4. Ingest staging copy (regenerated by stage_and_inject on re-ingest).
echo "== removing ingest staging copy"
rm -rf "${SA}/ingest/staging/SKA-Low.integration/${MS}"
# Deliberately KEPT: rapthor-htcondor.sif (shared inode, still needed),
# lotss_p207+24_negdec.skymodel (the hook re-copies it unconditionally anyway),
# the SKA-Mid.integration and testing scopes, and ingest/failed_processing_data.
echo "== after"; df -h /home/ubuntu | tail -1
echo
echo "== storm1 rapthor-mildtec now holds:"; ls -la "${SA}/rapthor-mildtec/"
echo "== storm1 deterministic SKA-Low.integration now holds:"
ls -A "${SA}/deterministic/SKA-Low.integration/" 2>/dev/null || echo " (empty)"
"""One-line status of the most recent swf22 run. Poll with: watch -n5 ..."""
from common_lib import resolve_broker_client
c, _ = resolve_broker_client()
jobs = [j for j in c.list_jobs().json().get("jobs", [])
if j["job_id"].startswith("broker-marimo-swf22")]
if jobs:
j = max(jobs, key=lambda x: x.get("created_at") or "")
print(f"{j['job_id']} {j['state']} site={j.get('winner_site_id') or '-'} "
f"attempts={j.get('dispatch_attempts')} {j.get('last_error') or ''}")
#!/bin/bash
# One-screen dashboard for a live swf22 run. Put it beside the notebook:
# watch -n 5 bash out/oke-demo/scripts/watch-demo.sh
#
# Everything here is read-only and safe to run repeatedly during the demo.
# Deliberately NOT included: `kubectl top` — this cluster has no metrics-server
# ("Metrics API not available"), so node CPU comes from /proc/loadavg instead.
export PATH="/home/ubuntu/bin:$PATH"
NS=job-gateway-oke
K3D="docker exec k3d-deploy-ubuntu-server-0 kubectl"
echo "──── broker ────────────────────────────────────────────────"
$K3D -n broker exec demo-runner -- bash -lc \
'cd /home/jovyan/work/demo/ska-src-ef-computing-broker && python3 /tmp/watch-broker.py' 2>/dev/null
echo
echo "──── placement (proof it is on OKE) ────────────────────────"
$K3D -n htcondor exec deploy/htcondor-cm -c cm -- \
condor_status -af Name PILOT_SITE State Activity 2>/dev/null | grep -iE 'oke|NODE[12]' || echo " (no pilots)"
echo
echo "──── stage-in (the ~4.5 min talking window) ────────────────"
kubectl -n "$NS" exec dataprep -- sh -c '
D=$(ls -dt /srv/storage/pilot-scratch/*/*/work/rapthor-input 2>/dev/null | head -1)
[ -n "$D" ] || { echo " (not started)"; exit 0; }
echo " $(du -sh "$D" 2>/dev/null | cut -f1) of 26G job=$(basename $(dirname $(dirname "$D")))"
' 2>/dev/null
echo
echo "──── payload stdout (STAGING → rapthor) ────────────────────"
$K3D -n htcondor exec deploy/htcondor-submit -c submit -- bash -lc '
R=$(ls -dt /srv/storage/.ska-leader/runs/broker-marimo-swf22-*-a1 2>/dev/null | head -1)
[ -n "$R" ] && tail -4 "$R/tasks/task-0.out" 2>/dev/null | sed "s/\x1b\[[0-9;]*m//g" | cut -c1-110
' 2>/dev/null
echo
echo "──── rapthor stages (same source the notebook tails) ───────"
kubectl -n "$NS" exec dataprep -- sh -c '
# Scope to THIS run only. A previous run leaves its rapthor.log behind, and
# during the staging window (when this run has none yet) the newest-on-disk
# log is the previous one — which reads as "the demo just failed".
J=$(ls -dt /srv/storage/pilot-scratch/*/*/ 2>/dev/null | head -1)
[ -n "$J" ] || { echo " (no job dir yet)"; exit 0; }
L=$(find "$J" -name rapthor.log 2>/dev/null | head -1)
[ -n "$L" ] || { echo " (still staging the MS — rapthor has not started)"; exit 0; }
grep -a Operation "$L" | sed "s/\x1b\[[0-9;]*m//g" | sed "s/ - INFO - rapthor:[^ ]* / /" | tail -4
' 2>/dev/null
echo
echo "──── OKE node load (no metrics-server; 36 cores/node) ──────"
for p in $(kubectl -n "$NS" get pods --no-headers 2>/dev/null | grep 'pilot-dynamic.*1/1' | awk '{print $1}'); do
N=$(kubectl -n "$NS" get pod "$p" -o jsonpath='{.spec.nodeName}' 2>/dev/null)
L=$(kubectl -n "$NS" exec "$p" -c startd -- cat /proc/loadavg 2>/dev/null | cut -d' ' -f1-3)
echo " $N load: $L"
done
echo
echo "──── OKE pods ──────────────────────────────────────────────"
kubectl -n "$NS" get pods --no-headers 2>/dev/null | grep -vE 'dataprep|Completed' | awk '{printf " %-38s %-9s %s\n", $1, $3, $5}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment