Campaign & Pipeline YAML
A campaigns git repo has three things the converter reads:
converter.yaml (cluster-wide defaults), campaigns/*.yaml (what to run)
and pipelines/*.yaml (how to run it). The filename stem is the campaign /
pipeline id. htrflow-campaigns init <dir> writes a repo in exactly this
shape — see Run a Campaign. The commands
that check, render and apply a repo are in htrflow-campaigns CLI.
Source: packages/converter/src/htrflow_converter/parse.py,
models.py.
converter.yaml
Cluster-wide defaults for everything the converter renders. Unknown keys are
rejected. Every field is optional; the values below are the defaults, except
source_template, which is shown as a placeholder. It has no default: set it
to your IIIF source before a campaign lists a volume as a bare reference code
(- R0001203). Without it, only manifest: and images: volumes are allowed.
In all three kinds of file, a key written twice in one mapping is a validation error naming both lines (YAML itself would silently keep the last).
namespace: htr-batch # Kubernetes namespace campaigns render into
queue: htr-batch # Kueue LocalQueue name
window: 20 # Job parallelism, and the CAP a campaign's own `window:` is clamped to
priority_classes: [htr-interactive, htr-bulk, htr-idle] # the WorkloadPriorityClass names the chart ships; what a campaign's `priority:` may name
s3_secret: htr-batch-s3 # Secret carrying S3 credentials
hf_token_secret: "" # optional: Secret with a `token` key, read by the warm-up for a private or gated Hub model
data_pvc: htr-test-data # PVC mounted as the model cache
runtime_class: nvidia # RuntimeClass for GPU pods — on the warm-up Job too
node_selector: {}
tolerations: [] # Kubernetes tolerations, each naming its taint's key (see below)
results_url: "https://<web front host>/results" # required: the chart's resultsUrl, where browsers read results through the results proxy; must end in /results
source_template: "https://<iiif-host>/<path>/{ref}/manifest" # no default; manifest URL for a bare volume id, {ref} is the id
max_seconds: 21600 # each pod's activeDeadlineSeconds; a pipeline's own `max_seconds:` overrides it
warmup_wait_seconds: 900 # how long a pod waits for its pipeline's warm-up marker before failing the index; capped by that pod's own deadline
ttl_seconds_after_finished: 604800 # a week: how long a finished campaign's Job stays before Kubernetes deletes it; a pipeline's own `ttl_seconds_after_finished:` overrides it
manifest_max_bytes: 16777216 # 16 MiB
fetch_max_bytes: 67108864 # 64 MiB
image_cache: null # off by default; { bucket: <name> } caches source images in that S3 bucket
flavors: [] # the chart's queue.flavors, by name and nodeLabels (see Pod sizes)
sizes: {} # named pod sizes a pipeline's `size:` picks (see Pod sizes)
default_size: null # the size a pipeline with no `size:` runs at (see Pod sizes)
The file itself is required: every command refuses a repo without one,
rather than guess the namespace it applies and prunes in. So is
results_url, and it must be an http(s) URL a browser can open:
every campaign pod gets it, and without it every volume fails once the pod
has been admitted and has waited for its warm-up.
queue, s3_secret, data_pvc, hf_token_secret and
priority_classes name objects the chart creates or allows;
Configuration shows which chart value each must agree
with. priority_classes is checked here because the cluster will not check
it: Kueue leaves a Job naming a missing class "Queued" for ever
(Queueing). An empty list refuses every
priority:.
tolerations are copied into every warm-up and campaign pod, spelt the
Kubernetes way (key, operator, value, effect, tolerationSeconds).
Each must name its taint's key: a keyless toleration tolerates every
taint. A toleration for the control plane's taint
(node-role.kubernetes.io/control-plane or …/master) is refused too. On a
single-node cluster whose node is the control plane, take the taint off the
node instead:
kubectl taint nodes <node> node-role.kubernetes.io/control-plane:NoSchedule-.
s3_secret, data_pvc and hf_token_secret are checked for shape only.
Which Secrets and PVCs a pod may mount is the chart's admission policy.
hf_token_secret is needed only for a private or gated Hugging Face
model. It names a Secret you create, with a token key holding a
read-scope Hub token; only the warm-up Job gets it, as HF_TOKEN
(Deploy).
image_cache is off by default, and absent renders every campaign pod
exactly as before it existed. Set bucket to cache source page images in an
S3 bucket on the same store as the results, so a volume run again needs
nothing from the IIIF server; the bucket is private (never in a public
policy, never linked) and must exist before a campaign runs
(Deploy). Setting it renders every campaign
pod's IMAGE_CACHE_BUCKET. See S3 Layout
for the key layout and what a hit or a miss does.
Pod sizes
A pipeline asks for a pod size by name, and converter.yaml says what each
name means. A pipeline with no size: runs at default_size. With that
unset too, its campaign pods ask for 4 CPU, 8 GiB of memory with a 16 GiB
limit, 1 GPU and a 2 GiB /work, on the first flavor with room.
flavors: # the chart's queue.flavors, names and nodeLabels as there
- name: small-gpu
nodeLabels: { nvidia.com/gpu.product: <product label of the small card> }
- name: large-gpu
nodeLabels: { nvidia.com/gpu.product: <product label of the large card> }
sizes:
small: { flavor: small-gpu, gpu: 1, cpu: 4, memory: 16Gi }
large: { flavor: large-gpu, gpu: 1, cpu: 8, memory: 32Gi, workdir: 4Gi }
default_size: small # pipelines without a size: the small card, not the first with room
| Key | Default | What it does |
|---|---|---|
flavor |
none | A flavors entry. Its nodeLabels become the pod's nodeSelector, which keeps Kueue off the other flavors (see the rule below). None: the first flavor with room |
gpu |
1 |
nvidia.com/gpu, 1 or more |
cpu |
required | Whole cores (8) or millicores (500m) |
memory |
required | Whole bytes, or with Ki, Mi, Gi, Ti (or k, M, G, T). Must leave at least 1Gi beside workdir: the in-memory /work counts against it, and the process gets the rest. The 1Gi is a floor against slips, not a sizing: a model wants several |
workdir |
2Gi |
The size of the memory-backed /work, at least 512Mi: it holds HOME, TMPDIR and the wrapper's page lookahead (LOOKAHEAD_BYTES), which is half of it |
Request and limit are the same number. A pod using more memory than it
requested is the first the kubelet evicts under pressure, and the pages
in /work would go with it.
Kueue has no way for a Job to ask for a flavor by name. It tries the
ClusterQueue's flavors in order and skips any whose node labels contradict
the pod's node selector, comparing only the keys that flavor names. So a
size's flavor is rendered as that flavor's labels, and every two flavors
must name a label key in common, with different values: give each the
same key (nvidia.com/gpu.product, say) with a value of its own
(Several sorts of GPU).
That is why flavors repeats the chart's queue.flavors, names and labels
alike, as priority_classes repeats its classes. Order does not matter
here; in the chart it does.
validate refuses flavors that break the rule, a size that names a flavor
not in flavors, a flavor whose labels contradict node_selector, a
default_size that is not a size, and a pipeline naming a size not in
sizes:
pipelines/demo-v1.yaml: size "huge" is not one of converter.yaml's sizes (small, large) — name one of them, or leave size out for the default
validate cannot see the cluster. apply can: before it sends a campaign
whose size names a flavor, it reads the ClusterQueue's flavors and holds the
campaign back when they are not converter.yaml's
(Several sorts of GPU).
Setting or changing default_size changes the size of every pipeline
without one, and a running campaign's size cannot change: validate refuses
it like any other change to those pipelines.
What neither can see is the chart's quota. A size that asks for more than every flavor it may land on has quota for is never admitted, and reads "Queued" for ever: give each flavor a quota of at least one pod of the largest size that can land on it.
The image allow-list and the model-revision rule are cluster policy
They are Kyverno policies the chart ships (security.allowedImageRepos,
security.requireModelRevision), not converter settings, because
admission sees everything the namespace admits. A converter.yaml that
carries allowed_image_repos or require_model_revision is a
validation error. A campaigns repo's CI runs the same policies over
rendered/ with the Kyverno CLI. See
Security.
Campaign file — campaigns/<name>.yaml
pipeline: demo-v1 # required: a pipeline id from pipelines/
priority: "" # optional: one of converter.yaml's priority_classes (htr-interactive, htr-bulk, htr-idle);
# orders the queue, never evicts a running campaign; empty is htr-bulk
window: 20 # optional: this campaign's parallelism, clamped to converter.yaml's window
suspend: false # optional: true pauses this campaign (see the CLI reference, "Pausing")
volumes:
# 1) Bare string: a reference code at your IIIF source. The manifest URL
# is templated from converter.yaml's source_template, which must be set.
- R0001203
# 2) Explicit IIIF manifest (Presentation v2 or v3), http(s) only:
- id: loc-mal2459400
manifest: https://www.loc.gov/item/mal2459400/manifest.json
# 3) Bare image URLs (http(s) only) — the wrapper builds and publishes
# a synthetic IIIF manifest for them itself (see S3 Layout: sources/):
- id: htr-demo-examples
images:
- https://example.org/page1.jpg
- https://example.org/page2.jpg
Source URLs are not secrets
Every manifest: and images: URL is stored verbatim: in git, in
rendered/, in the campaign's ConfigMap and in each volume's
manifest.json. A presigned URL publishes its signature to everyone who
can read any of those. See
Source URLs are not secrets.
Rules enforced by parse_campaign (validate, and by render):
| Rule | When violated |
|---|---|
pipeline: is required and names a file in pipelines/ |
Validation error; nothing renders |
| No unknown key on the campaign or on a volume | Validation error naming the key. A misspelt suspended: true would otherwise run the campaign unpaused |
| At least one volume | Validation error. A Job with completions: 0 is Succeeded the moment it is created |
Every volume is a bare string, or has manifest: or a non-empty images: |
Validation error |
manifest: and every images: entry are absolute http(s) URLs |
Validation error (must be an http(s) URL) |
| …that a browser can open: no backslash or control character, a valid port, and a host that is a DNS name (IDN allowed), a dotted IPv4 or a bracketed IPv6 address | Validation error naming the volume. The viewer and the status page build links with the browser's URL parser, which is stricter than Python's |
| No whitespace inside a URL | Validation error; percent-encode a space as %20. Whitespace separates images: URLs in volumes.txt; a comma is fine |
An images: volume's volumes.txt line is at most 100 KiB |
Validation error. The Job exports that line as one env entry, and Linux caps one entry at 128 KiB. Split the volume, or give it a IIIF manifest |
Volume ids match [A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])? |
Validation error (unsafe volume id). This is the label-value alphabet, so uppercase is allowed |
An id YAML would read as something else (0012345 octal, 1:20 base 60, 1.10 a number, yes, 2024-01-31 a date) is quoted |
Validation error saying what YAML read |
| Volume ids are unique within a campaign | Validation error (duplicate volume id) |
priority:, when set, is one of converter.yaml's priority_classes |
Validation error naming the classes. Leaving it out is htr-bulk; a higher class goes ahead of waiting campaigns but never evicts a running one (Queueing) |
window:, when set, is a positive integer |
Validation error |
window: above converter.yaml's window |
Clamped to it at render time. Set that cap to what the ClusterQueue's quota can admit (Queueing → The window) |
| A window change that would change a rendered, unpaused campaign's pod count | A warning:; apply refuses it for a running campaign (CLI) |
suspend: true |
Renders spec.suspend: true (CLI → Pausing) |
| A rendered campaign's volume list is unchanged | campaign <name> is append-only: create a new campaign, non-zero exit. completions cannot change on a Job |
| A pipeline a rendered campaign still runs keeps its image and steps | pipeline <id> changed (…) but campaigns …, non-zero exit (Immutability) |
The file stem is a DNS-1123 label (lower-case, digits, -, no dots, ≤63), does not start with htr-warmup-, and does not end in -part<number> or -status |
Validation error. Pod hostnames must be DNS labels; the other names belong to warm-up Jobs, split parts and status records |
The last pod name, <job>-<completions − 1>, is at most 63 characters |
campaign <name> cannot be applied: its last pod would be …. A 61-character name takes at most 10 volumes |
More than 10 000 volumes (so an index has at most four digits, which every pod name has room for), or more than 900 KiB of volumes.txt |
Split into <name>-part1, -part2, …, one Job and ConfigMap each; the stem is cut to 50 characters. The API server refuses a ConfigMap over 1 MiB |
| Two campaigns that would split onto the same shortened stem | Refused, since their part files would collide |
Checks that need the live cluster (the campaign's live ConfigMap, a
running campaign's window, two campaigns sharing a volume) are made by
apply (CLI → Checks).
A rule added later does not reach a campaign already rendered. A
campaign whose volume list is exactly what the committed rendered/
recorded for it keeps that rendering, even where a rule added since would
refuse it: a volume id written unquoted that YAML reads as a number, a
source URL a browser cannot open, or a bare volume id with no
source_template set (it keeps the manifest URL it was rendered with). Its
list is append-only, so it could never be brought to pass. validate and
render print a warning: line instead, which says the id the volume was
rendered under (write it quoted, as that id), what the browser would refuse,
or that source_template should be set to the template the bare ids were
rendered with. A new campaign, or
a rendered one whose list changes, is held to every rule.
The campaign file stem becomes the value of the converter's campaign label
and, for a campaign that does not split, the Job name; a campaign that splits
is <stem cut to 50 characters>-partN instead, one Job per part. The exact
label keys are in the skeletons under
manifests/.
Pipeline file — pipelines/<id>.yaml
# Image MUST be digest-pinned — a mutable tag would let results published
# under one id be produced by different code over time.
image: <registry>/htrflow-batch@sha256:<64 hex digits>
max_seconds: 3600 # optional: this recipe's per-volume wall-clock
# budget (the pod's activeDeadlineSeconds),
# overriding converter.yaml's
ttl_seconds_after_finished: 1209600 # optional: how long THIS pipeline's
# finished campaign Jobs stay,
# overriding converter.yaml's
size: large # optional: one of converter.yaml's sizes; none is
# 4 CPU, 8Gi (16Gi limit), 1 GPU, 2Gi /work
steps: # htrflow pipeline steps, passed through verbatim
- step: Segmentation
settings:
model: yolo
model_settings:
model: Riksarkivet/yolov9-regions-1
revision: 0123456789abcdef0123456789abcdef01234567 # YOLO: top-level,
# required when the
# cluster sets
# requireModelRevision
- step: Segmentation # the lines within each region: a reader needs
settings: # regions, then lines (see the rules below)
model: yolo
model_settings:
model: Riksarkivet/yolov9-lines-within-regions-1
revision: …
- step: TextRecognition
settings:
model: TrOCR
model_settings:
model: Riksarkivet/trocr-base-handwritten-hist-swe-2
model_kwargs:
revision: … # Hugging Face models (TrOCR, Donut, DiT):
# under model_kwargs, forwarded to
# from_pretrained -- NOT top-level
processor_kwargs:
revision: … # TrOCR, WordLevelTrOCR, Donut, DiT: the
# processor is a second download, pinned
# on its own
Rules enforced by parse_pipeline — a broken pipeline is reported as a
validation error and blocks rendering for every campaign that uses it:
| Rule | Why |
|---|---|
Pipeline id is lowercase, [a-z0-9.-] inside, alphanumeric at both ends, ≤52 chars |
It becomes htr-pipeline-<id> and htr-warmup-<id>, and a Job name is also a 63-character label value |
image: matches <repository>@sha256:<64 hex> |
The digest is the provenance recorded in every manifest.json and ALTO |
max_seconds:, when set, is a positive integer |
It becomes each pod's activeDeadlineSeconds for this pipeline's campaigns. A budget a volume cannot meet costs retries before the index fails |
ttl_seconds_after_finished:, when set, is a positive integer |
It becomes the campaign Job's ttlSecondsAfterFinished: how long completedIndexes stays readable with kubectl. The campaign's record has no TTL |
size:, when set, is one of converter.yaml's sizes |
It becomes the wrapper's requests and limits, its /work and its flavor's node selector. Kueue would queue a pod it cannot place for ever |
steps: is a non-empty list, each entry - step: <Name>, with no Export step |
Otherwise every volume fails in the wrapper. The wrapper appends the Export steps itself |
A step that loads a model has only model, model_settings and generation_settings under settings |
htrflow merges any other key over model_settings, so revision: null beside it would unpin the model |
A line reader (model: TrOCR or PyLaia) has at least two segmentation steps (model: yolo or PPDocLayoutV3) before it: regions, then the lines within them |
ALTO and PAGE XML hold the text of a line only inside a region. Lines straight on the page are exported without their text (see below) |
No unknown key (model_revision: included; the pin lives in steps) |
A stray key is a typo or a leftover |
Regions, then lines
Every model step runs on the smallest pieces the page has so far, whatever the step is called. A segmentation step splits each piece into the regions it finds, one level down. A line reader puts its text on the piece it reads. htrflow's ALTO and PAGE export writes a text line only for a line inside a region: page, region, line. A reader with one segmentation step before it reads lines that sit directly on the page. The export then holds none of their text: every page looks done and is empty. What the model detects makes no difference. A lines model that runs straight on the page still puts its lines one level below the page, not two.
So validate refuses that shape, and names the fix: a region segmentation
step (for example Riksarkivet/yolov9-regions-1) before the line step (for
example Riksarkivet/yolov9-lines-within-regions-1), as in the example
above. It also refuses a reader with no segmentation step before it.
WordLevelTrOCR is not held to the rule: the export writes its words.
A pipeline already in rendered/ with exactly these steps and image is not
refused, since its id names that recipe for good (see
Immutability). validate warns instead; new campaigns
should use a new pipeline file with the region step.
The wrapper checks the result as well, for shapes this rule cannot see. A page whose ALTO or PAGE XML holds none of the text htrflow recognized for it fails. If every page of a volume fails that way, the volume fails permanently (see the wrapper's stages).
Predicted page quality
- step: QualityPrediction
settings:
model_settings:
model: <org>/<qp-model-repo> # Hub repo id
revision: <40-hex commit sha>
model_file: <name>.joblib
bin_config_file: <name>.json
feature_groups: [segmentation, layout, htr_confidence, text, regionization] # optional
A QualityPrediction step scores each page's transcription, so it is
refused wherever it sits before the pipeline's TextRecognition step, and
refused the same way when the pipeline has no TextRecognition step at
all. Its model is a pickle, so model_settings.model must be a Hugging
Face Hub repo id, never a local path, and model_settings.revision must be
the model's 40-hex commit sha. model_settings.model_file and
model_settings.bin_config_file must each be a plain file name inside
that repo — no path separator, and never . or ... The optional
feature_groups list may only name groups this image can compute from the
page tree alone (segmentation, layout, htr_confidence, text,
regionization); a group that needs the page image, a DiT model or a
language model this image does not run is refused by name, the same as a
group that does not exist at all. A pipeline may carry at most one
QualityPrediction step. Its settings are
part of the recipe like every other step's: changing them changes
recipe_sha256, so a pinned pipeline is never rewritten in place.
Two more rules are the cluster's, enforced by Kyverno at admission and by the Kyverno CLI in the campaigns repo's CI:
- The image's repository is one
security.allowedImageReposnames. - With
security.requireModelRevision, the model-revision policy holds every pipeline ConfigMap to these rules:
| Rule | Refused as |
|---|---|
Every model_settings.model has a 40-hex revision: where its loader reads it: model_settings.revision for YOLO, model_settings.model_kwargs.revision for Hugging Face models (TrOCR, Donut, DiT) |
models not pinned to a revision: <models> — add revision: <40-character commit hash> under model_settings (YOLO) or model_settings.model_kwargs … |
TrOCR, WordLevelTrOCR, Donut and DiT also pin model_settings.processor_kwargs.revision: the processor is a second download |
processors not pinned to a revision: <models> — … |
No key beside model_settings in a step that loads a model |
settings beside model_settings in a step that loads a model: … — move them under model_settings |
The pipeline is under data, never binaryData |
a pipeline may not be carried in binaryData, where the revision rule cannot read it … |
Either revision placement satisfies the policy, but a model reads only the one its own loader expects, so a pin in the wrong place still fails to load.
Only top-level steps: are walked. The wrapper holds the same three paths
when it loads a pipeline, and fails the volume permanently when a key
beside model_settings would load another revision than the one pinned.
When something is wrong
validate and render print one line per problem, then a count, and
render nothing if there is any problem. Both hold a repo to the same rules,
so a pull request validate passes is one render on main takes. Every
line is path/to/file.yaml: <what is wrong> — <what to write instead>.
campaigns/broken.yaml: volume 1 ("a/b") has an id with characters that are not allowed — use only letters, digits, ".", "_" and "-", at most 63 of them
campaigns/broken.yaml: volume "R1" is listed twice — remove the duplicate
2 problems in 1 file — nothing was rendered
| What you wrote | What you are told |
|---|---|
An id: with /, a space or 64+ characters |
volume 3 ("a/b") has an id with characters that are not allowed — use only letters, digits, ".", "_" and "-", at most 63 of them |
| The same id twice in one campaign | volume "R1" is listed twice — remove the duplicate |
A volume with neither manifest: nor images: (or with both) |
volume 3 needs exactly one source — give it manifest: <IIIF manifest URL>, or images: <list of image URLs> |
A manifest: that is not an http(s) URL |
volume 3 has a manifest that is not an http(s) URL ("javascript:alert(1)") — write the whole URL, starting with https:// |
A list entry that is neither a bare id nor a mapping with id: |
volume 3 has no id — write the entry as "- R1", or as "- id: R1" with manifest: or images: |
pipeline: naming a file that is not in pipelines/ |
pipeline "kyrk-v3" has no file in pipelines/ — add pipelines/kyrk-v3.yaml, or point pipeline: at one that is there |
priority: naming a class converter.yaml does not list |
priority "htr-urgent" is not one of the cluster's classes (htr-interactive, htr-bulk, htr-idle) — set converter.yaml priority_classes to what the chart's queue.priorityClasses ships |
A campaign file called foo-part1.yaml |
the campaign name (taken from the file name) ends in "-part<number>", which is what the converter calls the parts of a campaign it splits — rename the file |
A tag instead of a digest in image: |
"image" is not pinned to a digest (got "repo/img:v5") — write image: <registry>/<repo>@sha256:<64 hex digits> |
namespace: written as anything but a DNS-1123 label |
"namespace" is not a Kubernetes namespace (got "htr.batch.example") — use lower-case letters, digits and "-", starting and ending with a letter or digit, at most 63 characters and no dots |
queue:, s3_secret:, data_pvc: or runtime_class: written as anything but a Kubernetes object name |
"queue" is not a Kubernetes object name (got "HTR-Batch") — use lower-case letters, digits, "-" and ".", starting and ending with a letter or digit, at most 253 characters |
A node_selector: key or value that is not a label |
"node_selector" has a key that is not a Kubernetes node label (got "Bad Key") — a key is a name, optionally after a "<dns-prefix>/"; both halves and the value are letters, digits, ".", "_" and "-", at most 63 characters |
allowed_image_repos: or require_model_revision: in converter.yaml |
allowed_image_repos moved to the htrflow-batch chart (security.allowedImageRepos, enforced by Kyverno) — remove it from converter.yaml |
Bare volume ids (- R1) with no source_template: in converter.yaml (one line per campaign) |
volumes "R1", "R2" and "R3" are bare reference codes, and converter.yaml has no source_template to turn them into manifest URLs — set source_template in converter.yaml (e.g. "https://iiif.example.org/{ref}/manifest"), or write each volume as "id:" with "manifest: <url>" |
A source_template: with no {ref} in it, with {ref} twice, or with any other placeholder |
"source_template" must have {ref} in it exactly once and nothing else in braces (got "https://iiif.example.org/{id}/manifest") — {ref} is where a campaign's bare volume id goes |
| A reader with fewer than two segmentation steps before it | "steps" put the lines that TrOCR (step 2) reads directly on the page, with 1 Segmentation step before it — htrflow's ALTO and PAGE export writes only the text of lines inside a region, so every page would publish without its text; put a region Segmentation step before the line step … |
steps: that is not a list |
"steps" must be a list of steps — write steps: and then "- step: <Name>" entries under it |
window: "5" (quoted, so YAML makes it text), window: 0, window: true |
"window" must be a whole number of 1 or more (got "5" — quotes make it text) |
max_seconds: likewise |
"max_seconds" must be a whole number of seconds, 1 or more (got 0) |
max_seconds: or ttl_seconds_after_finished: larger than a 32-bit field |
"max_seconds" must be 2147483647 or less (got 4294967296) — both are int32 Kubernetes fields |
manifest_max_bytes: or fetch_max_bytes: at 0 |
"fetch_max_bytes" must be 1 or more (got 0) |
suspend: maybe (or any other non-boolean) |
"suspend" must be true or false (got "maybe") |
| A setting given a list or a block where one value belongs | "window" must be a whole number (got a list) |
| A bad value inside a nested setting | "node_selector.a" must be text (got 1), "tolerations" entry 1 must be settings written as "key: value" lines (got 3) |
A key the file (converter.yaml or a pipeline) does not have |
"bogus_field" is not a setting this file has — remove it, or fix the spelling |
| A required key left out | "image" is missing — add "image:" to this file |
| Broken indentation or quoting | this file is not valid YAML — <the line and column PyYAML names> |
| A file that is a list, or free text | this file must be campaign settings written as "key: value" lines — a bare list or a piece of text is not one |
A campaign whose pipeline file is itself broken is not also told its
pipeline is missing. The wording is pinned in
packages/converter/tests/test_parse.py (EXPECTED).
Immutability
A pipeline id is a permanent name for a recipe: changing the steps, the
image or the size under an existing id is drift once results exist under it. To change a
recipe, mint a new id (demo-v2); old results under demo-v1 stay untouched
and comparable side by side.
validate and render enforce this while a campaign still runs the
pipeline. They compare each pipelines/<id>.yaml with the committed
rendered/pipelines/<id>.yaml, and refuse an edit to the image, steps or
size when a campaign still in campaigns/ was rendered against it:
pipeline demo-v1 changed (image) but campaigns kyrk, loc still run it — a pipeline is immutable while campaigns reference it; add a new pipeline file (demo-v1-2) and point new campaigns at it
apply holds the same rule against the cluster, in case rendered/ is
missing or behind: pipeline demo-v1 is in the cluster with different steps
and campaigns kyrk, loc still run it … — nothing was applied
(CLI → Checks).
Once no rendered campaign names the pipeline (a finished campaign's file is removed), the guard lets go. Not reusing an id whose results are published is then a convention enforced by review.
Only the recipe is held immutable, not the rendered manifest. A converter
upgrade or a converter.yaml change that alters every warm-up Job's pod
template makes apply
replace the warm-up Job. What a size means in converter.yaml is not the recipe
either: changing it changes the pod template of every campaign Job at that
size, and the API server refuses that for a Job that exists, so apply
reports it and leaves the Job as it was
(CLI). New numbers belong
under a new size name. The record names the size, not its numbers: the
pipeline's ConfigMap carries the size's name, and the numbers that ran are
the ones in converter.yaml at the commit that rendered it, which the
campaign's Job in rendered/ shows.
A campaign is append-only: render holds its volume list against
rendered/, and apply against the campaign's live ConfigMap, because a
Job's completions cannot change. New volumes go in a new campaign file.